diff --git a/.gitignore b/.gitignore
index 906dc226..b3006f90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,8 @@
+*~
+*.pyc
 .coverage
 .tox/
+dist/
 docs/_build/
 htmlcov/
 Tailbone.egg-info/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..c974b3a6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,683 @@
+
+# Changelog
+All notable changes to Tailbone will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+
+## v0.22.7 (2025-02-19)
+
+### Fix
+
+- stop using old config for logo image url on login page
+- fix warning msg for deprecated Grid param
+
+## v0.22.6 (2025-02-01)
+
+### Fix
+
+- register vue3 form component for products -> make batch
+
+## v0.22.5 (2024-12-16)
+
+### Fix
+
+- whoops this is latest rattail
+- require newer rattail lib
+- require newer wuttaweb
+- let caller request safe HTML literal for rendered grid table
+
+## v0.22.4 (2024-11-22)
+
+### Fix
+
+- avoid error in product search for duplicated key
+- use vmodel for confirm password widget input
+
+## v0.22.3 (2024-11-19)
+
+### Fix
+
+- avoid error for trainwreck query when not a customer
+
+## v0.22.2 (2024-11-18)
+
+### Fix
+
+- use local/custom enum for continuum operations
+- add basic master view for Product Costs
+- show continuum operation type when viewing version history
+- always define `app` attr for ViewSupplement
+- avoid deprecated import
+
+## v0.22.1 (2024-11-02)
+
+### Fix
+
+- fix submit button for running problem report
+- avoid deprecated grid method
+
+## v0.22.0 (2024-10-22)
+
+### Feat
+
+- add support for new ordering batch from parsed file
+
+### Fix
+
+- avoid deprecated method to suggest username
+
+## v0.21.11 (2024-10-03)
+
+### Fix
+
+- custom method for adding grid action
+- become/stop root should redirect to previous url
+
+## v0.21.10 (2024-09-15)
+
+### Fix
+
+- update project repo links, kallithea -> forgejo
+- use better icon for submit button on login page
+- wrap notes text for batch view
+- expose datasync consumer batch size via configure page
+
+## v0.21.9 (2024-08-28)
+
+### Fix
+
+- render custom attrs in form component tag
+
+## v0.21.8 (2024-08-28)
+
+### Fix
+
+- ignore session kwarg for `MasterView.make_row_grid()`
+
+## v0.21.7 (2024-08-28)
+
+### Fix
+
+- avoid error when form value cannot be obtained
+
+## v0.21.6 (2024-08-28)
+
+### Fix
+
+- avoid error when grid value cannot be obtained
+
+## v0.21.5 (2024-08-28)
+
+### Fix
+
+- set empty string for "-new-" file configure option
+
+## v0.21.4 (2024-08-26)
+
+### Fix
+
+- handle differing email profile keys for appinfo/configure
+
+## v0.21.3 (2024-08-26)
+
+### Fix
+
+- show non-standard config values for app info configure email
+
+## v0.21.2 (2024-08-26)
+
+### Fix
+
+- refactor waterpark base template to use wutta feedback component
+- fix input/output file upload feature for configure pages, per oruga
+- tweak how grid data translates to Vue template context
+- merge filters into main grid template
+- add basic wutta view for users
+- some fixes for wutta people view
+- various fixes for waterpark theme
+- avoid deprecated `component` form kwarg
+
+## v0.21.1 (2024-08-22)
+
+### Fix
+
+- misc. bugfixes per recent changes
+
+## v0.21.0 (2024-08-22)
+
+### Feat
+
+- move "most" filtering logic for grid class to wuttaweb
+- inherit from wuttaweb templates for home, login pages
+- inherit from wuttaweb for AppInfoView, appinfo/configure template
+- add "has output file templates" config option for master view
+
+### Fix
+
+- change grid reset-view param name to match wuttaweb
+- move "searchable columns" grid feature to wuttaweb
+- use wuttaweb to get/render csrf token
+- inherit from wuttaweb for appinfo/index template
+- prefer wuttaweb config for "home redirect to login" feature
+- fix master/index template rendering for waterpark theme
+- fix spacing for navbar logo/title in waterpark theme
+
+## v0.20.1 (2024-08-20)
+
+### Fix
+
+- fix default filter verbs logic for workorder status
+
+## v0.20.0 (2024-08-20)
+
+### Feat
+
+- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
+- refactor templates to simplify base/page/form structure
+
+### Fix
+
+- avoid deprecated reference to app db engine
+
+## v0.19.3 (2024-08-19)
+
+### Fix
+
+- add pager stats to all grid vue data (fixes view history)
+
+## v0.19.2 (2024-08-19)
+
+### Fix
+
+- sort on frontend for appinfo package listing grid
+- prefer attr over key lookup when getting model values
+- replace all occurrences of `component_studly` => `vue_component`
+
+## v0.19.1 (2024-08-19)
+
+### Fix
+
+- fix broken user auth for web API app
+
+## v0.19.0 (2024-08-18)
+
+### Feat
+
+- move multi-column grid sorting logic to wuttaweb
+- move single-column grid sorting logic to wuttaweb
+
+### Fix
+
+- fix misc. errors in grid template per wuttaweb
+- fix broken permission directives in web api startup
+
+## v0.18.0 (2024-08-16)
+
+### Feat
+
+- move "basic" grid pagination logic to wuttaweb
+- inherit from wutta base class for Grid
+- inherit most logic from wuttaweb, for GridAction
+
+### Fix
+
+- avoid route error in user view, when using wutta people view
+- fix some more wutta compat for base template
+
+## v0.17.0 (2024-08-15)
+
+### Feat
+
+- use wuttaweb for `get_liburl()` logic
+
+## v0.16.1 (2024-08-15)
+
+### Fix
+
+- improve wutta People view a bit
+- update references to `get_class_hierarchy()`
+- tweak template for `people/view_profile` per wutta compat
+
+## v0.16.0 (2024-08-15)
+
+### Feat
+
+- add first wutta-based master, for PersonView
+- refactor forms/grids/views/templates per wuttaweb compat
+
+## v0.15.6 (2024-08-13)
+
+### Fix
+
+- avoid `before_render` subscriber hook for web API
+- simplify verbiage for batch execution panel
+
+## v0.15.5 (2024-08-09)
+
+### Fix
+
+- assign convenience attrs for all views (config, app, enum, model)
+
+## v0.15.4 (2024-08-09)
+
+### Fix
+
+- avoid bug when checking current theme
+
+## v0.15.3 (2024-08-08)
+
+### Fix
+
+- fix timepicker `parseTime()` when value is null
+
+## v0.15.2 (2024-08-06)
+
+### Fix
+
+- use auth handler, avoid legacy calls for role/perm checks
+
+## v0.15.1 (2024-08-05)
+
+### Fix
+
+- move magic `b` template context var to wuttaweb
+
+## v0.15.0 (2024-08-05)
+
+### Feat
+
+- move more subscriber logic to wuttaweb
+
+### Fix
+
+- use wuttaweb logic for `util.get_form_data()`
+
+## v0.14.5 (2024-08-03)
+
+### Fix
+
+- use auth handler instead of deprecated auth functions
+- avoid duplicate `partial` param when grid reloads data
+
+## v0.14.4 (2024-07-18)
+
+### Fix
+
+- fix more settings persistence bug(s) for datasync/configure
+- fix modals for luigi tasks page, per oruga
+
+## v0.14.3 (2024-07-17)
+
+### Fix
+
+- fix auto-collapse title for viewing trainwreck txn
+- allow auto-collapse of header when viewing trainwreck txn
+
+## v0.14.2 (2024-07-15)
+
+### Fix
+
+- add null menu handler, for use with API apps
+
+## v0.14.1 (2024-07-14)
+
+### Fix
+
+- update usage of auth handler, per rattail changes
+- fix model reference in menu handler
+- fix bug when making "integration" menus
+
+## v0.14.0 (2024-07-14)
+
+### Feat
+
+- move core menu logic to wuttaweb
+
+## v0.13.2 (2024-07-13)
+
+### Fix
+
+- fix logic bug for datasync/config settings save
+
+## v0.13.1 (2024-07-13)
+
+### Fix
+
+- fix settings persistence bug(s) for datasync/configure page
+
+## v0.13.0 (2024-07-12)
+
+### Feat
+
+- begin integrating WuttaWeb as upstream dependency
+
+### Fix
+
+- cast enum as list to satisfy deform widget
+
+## v0.12.1 (2024-07-11)
+
+### Fix
+
+- refactor `config.get_model()` => `app.model`
+
+## v0.12.0 (2024-07-09)
+
+### Feat
+
+- drop python 3.6 support, use pyproject.toml (again)
+
+## v0.11.10 (2024-07-05)
+
+### Fix
+
+- make the Members tab optional, for profile view
+
+## v0.11.9 (2024-07-05)
+
+### Fix
+
+- do not show flash message when changing app theme
+
+- improve collapse panels for butterball theme
+
+- expand input for butterball theme
+
+- add xref button to customer profile, for trainwreck txn view
+
+- add optional Transactions tab for profile view
+
+## v0.11.8 (2024-07-04)
+
+### Fix
+
+- fix grid action icons for datasync/configure, per oruga
+
+- allow view supplements to add extra links for profile employee tab
+
+- leverage import handler method to determine command/subcommand
+
+- add tool to make user account from profile view
+
+## v0.11.7 (2024-07-04)
+
+### Fix
+
+- add stacklevel to deprecation warnings
+
+- require zope.sqlalchemy >= 1.5
+
+- include edit profile email/phone dialogs only if user has perms
+
+- allow view supplements to add to profile member context
+
+- cast enum as list to satisfy deform widget
+
+- expand POD image URL setting input
+
+## v0.11.6 (2024-07-01)
+
+### Fix
+
+- set explicit referrer when changing dbkey
+
+- remove references, dependency for `six` package
+
+## v0.11.5 (2024-06-30)
+
+### Fix
+
+- allow comma in numeric filter input
+
+- add custom url prefix if needed, for fanstatic
+
+- use vue 3.4.31 and oruga 0.8.12 by default
+
+## v0.11.4 (2024-06-30)
+
+### Fix
+
+- start/stop being root should submit POST instead of GET
+
+- require vendor when making new ordering batch via api
+
+- don't escape each address for email attempts grid
+
+## v0.11.3 (2024-06-28)
+
+### Fix
+
+- add link to "resolved by" user for pending products
+
+- handle error when merging 2 records fails
+
+## v0.11.2 (2024-06-18)
+
+### Fix
+
+- hide certain custorder settings if not applicable
+
+- use different logic for buefy/oruga for product lookup keydown
+
+- product records should be touchable
+
+- show flash error message if resolve pending product fails
+
+## v0.11.1 (2024-06-14)
+
+### Fix
+
+- revert back to setup.py + setup.cfg
+
+## v0.11.0 (2024-06-10)
+
+### Feat
+
+- switch from setup.cfg to pyproject.toml + hatchling
+
+## v0.10.16 (2024-06-10)
+
+### Feat
+
+- standardize how app, package versions are determined
+
+### Fix
+
+- avoid deprecated config methods for app/node title
+
+## v0.10.15 (2024-06-07)
+
+### Fix
+
+- do *not* Use `pkg_resources` to determine package versions
+
+## v0.10.14 (2024-06-06)
+
+### Fix
+
+- use `pkg_resources` to determine package versions
+
+## v0.10.13 (2024-06-06)
+
+### Feat
+
+- remove old/unused scaffold for use with `pcreate`
+
+- add 'fanstatic' support for sake of libcache assets
+
+## v0.10.12 (2024-06-04)
+
+### Feat
+
+- require pyramid 2.x; remove 1.x-style auth policies
+
+- remove version cap for deform
+
+- set explicit referrer when changing app theme
+
+- add `<b-tooltip>` component shim
+
+- include extra styles from `base_meta` template for butterball
+
+- include butterball theme by default for new apps
+
+### Fix
+
+- fix product lookup component, per butterball
+
+## v0.10.11 (2024-06-03)
+
+### Feat
+
+- fix vue3 refresh bugs for various views
+
+- fix grid bug for tempmon appliance view, per oruga
+
+- fix ordering worksheet generator, per butterball
+
+- fix inventory worksheet generator, per butterball
+
+## v0.10.10 (2024-06-03)
+
+### Feat
+
+- more butterball fixes for "view profile" template
+
+### Fix
+
+- fix focus for `<b-select>` shim component
+
+## v0.10.9 (2024-06-03)
+
+### Feat
+
+- let master view control context menu items for page
+
+- fix the "new custorder" page for butterball
+
+### Fix
+
+- fix panel style for PO vs. Invoice breakdown in receiving batch
+
+## v0.10.8 (2024-06-02)
+
+### Feat
+
+- add styling for checked grid rows, per oruga/butterball
+
+- fix product view template for oruga/butterball
+
+- allow per-user custom styles for butterball
+
+- use oruga 0.8.9 by default
+
+## v0.10.7 (2024-06-01)
+
+### Feat
+
+- add setting to allow decimal quantities for receiving
+
+- log error if registry has no rattail config
+
+- add column filters for import/export main grid
+
+- escape all unsafe html for grid data
+
+- add speedbumps for delete, set preferred email/phone in profile view
+
+- fix file upload widget for oruga
+
+### Fix
+
+- fix overflow when instance header title is too long (butterball)
+
+## v0.10.6 (2024-05-29)
+
+### Feat
+
+- add way to flag organic products within lookup dialog
+
+- expose db picker for butterball theme
+
+- expose quickie lookup for butterball theme
+
+- fix basic problems with people profile view, per butterball
+
+## v0.10.5 (2024-05-29)
+
+### Feat
+
+- add `<tailbone-timepicker>` component for oruga
+
+## v0.10.4 (2024-05-12)
+
+### Fix
+
+- fix styles for grid actions, per butterball
+
+## v0.10.3 (2024-05-10)
+
+### Fix
+
+- fix bug with grid date filters
+
+## v0.10.2 (2024-05-08)
+
+### Feat
+
+- remove version restriction for pyramid_beaker dependency
+
+- rename some attrs etc. for buefy components used with oruga
+
+- fix "tools" helper for receiving batch view, per oruga
+
+- more data type fixes for ``<tailbone-datepicker>``
+
+- fix "view receiving row" page, per oruga
+
+- tweak styles for grid action links, per butterball
+
+### Fix
+
+- fix employees grid when viewing department (per oruga)
+
+- fix login "enter" key behavior, per oruga
+
+- fix button text for autocomplete
+
+## v0.10.1 (2024-04-28)
+
+### Feat
+
+- sort list of available themes
+
+- update various icon names for oruga compatibility
+
+- show "View This" button when cloning a record
+
+- stop including 'falafel' as available theme
+
+### Fix
+
+- fix vertical alignment in main menu bar, for butterball
+
+- fix upgrade execution logic/UI per oruga
+
+## v0.10.0 (2024-04-28)
+
+This version bump is to reflect adding support for Vue 3 + Oruga via
+the 'butterball' theme.  There is likely more work to be done for that
+yet, but it mostly works at this point.
+
+### Feat
+
+- misc. template and view logic tweaks (applicable to all themes) for
+  better patterns, consistency etc.
+
+- add initial support for Vue 3 + Oruga, via "butterball" theme
+
+
+## Older Releases
+
+Please see `docs/OLDCHANGES.rst` for older release notes.
diff --git a/MANIFEST.in b/MANIFEST.in
index 0114904a..a3d57f93 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -11,6 +11,8 @@ recursive-include tailbone/static *.jpg
 recursive-include tailbone/static *.gif
 recursive-include tailbone/static *.ico
 
+recursive-include tailbone/static/files *
+
 recursive-include tailbone/templates *.mako
 recursive-include tailbone/templates *.pt
 recursive-include tailbone/reports *.mako
diff --git a/README.rst b/README.md
similarity index 56%
rename from README.rst
rename to README.md
index 0cffc62d..74c007f6 100644
--- a/README.rst
+++ b/README.md
@@ -1,10 +1,8 @@
 
-Tailbone
-========
+# Tailbone
 
 Tailbone is an extensible web application based on Rattail.  It provides a
 "back-office network environment" (BONE) for use in managing retail data.
 
-Please see Rattail's `home page`_ for more information.
-
-.. _home page: http://rattailproject.org/
+Please see Rattail's [home page](http://rattailproject.org/) for more
+information.
diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst
similarity index 64%
rename from CHANGES.rst
rename to docs/OLDCHANGES.rst
index 29e1128b..0a802f40 100644
--- a/CHANGES.rst
+++ b/docs/OLDCHANGES.rst
@@ -2,6 +2,2753 @@
 CHANGELOG
 =========
 
+NB. this file contains "old" release notes only.  for newer releases
+see the `CHANGELOG.md` file in the source root folder.
+
+
+0.9.96 (2024-04-25)
+-------------------
+
+* Remove unused code for ``webhelpers2_grid``.
+
+* Rename setting for custom user css (remove "buefy").
+
+* Fix permission checks for root user with pyramid 2.x.
+
+* Cleanup grid/filters logic a bit.
+
+* Use normal (not checkbox) button for grid filters.
+
+* Tweak icon for Download Results button.
+
+* Use v-model to track selection etc. for download results fields.
+
+* Allow deleting rows from executed batches.
+
+
+0.9.95 (2024-04-19)
+-------------------
+
+* Fix ASGI websockets when serving on sub-path under site root.
+
+* Fix raw query to avoid SQLAlchemy 2.x warnings.
+
+* Remove config "style" from appinfo page.
+
+
+0.9.94 (2024-04-16)
+-------------------
+
+* Fix master template bug when no form in context.
+
+
+0.9.93 (2024-04-16)
+-------------------
+
+* Improve form support for view supplements.
+
+* Prevent multi-click for grid filters "Save Defaults" button.
+
+* Fix typo when getting app instance.
+
+
+0.9.92 (2024-04-16)
+-------------------
+
+* Escape underscore char for "contains" query filter.
+
+* Rename custom ``user_css`` context.
+
+* Add support for Pyramid 2.x; new security policy.
+
+
+0.9.91 (2024-04-15)
+-------------------
+
+* Avoid uncaught error when updating order batch row quantities.
+
+* Try to return JSON error when receiving API call fails.
+
+* Avoid error for tax field when creating new department.
+
+* Show toast msg instead of silent error, when grid fetch fails.
+
+* Remove most references to "buefy" name in class methods, template
+  filenames etc.
+
+
+0.9.90 (2024-04-01)
+-------------------
+
+* Add basic CRUD for Person "preferred first name".
+
+
+0.9.89 (2024-03-27)
+-------------------
+
+* Fix bulk-delete rows for import/export batch.
+
+
+0.9.88 (2024-03-26)
+-------------------
+
+* Update some SQLAlchemy logic per upcoming 2.0 changes.
+
+
+0.9.87 (2023-12-26)
+-------------------
+
+* Auto-disable submit button for login form.
+
+* Hide single invoice file field for multi-invoice receiving batch.
+
+* Use common logic to render invoice total for receiving.
+
+* Expose default custorder discount for Departments.
+
+
+0.9.86 (2023-12-12)
+-------------------
+
+* Use ``ltrim(rtrim())`` instead of just ``trim()`` in grid filters.
+
+
+0.9.85 (2023-12-01)
+-------------------
+
+* Use clientele handler to populate customer dropdown widget.
+
+
+0.9.84 (2023-11-30)
+-------------------
+
+* Provide a way to show enum display text for some version diff fields.
+
+
+0.9.83 (2023-11-30)
+-------------------
+
+* Avoid error when editing a department.
+
+
+0.9.82 (2023-11-19)
+-------------------
+
+* Fix DB picker, theme picker per Buefy conventions.
+
+
+0.9.81 (2023-11-15)
+-------------------
+
+* Log warning instead of error for batch population error.
+
+* Remove reference to ``pytz`` library.
+
+* Avoid outright error if user scans barcode for inventory count.
+
+
+0.9.80 (2023-11-05)
+-------------------
+
+* Expose status code for equity payments.
+
+
+0.9.79 (2023-11-01)
+-------------------
+
+* Add button to confirm all costs for receiving.
+
+
+0.9.78 (2023-11-01)
+-------------------
+
+* Use shared logic to get batch handler.
+
+* Fix config key for default themes list.
+
+
+0.9.77 (2023-11-01)
+-------------------
+
+* Encode values for "between" query filter.
+
+* Avoid error when rendering version diff.
+
+
+0.9.76 (2023-11-01)
+-------------------
+
+* Fix missing import.
+
+
+0.9.75 (2023-11-01)
+-------------------
+
+* Add deprecation warnings for ambgiguous config keys.
+
+
+0.9.74 (2023-10-30)
+-------------------
+
+* Log warning / avoid error if email profile can't be normalized.
+
+
+0.9.73 (2023-10-29)
+-------------------
+
+* Add way to "ignore" a pending product.
+
+* Tweak param docs for ``Form.set_validator()``.
+
+* Remove unused "simple menus" module approach.
+
+
+0.9.72 (2023-10-26)
+-------------------
+
+* Use product lookup component for "resolve pending product" tool.
+
+
+0.9.71 (2023-10-25)
+-------------------
+
+* Fix bug when editing vendor.
+
+* Show user warning if "add item to custorder" fails.
+
+* Allow pending product fields to be required, for new custorder.
+
+* Add price confirm prompt when adding unknown item to custorder.
+
+* Use ``<b-select>`` for theme picker.
+
+* Add ``column_only`` kwarg for ``Grid.set_label()`` method.
+
+* Do not show profile buttons for inactive customer shoppers.
+
+* Add separate perm for making new custorder for unknown product.
+
+* Expand the "product lookup" component to include autocomplete.
+
+
+0.9.70 (2023-10-24)
+-------------------
+
+* Fix config file priority for display, and batch subprocess commands.
+
+
+0.9.69 (2023-10-24)
+-------------------
+
+* Allow override of version diff for master views.
+
+* No need to configure logging.
+
+
+0.9.68 (2023-10-23)
+-------------------
+
+* Expose more permissions for POS.
+
+* Fix order xlsx download if missing order date.
+
+* Replace dropdowns with autocomplete, for "find principals by perm".
+
+* Use ``Grid.make_sorter()`` instead of legacy code.
+
+* Avoid "None" when rendering product UOM field.
+
+* Fix default grid filter when "local" date times are involved.
+
+* Expose new fields for POS batch/row.
+
+* Remove sorter for "Credits?" column in purchasing batch row grid.
+
+* Add validation to prevent duplicate files for multi-invoice receiving.
+
+* Include invoice number for receiving batch row API.
+
+* Show food stamp tender info for POS batch.
+
+* Stop using sa-filters for basic grid sorting.
+
+
+0.9.67 (2023-10-12)
+-------------------
+
+* Fix grid sorting when column key/name differ.
+
+* Expose department tax, FS flag.
+
+* Add permission for testing error handling at POS.
+
+* Add some awareness of suspend/resume for POS batch.
+
+* Fix version child classes for Customers view.
+
+
+0.9.66 (2023-10-11)
+-------------------
+
+* Make grid JS ``loadAsyncData()`` method truly async.
+
+* Add support for multi-column grid sorting.
+
+* Add smarts to show display text for some version diff fields.
+
+* Allow null for FalafelDateTime form fields.
+
+* Show full version history within the "view" page.
+
+* Use autocomplete instead of dropdown for grid "add filter".
+
+
+0.9.65 (2023-10-07)
+-------------------
+
+* Avoid deprecated logic for fetching vendor contact email/phone.
+
+* Add "mark complete" button for inventory batch row entry page.
+
+* Expose tender ref in POS batch rows; new tender flags.
+
+* Improve views for taxes, esp. in POS batches.
+
+
+0.9.64 (2023-10-06)
+-------------------
+
+* Fix bug for param helptext in New Report page.
+
+
+0.9.63 (2023-10-06)
+-------------------
+
+* Fix CRUD pages for tempmon clients, probes.
+
+* Fix bug in POS batch view.
+
+* Expose permissions for POS, if so configured.
+
+
+0.9.62 (2023-10-04)
+-------------------
+
+* Avoid deprecated ``pretty_hours()`` function.
+
+* Improve master view ``oneoff_import()`` method.
+
+
+0.9.61 (2023-10-04)
+-------------------
+
+* Use enum to display ``POS_ROW_TYPE``.
+
+* Expose cash-back flags for tenders.
+
+* Re-work FalafelDateTime logic a bit.
+
+
+0.9.60 (2023-10-01)
+-------------------
+
+* Do not allow executing custorder if no customer is set.
+
+* Add clone support for POS batches.
+
+* Expose views for tenders, more columns for POS batch/rows.
+
+* Tidy up logic for vendor filtering in products grid.
+
+* Add support for void rows in POS batch.
+
+
+0.9.59 (2023-09-25)
+-------------------
+
+* Add custom form type/widget for time fields.
+
+
+0.9.58 (2023-09-25)
+-------------------
+
+* Expose POS batch views as "typical".
+
+
+0.9.57 (2023-09-24)
+-------------------
+
+* Show yesterday by default for Trainwreck if so configured.
+
+* Add ``remove_sorter()`` method for grids.
+
+* Show "true" (calculated) equity total in members grid.
+
+* Add basic views for POS batches.
+
+* Show customer for POS batches.
+
+* Use header button instead of link for "touch" instance.
+
+
+0.9.56 (2023-09-19)
+-------------------
+
+* Add link to vendor name for receiving batches grid.
+
+* Prevent catalog/invoice cost edits if receiving batch is complete.
+
+* Use small text input for receiving cost editor fields.
+
+* Show catalog/invoice costs as 2-decimal currency in receiving.
+
+
+0.9.55 (2023-09-18)
+-------------------
+
+* Show user warning if receive quick lookup fails.
+
+* Fix bug for new receiving from scratch via API.
+
+
+0.9.54 (2023-09-17)
+-------------------
+
+* Add "falafel" custom date/time field type and widget.
+
+* Avoid error when history has blanks for ordering worksheet.
+
+* Include PO number for receiving batch details via API.
+
+* Tweaks to improve handling of "missing" items for receiving.
+
+
+0.9.53 (2023-09-16)
+-------------------
+
+* Make member key field readonly when viewing equity payment.
+
+
+0.9.52 (2023-09-15)
+-------------------
+
+* Add basic feature for "grid totals".
+
+
+0.9.51 (2023-09-15)
+-------------------
+
+* Tweak default field list for batch views.
+
+* Add ``get_rattail_app()`` method for view supplements.
+
+
+0.9.50 (2023-09-12)
+-------------------
+
+* Avoid legacy logic for ``Customer.people`` schema.
+
+* Show events instead of notes, in field subgrid for custorder item.
+
+
+0.9.49 (2023-09-11)
+-------------------
+
+* Add custom hook for grid "apply filters".
+
+* Use common POST logic for submitting new customer order.
+
+* Optionally configure SQLAlchemy Session with ``future=True``.
+
+* Show related customer orders for Pending Product view.
+
+* Set stacklevel for all deprecation warnings.
+
+* Add support for toggling custorder item "flagged".
+
+* Add support for "mark received" when viewing custorder item.
+
+* Misc. improvements for custorder views.
+
+
+0.9.48 (2023-09-08)
+-------------------
+
+* Add grid link for equity payment description.
+
+* Fix msg body display, download link for email bounces.
+
+* Fix member key display for equity payment form.
+
+
+0.9.47 (2023-09-07)
+-------------------
+
+* Fallback to None when getting values for merge preview.
+
+
+0.9.46 (2023-09-07)
+-------------------
+
+* Improve display for member equity payments.
+
+
+0.9.45 (2023-09-02)
+-------------------
+
+* Add grid filter type for BigInteger columns.
+
+* Add products API route to fetch label profiles for use w/ printing.
+
+* Tweaks for cost editing within a receiving batch.
+
+
+0.9.44 (2023-08-31)
+-------------------
+
+* Avoid deprecated ``User.email_address`` property.
+
+* Preserve URL hash when redirecting in grid "reset to defaults".
+
+
+0.9.43 (2023-08-30)
+-------------------
+
+* Let "new product" batch override type-2 UPC lookup behavior.
+
+
+0.9.42 (2023-08-29)
+-------------------
+
+* When bulk-deleting, skip objects which are not "deletable".
+
+* Declare "from PO" receiving workflow if applicable, in API.
+
+* Auto-select text when editing costs for receiving.
+
+* Include shopper history from parent customer account perspective.
+
+* Link to product record, for New Product batch row.
+
+* Fix profile history to show when a CustomerShopperHistory is deleted.
+
+* Fairly massive overhaul of the Profile view; standardize tabs etc..
+
+* Add support for "missing" credit in mobile receiving.
+
+
+0.9.41 (2023-08-08)
+-------------------
+
+* Add common logic to validate employee reference field.
+
+* Fix HTML rendering for UOM choice options.
+
+* Fix custom cell click handlers in main buefy grid tables.
+
+
+0.9.40 (2023-08-03)
+-------------------
+
+* Make system key searchable for problem report grid.
+
+
+0.9.39 (2023-07-15)
+-------------------
+
+* Show invoice number for each row in receiving.
+
+* Tweak display options for tempmon probe readings graph.
+
+
+0.9.38 (2023-07-07)
+-------------------
+
+* Optimize "auto-receive" batch process.
+
+
+0.9.37 (2023-07-03)
+-------------------
+
+* Avoid deprecated product key field getter.
+
+* Allow "arbitrary" PO attachment to purchase batch.
+
+
+0.9.36 (2023-06-20)
+-------------------
+
+* Include user "active" flag in profile view context.
+
+
+0.9.35 (2023-06-20)
+-------------------
+
+* Add views etc. for member equity payments.
+
+* Improve merge support for records with no uuid.
+
+* Turn on quickie person search for CustomerShopper views.
+
+
+0.9.34 (2023-06-17)
+-------------------
+
+* Add basic Shopper tab for profile view.
+
+* Cleanup some wording in profile view template.
+
+* Tweak ``SimpleRequestMixin`` to not rely on ``response.data.ok``.
+
+* Add support for Notes tab in profile view.
+
+* Add basic support for Person quickie lookup.
+
+* Hide unwanted revisions for CustomerPerson etc.
+
+* Fix some things for viewing a member.
+
+
+0.9.33 (2023-06-16)
+-------------------
+
+* Update usage of app handler per upstream changes.
+
+
+0.9.32 (2023-06-16)
+-------------------
+
+* Fix grid filter bug when switching from 'equal' to 'between' verbs.
+
+* Add users context data for profile view.
+
+* Join the Person model for Customers grid differently based on config.
+
+
+0.9.31 (2023-06-15)
+-------------------
+
+* Prefer account holder, shoppers over legacy ``Customers.people``.
+
+
+0.9.30 (2023-06-12)
+-------------------
+
+* Add basic support for exposing ``Customer.shoppers``.
+
+* Move "view history" and related buttons, for person profile view.
+
+* Consider vendor catalog batch views "typical".
+
+* Let external customer link buttons be more dynamic, for profile view.
+
+* Add options for grid results to link straight to Profile view.
+
+* Change label for Member.person to "Account Holder".
+
+
+0.9.29 (2023-06-06)
+-------------------
+
+* Add "typical" view config, for e.g. Theo and the like.
+
+* Add customer number filter for People grid.
+
+* Tweak logic for ``MasterView.get_action_route_kwargs()``.
+
+* Add "touch" support for Members.
+
+* Add support for "configured customer/member key".
+
+* Use *actual* current URL for user feedback msg.
+
+* Remove old/unused feedback templates.
+
+* Add basic support for membership types.
+
+* Add support for version history in person profile view.
+
+
+0.9.28 (2023-06-02)
+-------------------
+
+* Expose mail handler and template paths in email config page.
+
+
+0.9.27 (2023-06-01)
+-------------------
+
+* Share some code for validating vendor field.
+
+* Save datasync config with new keys, per RattailConfiguration.
+
+
+0.9.26 (2023-05-25)
+-------------------
+
+* Prevent bug in upgrade diff for empty new version.
+
+* Expose basic way to send test email.
+
+* Avoid error when filter params not valid.
+
+* Tweak byjove project generator form.
+
+* Define essential views for API.
+
+
+0.9.25 (2023-05-18)
+-------------------
+
+* Add initial swagger.json endpoint for API.
+
+* Add workaround for "share grid link" on insecure sites.
+
+
+0.9.24 (2023-05-16)
+-------------------
+
+* Replace ``setup.py`` contents with ``setup.cfg``.
+
+* Prevent error in old product search logic.
+
+
+0.9.23 (2023-05-15)
+-------------------
+
+* Get rid of ``newstyle`` flag for ``Form.validate()`` method.
+
+* Add basic support for managing, and accepting API tokens.
+
+
+0.9.22 (2023-05-13)
+-------------------
+
+* Tweak button wording in "find role by perm" form.
+
+* Warn user if DB not up to date, in new table wizard.
+
+
+0.9.21 (2023-05-10)
+-------------------
+
+* Move row delete check logic for receiving to batch handler.
+
+
+0.9.20 (2023-05-09)
+-------------------
+
+* Add form config for generating 'shopfoo' projects.
+
+* Misc. tweaks for "run import job" form.
+
+
+0.9.19 (2023-05-05)
+-------------------
+
+* Massive overhaul of "generate project" feature.
+
+* Include project views by default, in "essential" views.
+
+
+0.9.18 (2023-05-03)
+-------------------
+
+* Avoid error if tempmon probe has invalid status.
+
+* Expose, honor the ``prevent_password_change`` flag for Users.
+
+
+0.9.17 (2023-04-17)
+-------------------
+
+* Allow bulk-delete for products grid.
+
+* Improve global menu search behavior for multiple terms.
+
+
+0.9.16 (2023-03-27)
+-------------------
+
+* Avoid accidental auto-submit of new msg form, for subject field.
+
+* Add ``has_perm()`` etc. to request during the NewRequest event.
+
+* Fix table sorting for FK reference column in new table wizard.
+
+* Overhaul the "find by perm" feature a bit.
+
+
+0.9.15 (2023-03-15)
+-------------------
+
+* Remove version workaround for sphinx.
+
+* Let providers do DB connection setup for web API.
+
+
+0.9.14 (2023-03-09)
+-------------------
+
+* Fix JSON rendering for Cornice API views.
+
+
+0.9.13 (2023-03-08)
+-------------------
+
+* Remove version cap for cornice, now that we require python3.
+
+
+0.9.12 (2023-03-02)
+-------------------
+
+* Add "equal to any of" verb for string-type grid filters.
+
+* Allow download results for Trainwreck.
+
+
+0.9.11 (2023-02-24)
+-------------------
+
+* Allow sort/filter by vendor for sample files grid.
+
+
+0.9.10 (2023-02-22)
+-------------------
+
+* Add views for sample vendor files.
+
+
+0.9.9 (2023-02-21)
+------------------
+
+* Validate vendor for catalog batch upload.
+
+
+0.9.8 (2023-02-20)
+------------------
+
+* Make ``config`` param more explicit, for GridFilter constructor.
+
+
+0.9.7 (2023-02-14)
+------------------
+
+* Add dedicated view config methods for "view" and "edit help".
+
+
+0.9.6 (2023-02-12)
+------------------
+
+* Refactor ``Query.get()`` => ``Session.get()`` per SQLAlchemy 1.4.
+
+
+0.9.5 (2023-02-11)
+------------------
+
+* Use sa-filters instead of sqlalchemy-filters for API queries.
+
+
+0.9.4 (2023-02-11)
+------------------
+
+* Remove legacy grid for alt codes in product view.
+
+
+0.9.3 (2023-02-10)
+------------------
+
+* Add dependency for pyramid_retry.
+
+* Use latest zope.sqlalchemy package.
+
+* Fix auto-advance on ENTER for login form.
+
+* Use label handler to avoid deprecated logic.
+
+* Remove legacy vendor sources grid for product view.
+
+* Expose setting for POD image URL.
+
+* Fix multi-file upload widget bug.
+
+
+0.9.2 (2023-02-03)
+------------------
+
+* Fix auto-focus username for login form.
+
+
+0.9.1 (2023-02-03)
+------------------
+
+* Stop including deform JS static files.
+
+
+0.9.0 (2023-02-03)
+------------------
+
+* Officially drop support for python2.
+
+* Remove all deprecated jquery and ``use_buefy`` logic.
+
+* Add new Buefy-specific upgrade template.
+
+* Replace 'default' theme to match 'falafel'.
+
+* Allow editing the Department field for a Subdepartment.
+
+* Refactor the Ordering Worksheet generator, per Buefy.
+
+
+0.8.292 (2023-02-02)
+--------------------
+
+* Always assume ``use_buefy=True`` within main page template.
+
+
+0.8.291 (2023-02-02)
+--------------------
+
+* Fix checkbox behavior for Inventory Worksheet.
+
+* Form constructor assumes ``use_buefy=True`` by default.
+
+
+0.8.290 (2023-02-02)
+--------------------
+
+* Remove support for Buefy 0.8.
+
+* Add progress bar page for Buefy theme.
+
+
+0.8.289 (2023-01-30)
+--------------------
+
+* Fix icon for multi-file upload widget.
+
+* Tweak customer panel header style for new custorder.
+
+* Add basic API support for printing product labels.
+
+* Tweak the Ordering Worksheet generator, per Buefy.
+
+* Refactor the Inventory Worksheet generator, per Buefy.
+
+
+0.8.288 (2023-01-28)
+--------------------
+
+* Tweak import handler form, some fields not required.
+
+* Tweak styles for Quantity panel when viewing Receiving row.
+
+
+0.8.287 (2023-01-26)
+--------------------
+
+* Fix click event for right-aligned buttons on profile view.
+
+
+0.8.286 (2023-01-18)
+--------------------
+
+* Add some more menu items to default set.
+
+* Add default view config for Trainwreck.
+
+* Rename frontend request handler logic to ``SimpleRequestMixin``.
+
+
+0.8.285 (2023-01-18)
+--------------------
+
+* Misc. tweaks for App Details / Configure Menus.
+
+* Add specific data type options for new table entry form.
+
+* Add more views, menus to default set.
+
+* Add way to override particular 'essential' views.
+
+
+0.8.284 (2023-01-15)
+--------------------
+
+* Let the API "rawbytes" response be just that, w/ no file.
+
+* Fix bug when adding new profile via datasync configure.
+
+* Add default logic to get merge data for object.
+
+* Add new handlers, TailboneHandler and MenuHandler.
+
+* Add full set of default menus.
+
+* Wrap up steps for new table wizard.
+
+* Add basic "new model view" wizard.
+
+
+0.8.283 (2023-01-14)
+--------------------
+
+* Tweak how backfill task is launched.
+
+
+0.8.282 (2023-01-13)
+--------------------
+
+* Show basic column info as row grid when viewing Table.
+
+* Semi-finish logic for writing new table model class to file.
+
+* Fix "toggle batch complete" for Chrome browser.
+
+* Revert logic that assumes all themes use buefy.
+
+* Refactor tempmon dashboard view, for buefy themes.
+
+* Prevent listing for top-level Messages view.
+
+
+0.8.281 (2023-01-12)
+--------------------
+
+* Add new views for App Info, and Configure App.
+
+
+0.8.280 (2023-01-11)
+--------------------
+
+* Allow all external dependency URLs to be set in config.
+
+
+0.8.279 (2023-01-11)
+--------------------
+
+* Add basic support for receiving from multiple invoice files.
+
+* Add support for per-item default discount, for new custorder.
+
+* Fix panel header icon behavior for new custorder.
+
+* Refactor inventory batch "add row" page, per new theme.
+
+
+0.8.278 (2023-01-08)
+--------------------
+
+* Improve "download rows as XLSX" for importer batch.
+
+
+0.8.277 (2023-01-07)
+--------------------
+
+* Expose, start to honor "units only" setting for products.
+
+
+0.8.276 (2023-01-05)
+--------------------
+
+* Keep aspect ratio for product images in new custorder.
+
+* Fix template bug for generating report.
+
+* Show help link when generating or viewing report, if applicable.
+
+* Use product handler to normalize data for products API.
+
+
+0.8.275 (2023-01-04)
+--------------------
+
+* Allow xref buttons to have "internal" links.
+
+
+0.8.274 (2023-01-02)
+--------------------
+
+* Show only "core" app settings by default.
+
+* Allow buefy version to be 'latest'.
+
+* Add beginnings of "New Table" feature.
+
+* Make invalid email more obvious, in profile view.
+
+* Expose some settings for Trainwreck DB rotation.
+
+
+0.8.273 (2022-12-28)
+--------------------
+
+* Add support for Buefy 0.9.x.
+
+* Warn user when luigi is not installed, for relevant view.
+
+* Fix HUD display when toggling employee status in profile view.
+
+* Fix checkbox values when re-running a report.
+
+* Make static files optional, for new tailbone-integration project.
+
+* Preserve current tab for page reload in profile view.
+
+* Add cleanup logic for old Beaker session data.
+
+* Add basic support for editing help info for page, fields.
+
+* Override document title when upgrading.
+
+* Filter by person instead of user, for Generated Reports "Created by".
+
+* Add "direct link" support for master grids.
+
+* Add support for websockets over HTTP.
+
+* Fix product image view for python3.
+
+* Add "global searchbox" for quicker access to main views.
+
+* Use minified version of vue.js by default, in falafel theme.
+
+
+0.8.272 (2022-12-21)
+--------------------
+
+* Add support for "is row checkable" in grids.
+
+* Add ``make_status_renderer()`` to MasterView.
+
+* Expose the ``terms`` field for Vendor CRUD.
+
+
+0.8.271 (2022-12-15)
+--------------------
+
+* Add ``configure_execute_form()`` hook for batch views.
+
+
+0.8.270 (2022-12-10)
+--------------------
+
+* Fix error if no view supplements defined.
+
+
+0.8.269 (2022-12-10)
+--------------------
+
+* Show simple error string, when subprocess batch actions fail.
+
+* Fix ordering worksheet API for date objects.
+
+* Add the ViewSupplement concept.
+
+* Cleanup employees view per new supplements.
+
+* Add common logic for xref buttons, links when viewing object.
+
+* Add common logic to determine panel fields for product view.
+
+* Add xref buttons for Customer, Member tabs in profile view.
+
+* Suppress error if menu entry has bad route name.
+
+
+0.8.268 (2022-12-07)
+--------------------
+
+* Add support for Beaker >= 1.12.0.
+
+
+0.8.267 (2022-12-06)
+--------------------
+
+* Fix bug when viewing certain receiving batches.
+
+
+0.8.266 (2022-12-06)
+--------------------
+
+* Add simple template hook for "before object helpers".
+
+* Include email address for current API user info.
+
+* Add support for editing catalog cost in receiving batch, per new theme.
+
+* Add receiving workflow as param when making receiving batch.
+
+* Show invoice cost in receiving batch, if "from scratch".
+
+* Add support for editing invoice cost in receiving batch, per new theme.
+
+* Add helptext for "Admin-ish" field when editing Role.
+
+
+0.8.265 (2022-12-01)
+--------------------
+
+* Add way to quickly re-run "any" report.
+
+* Avoid web config when launching overnight task.
+
+
+0.8.264 (2022-11-28)
+--------------------
+
+* Add prompt dialog when launching overnight task.
+
+* Fix page title for datasync status.
+
+* Use newer config strategy for all views.
+
+* Auto-format phone number when saving for contact records.
+
+
+0.8.263 (2022-11-21)
+--------------------
+
+* Update 'testing' watermark for dev background.
+
+* Let the Luigi handler take care of removing some DB settings.
+
+
+0.8.262 (2022-11-20)
+--------------------
+
+* Add luigi module/class awareness for overnight tasks.
+
+
+0.8.261 (2022-11-20)
+--------------------
+
+* Allow disabling, or per-day scheduling, of problem reports.
+
+* Fix how keys are stored for luigi overnight/backfill tasks.
+
+
+0.8.260 (2022-11-18)
+--------------------
+
+* Turn on download results feature for Employees.
+
+
+0.8.259 (2022-11-17)
+--------------------
+
+* Add "between" verb for numeric grid filters.
+
+
+0.8.258 (2022-11-15)
+--------------------
+
+* Let the auth handler manage user merge.
+
+
+0.8.257 (2022-11-03)
+--------------------
+
+* Add template method for rendering row grid component.
+
+* Use people handler to update address.
+
+* Fix start_date param for pricing batch upload.
+
+* Use shared logic for rendering percentage values.
+
+* Log a warning to troubleshoot luigi restart failure.
+
+* Show UPC for receiving line item if no product reference.
+
+
+0.8.256 (2022-09-09)
+--------------------
+
+* Add basic per-item discount support for custorders.
+
+* Make past item lookup optional for custorders.
+
+* Do not convert date if already a date (for grid filters).
+
+* Avoid use of ``self.handler`` within batch API views.
+
+
+0.8.255 (2022-09-06)
+--------------------
+
+* Include ``WorkOrder.estimated_total`` for API.
+
+* Add default normalize logic for API views.
+
+* Disable "Delete Results" button if no results, for row grid.
+
+* Move logic for "bulk-delete row objects" into MasterView.
+
+* Convert value for more date filters; only add condition if valid.
+
+
+0.8.254 (2022-08-30)
+--------------------
+
+* Improve parsing of purchase order quantities.
+
+* Expose more attrs for new product batch rows.
+
+
+0.8.253 (2022-08-30)
+--------------------
+
+* Convert value for date filter; only add condition if valid.
+
+* Add 'warning' flash messages to old jquery base template.
+
+* Add uom fields, configurable template for newproduct batch.
+
+
+0.8.252 (2022-08-25)
+--------------------
+
+* Avoid error when no datasync profiles configured.
+
+* Add max lengths when editing person name via profile view.
+
+
+0.8.251 (2022-08-24)
+--------------------
+
+* Fix index title for datasync configure page.
+
+* Add basic support for backfill Luigi tasks.
+
+
+0.8.250 (2022-08-21)
+--------------------
+
+* Add ``render_person_profile()`` method to MasterView.
+
+* Add way to declare failure for an upgrade.
+
+* Add websockets progress, "multi-system" support for upgrades.
+
+* Add global context from handler, for email previews.
+
+* Allow configuring datasync watcher kwargs.
+
+* Expose, honor "admin-ish" flag for roles.
+
+
+0.8.249 (2022-08-18)
+--------------------
+
+* Add brief delay before declaring websocket broken.
+
+* Add basic views for Luigi / overnight tasks.
+
+* Expose setting for auto-correct when receiving from invoice.
+
+
+0.8.248 (2022-08-17)
+--------------------
+
+* Redirect to custom index URL when user cancels new custorder entry.
+
+* Add ``get_next_url_after_submit_new_order()`` for customer orders.
+
+* Add first experiment with websockets, for datasync status page.
+
+* Allow user feedback to request email reply back.
+
+
+0.8.247 (2022-08-14)
+--------------------
+
+* Avoid double-quotes in field error messages JS code.
+
+* Add the FormPosterMixin to ProfileInfo component.
+
+* Fix default help URLs for ordering, receiving.
+
+* Move handheld batch view module to appropriate location.
+
+* Refactor usage of ``get_vendor()`` lookup.
+
+* Consolidate master API view logic.
+
+
+0.8.246 (2022-08-12)
+--------------------
+
+* Couple of API tweaks for work orders.
+
+* Standardize merge logic when a handler is defined for it.
+
+
+0.8.245 (2022-08-10)
+--------------------
+
+* Add convenience wrapper to make customer field widget, etc..
+
+* Some API tweaks to support a byjove app.
+
+* Tweak flash msg, logging when batch population fails.
+
+* Log traceback output when batch action subprocess fails.
+
+* Add initial views for work orders.
+
+* Fix sequence of events re: grid component creation.
+
+* Allow download results for Customers grid.
+
+
+0.8.244 (2022-08-08)
+--------------------
+
+* Add separate product grid filters for Category Code, Category Name.
+
+
+0.8.243 (2022-08-08)
+--------------------
+
+* Add button to raise bogus error, for testing email alerts.
+
+* Make sure "configure" pages use AppHandler to save/delete settings.
+
+* Expose setting for sendmail failure alerts.
+
+
+0.8.242 (2022-08-07)
+--------------------
+
+* Always show "all" email settings if user has config perm.
+
+
+0.8.241 (2022-08-06)
+--------------------
+
+* Add support for toggling visibility of email profile settings.
+
+
+0.8.240 (2022-08-05)
+--------------------
+
+* Clean up URL routes for row CRUD.
+
+
+0.8.239 (2022-08-04)
+--------------------
+
+* Invalidate config cache when raw setting is deleted.
+
+
+0.8.238 (2022-08-03)
+--------------------
+
+* Improve "touch" logic for employees.
+
+* Stop using the old ``rattail.db.api.settings`` module.
+
+* Force cache invalidation when Raw Setting is edited.
+
+
+0.8.237 (2022-07-27)
+--------------------
+
+* Add some more views to potentially include via poser.
+
+* Misc. improvements for desktop receiving views.
+
+
+0.8.236 (2022-07-25)
+--------------------
+
+* Add setting to expose/hide "active in POS" customer flag.
+
+* Allow optional row grid title for master view.
+
+* Add basic/minimal merge support for customers.
+
+* Assume default vendor for new receiving batch.
+
+* Add basic edit support for Purchases.
+
+* Add ``iter(Form)`` logic, to loop through fields.
+
+* Add "auto-receive all items" support for receiving batch API.
+
+
+0.8.235 (2022-07-22)
+--------------------
+
+* Split out rendering of ``this-page`` component in falafel theme.
+
+* Allow download of results for common product-related tables.
+
+* Make caching products optional, when creating vendor catalog batch.
+
+* Expose the ``complete`` flag for pricing batch.
+
+* Add ``template_kwargs_clone()`` stub for master view.
+
+* Misc deform template improvements.
+
+
+0.8.234 (2022-07-18)
+--------------------
+
+* Fix form validation for app settings page w/ buefy theme.
+
+* Honor default pagesize for all grids, per setting.
+
+* Add basic "download results" for Subdepartments grid.
+
+* Add new-style config defaults for BrandView.
+
+
+0.8.233 (2022-06-24)
+--------------------
+
+* Add minimal buefy support for 'percentinput' field widget.
+
+* Add autocomplete support for subdepartments.
+
+
+0.8.232 (2022-06-14)
+--------------------
+
+* Let default grid page size correspond to first option.
+
+* Add start date support for "future" pricing batch.
+
+
+0.8.231 (2022-05-15)
+--------------------
+
+* Expose config for identifying supported vendors.
+
+* Allow restricting to supported vendors only, for Receiving.
+
+
+0.8.230 (2022-05-10)
+--------------------
+
+* Sort roles list when viewing a user.
+
+* Add grid workarounds when data is list instead of query.
+
+
+0.8.229 (2022-05-03)
+--------------------
+
+* Tweak how family data is displayed.
+
+
+0.8.228 (2022-04-13)
+--------------------
+
+* Fix quotes for field helptext.
+
+* Flush early when populating batch, to ensure error is shown.
+
+
+0.8.227 (2022-04-04)
+--------------------
+
+* Add touch for report codes.
+
+* Raise 404 if report not found.
+
+* Add template kwargs stub for ``view_row()``.
+
+* Log error when failing to submit new custorder batch.
+
+* Honor case vs. unit restrictions for new custorder.
+
+* Tweak where description field is shown for receiving batch.
+
+* Fix "touch" url for non-standard record types.
+
+
+0.8.226 (2022-03-29)
+--------------------
+
+* Let errors raise when showing poser reports.
+
+
+0.8.225 (2022-03-29)
+--------------------
+
+* Force session flush within try/catch, for batch refresh.
+
+
+0.8.224 (2022-03-25)
+--------------------
+
+* Improve vendor validation for new receiving batch.
+
+* Use common logic for fetching batch handler.
+
+
+0.8.223 (2022-03-21)
+--------------------
+
+* Show link to txn as field when viewing trainwreck item.
+
+
+0.8.222 (2022-03-17)
+--------------------
+
+* Expose custorder xref markers for trainwreck.
+
+
+0.8.221 (2022-03-16)
+--------------------
+
+* Always show batch params by default when viewing.
+
+* Show helptext when applicable for "new batch from product query".
+
+* Make problem report titles searchable in grid.
+
+
+0.8.220 (2022-03-15)
+--------------------
+
+* Log error instead of warning, when batch population fails.
+
+* Add default help link for Receiving feature.
+
+
+0.8.219 (2022-03-10)
+--------------------
+
+* Cleanup grid filters for vendor catalog batches.
+
+* Cleanup view config syntax for vendor catalog batch.
+
+* Add workaround when inserting new fields to form field list.
+
+* Add ``Form.insert()`` method, to insert field based on index.
+
+* Default behavior for report chooser should *not* be form/dropdown.
+
+
+0.8.218 (2022-03-08)
+--------------------
+
+* Log warning/traceback when failing to include a configured view.
+
+* Fix gotcha when defining new provider views.
+
+* Bump the default Buefy version to 0.8.13.
+
+
+0.8.217 (2022-03-07)
+--------------------
+
+* Add the "provider" concept, let them configure db sessions.
+
+* Let providers add extra views, options for includes config.
+
+* Let tailbone providers include static views.
+
+* Link to email settings profile when viewing email attempt.
+
+
+0.8.216 (2022-03-05)
+--------------------
+
+* Show list of generated reports when viewing Poser Report.
+
+* Show link back to Poser Report when viewing Generated Report.
+
+* Always include ``app_title`` in global template rendering context.
+
+* Update some more view config syntax.
+
+* Make common web view a bit more common.
+
+* Improve the Poser Setup page; allow poser dir refresh.
+
+* Add initial/basic support for configuring "included views".
+
+* Add ``tailbone.views.essentials`` to include common / "core" views.
+
+* Add flash message when upgrade execution completes (pass or fail).
+
+
+0.8.215 (2022-03-02)
+--------------------
+
+* Show toast msg instead of alert after sending feedback.
+
+* Add basic support for Poser reports, list/create.
+
+
+0.8.214 (2022-03-01)
+--------------------
+
+* Params should be readonly when editing batch.
+
+* Tweak styles for links in object helper panel.
+
+
+0.8.213 (2022-03-01)
+--------------------
+
+* Add simple searchable column support for non-AJAX grids.
+
+* Fix stdout/stderr fields for upgrade view.
+
+* Pass query along for download results, so subclass can modify.
+
+* Avoid making discounts data if missing field, for trainwreck item view.
+
+
+0.8.212 (2022-02-26)
+--------------------
+
+* Add page/way to configure main menus.
+
+
+0.8.211 (2022-02-25)
+--------------------
+
+* Add view template stub for trainwreck transaction.
+
+* Add auto-filter hyperlinks for batch row status breakdown.
+
+* Auto-filter hyperlinks for PO vs. invoice breakdown in Receiving.
+
+* Add grid hyperlinks for trainwreck transaction line items.
+
+* Use dict instead of custom object to represent menus.
+
+* Expose "discount type" for Trainwreck line items.
+
+
+0.8.210 (2022-02-20)
+--------------------
+
+* Only show DB picker for permissioned users.
+
+* Expose some new trainwreck fields; per-item discounts.
+
+* Show SRP as currency for vendor catalog batch.
+
+
+0.8.209 (2022-02-16)
+--------------------
+
+* Fix progress bar when running problem report.
+
+
+0.8.208 (2022-02-15)
+--------------------
+
+* Allow override of navbar-end element in falafel theme header.
+
+* Add initial support for editing user preferences.
+
+* Add FormPosterMixin to WholePage class.
+
+
+0.8.207 (2022-02-13)
+--------------------
+
+* Try out new config defaults function for some views (user, customer).
+
+* Add highlight for non-active users, customers in grid.
+
+* Prevent cache for index pages by default, unless configured not to.
+
+* Cleanup labels for Vendor/Code "preferred" vs. "any" in products grid.
+
+* Add config for showing ordered vs. shipped amounts when receiving.
+
+* Tweak how "duration" fields are rendered for grids, forms.
+
+* New upgrades should be enabled by default.
+
+
+0.8.206 (2022-02-08)
+--------------------
+
+* Add "full lookup" product search modal for new custorder page.
+
+
+0.8.205 (2022-02-05)
+--------------------
+
+* Tweak how product key field is handled for product views.
+
+* Add some autocomplete workarounds for new vendor catalog batch.
+
+
+0.8.204 (2022-02-04)
+--------------------
+
+* Add ``CustomerGroupAssignment`` to customer version history.
+
+
+0.8.203 (2022-02-01)
+--------------------
+
+* Expose batch params for vendor catalogs.
+
+
+0.8.202 (2022-01-31)
+--------------------
+
+* Make "generate report" the same as "create new generated report".
+
+
+0.8.201 (2022-01-31)
+--------------------
+
+* Show helptext for params when generating new report.
+
+* Tweak handling of empty params when generating report.
+
+
+0.8.200 (2022-01-31)
+--------------------
+
+* Improve profile link helper for buefy themes.
+
+* Add project generator support for rattail-integration, tailbone-integration.
+
+
+0.8.199 (2022-01-26)
+--------------------
+
+* Tweak the "auto-receive all" tool for Chrome browser.
+
+
+0.8.198 (2022-01-25)
+--------------------
+
+* Only expose "product" departments within product view dropdowns.
+
+
+0.8.197 (2022-01-19)
+--------------------
+
+* Use buefy input for quickie search.
+
+
+0.8.196 (2022-01-15)
+--------------------
+
+* Use the new label handler.
+
+
+0.8.195 (2022-01-13)
+--------------------
+
+* Strip whitespace for new customer fields, in new custorder page.
+
+
+0.8.194 (2022-01-12)
+--------------------
+
+* Include all static files in manifest.
+
+* Update usage of ``app.get_email_handler()`` to avoid warnings.
+
+
+0.8.193 (2022-01-10)
+--------------------
+
+* Add buefy support for quick-printing product labels; also speed bump.
+
+* Add way to set form-wide schema validator.
+
+* Add progress support when deleting a batch.
+
+* Expose the Sale, TPR, Current price fields for label batch.
+
+
+0.8.192 (2022-01-08)
+--------------------
+
+* Add configurable template file for vendor catalog batch.
+
+* Some aesthetic improvements for vendor catalog batch.
+
+* Several disparate changes needed for vendor catalog improvements.
+
+* Expose, honor "allow future" setting for vendor catalog batch.
+
+* Add config for supported vendor catalog parsers.
+
+* Update some method calls to avoid deprecation warnings.
+
+
+0.8.191 (2022-01-03)
+--------------------
+
+* Fix permission check for input file template links.
+
+* Remove usage of ``app.get_designated_import_handler()``.
+
+* Add basic configure page for Trainwreck.
+
+* Use ``AuthHandler.get_permissions()``.
+
+
+0.8.190 (2021-12-29)
+--------------------
+
+* Show create button on "most" pages for a master view.
+
+* Expose products setting for type 2 UPC lookup.
+
+* Add basic "resolve" support for person, product from new custorder.
+
+
+0.8.189 (2021-12-23)
+--------------------
+
+* Add basic "pending product" support for new custorder batch.
+
+* Improve email bounce view per buefy theme.
+
+
+0.8.188 (2021-12-20)
+--------------------
+
+* Flag discontinued items for main Products grid.
+
+
+0.8.187 (2021-12-20)
+--------------------
+
+* Add common configuration logic for "input file templates".
+
+* Add some standard CRUD buttons for buefy themes.
+
+
+0.8.186 (2021-12-17)
+--------------------
+
+* Render "pretty" UPC by default, for batch row form fields.
+
+* Let config decide which versions of vue.js and buefy to use.
+
+
+0.8.185 (2021-12-15)
+--------------------
+
+* Allow for null price when showing price history.
+
+* Overhaul desktop views for receiving, for efficiency.
+
+* Add some basic "config" views, to obviate some App Settings.
+
+* Add "jump to" chooser in App Settings, for various "configure" pages.
+
+* Fix params field when deleting a report.
+
+* Add some smarts when making batch execution form schema.
+
+
+0.8.184 (2021-12-09)
+--------------------
+
+* Refactor "receive row" and "declare credit" tools per buefy theme.
+
+* Allow "auto-receive all items" batch feature in production.
+
+* Make "view row" prettier for receiving batch, for buefy themes.
+
+* Add buttons to edit, confirm cost for receiving batch row view.
+
+
+0.8.183 (2021-12-08)
+--------------------
+
+* Add basic views to expose Problem Reports, and run them.
+
+* Only include ``--runas`` arg if we have a value, for import jobs.
+
+* Assume default receiving workflow if there is only one.
+
+* Fix bug when report has no params dict.
+
+
+0.8.182 (2021-12-07)
+--------------------
+
+* Fix form ref bug, for batch execution.
+
+
+0.8.181 (2021-12-07)
+--------------------
+
+* Bugfix.
+
+
+0.8.180 (2021-12-07)
+--------------------
+
+* Add basic import/export handler views, tool to run jobs.
+
+* Overhaul import handler config etc.:
+  * add ``MasterView.configurable`` concept, ``/configure.mako`` template
+  * add new master view for DataSync Threads (needs content)
+  * tweak view config for DataSync Changes accordingly
+  * update the Configure DataSync page per ``configurable`` concept
+  * add new Configure Import/Export page, per ``configurable``
+  * add basic views for Raw Permissions
+
+* Honor "safe for web app" flags for import/export handlers.
+
+* When viewing report output, show params as proper buefy table.
+
+
+0.8.179 (2021-12-03)
+--------------------
+
+* Expose the Sale Price and TPR Price for product views.
+
+
+0.8.178 (2021-11-29)
+--------------------
+
+* Add page for configuring datasync.
+
+
+0.8.177 (2021-11-28)
+--------------------
+
+* Show current/sale pricing for products in new custorder page.
+
+* Add simple search filters for past items dialog in new custorder.
+
+
+0.8.176 (2021-11-25)
+--------------------
+
+* Add basic support for receiving from PO with invoice.
+
+* Don't use multi-select for new report in buefy themes.
+
+
+0.8.175 (2021-11-17)
+--------------------
+
+* Fix bug when product has empty suggested price.
+
+* Show ordered quantity when viewing costing batch row.
+
+
+0.8.174 (2021-11-14)
+--------------------
+
+* Expose the "sync users" flag for Roles.
+
+
+0.8.173 (2021-11-11)
+--------------------
+
+* Improve error handling when executing a custorder batch.
+
+* Fix "download results" support for Products.
+
+
+0.8.172 (2021-11-11)
+--------------------
+
+* Add permission for viewing "all" employees.
+
+
+0.8.171 (2021-11-11)
+--------------------
+
+* Add "true margin" to products XLSX export.
+
+* Add initial ``VersionMasterView`` base class.
+
+* Add views for ``PendingProduct`` model; also ``DepartmentWidget``.
+
+
+0.8.170 (2021-11-09)
+--------------------
+
+* Fix dynamic content title for "view profile" page.
+
+
+0.8.169 (2021-11-08)
+--------------------
+
+* Use products handler to get image URL.
+
+* Show some more product attributes in custorder item selection popup.
+
+* Auto-select Quantity tab when editing item for new custorder.
+
+* Let user "add past product" when making new custorder.
+
+* Let handler restrict available invoice parser options.
+
+* Cleanup grid columns for receiving batches.
+
+* Fall back to empty string for product regular price.
+
+
+0.8.168 (2021-11-05)
+--------------------
+
+* Make separate method for writing results XLSX file.
+
+* Add ``render_brand()`` method for MasterView.
+
+* Add link to download generic template for vendor catalog batch.
+
+
+0.8.167 (2021-11-04)
+--------------------
+
+* Try to prevent caching for any /index (grid) page.
+
+* Fix product view page when user cannot view version history.
+
+* Move some custorder logic to handler; allow force-swap of product selection.
+
+* Honor the "product price may be questionable" flag for new custorder.
+
+* Show unit price in line items grid for new custorder.
+
+* Avoid exposing batch params when creating a batch.
+
+
+0.8.166 (2021-11-03)
+--------------------
+
+* Fix the Department filter for Products grid, for jquery themes.
+
+
+0.8.165 (2021-11-02)
+--------------------
+
+* Optionally set the ``sticky-header`` attribute for main buefy grids.
+
+* Show case qty by default for costing batch rows.
+
+* Highlight the "did not receive" rows for purchase batch.
+
+* Improve validation for Person field of User form.
+
+* Omit "edit" link unless user has perm, for Customer "people" subgrid.
+
+* Highlight "cannot calculate price" rows for new product batch.
+
+
+0.8.164 (2021-10-20)
+--------------------
+
+* Give custorder batch handler a couple ways to affect adding new items.
+
+* Refactor to leverage all existing methods of auth handler.
+
+* Overhaul the autocomplete component, for sake of new custorder.
+
+* Improve "refresh contact", show new fields in green for custorder.
+
+* Invoke handler when adding new item to custorder batch.
+
+* Add basic "price needs confirmation" support for custorder.
+
+* Clean up the product selection UI for new custorder.
+
+
+0.8.163 (2021-10-14)
+--------------------
+
+* Misc. tweaks for users, roles.
+
+
+0.8.162 (2021-10-14)
+--------------------
+
+* Cleanup form display a bit, for App Settings.
+
+* Invoke the auth handler to cache user permissions etc.
+
+
+0.8.161 (2021-10-13)
+--------------------
+
+* Add ``debounce()`` wrapper for buefy autocomplete.
+
+* Leverage the auth handler for main user login.
+
+
+0.8.160 (2021-10-11)
+--------------------
+
+* Stop rounding case/unit cost fields to 2 places for purchase batch.
+
+* Fix some phone/email bugs for new custorder page.
+
+* Fix bug when making context for mailing address.
+
+* Improve display, handling for "add contact info to customer record".
+
+
+0.8.159 (2021-10-10)
+--------------------
+
+* Simplify template context customization for view_profile_buefy.
+
+
+0.8.158 (2021-10-07)
+--------------------
+
+* Add support for "new customer" when creating new custorder.
+
+* Improve contact name handling for new custorder.
+
+
+0.8.157 (2021-10-06)
+--------------------
+
+* Some tweaks for invoice costing batch views.
+
+* Add "restrict contact info" features for new custorder batch.
+
+* Add "contact update request" workflow for new custorder batch.
+
+
+0.8.156 (2021-10-05)
+--------------------
+
+* Show "contact notes" when creating new custorder.
+
+* Improve phone editing for new custorder.
+
+* Add button to refresh contact info for new custorder.
+
+* Overhaul the "Personal" tab of profile view.
+
+* Refactor the Employee tab of profile view, per better patterns.
+
+
+0.8.155 (2021-10-01)
+--------------------
+
+* Refactor autocomplete view logic to leverage new "autocompleters".
+
+
+0.8.154 (2021-09-30)
+--------------------
+
+* Initial (basic) views for invoice costing batches.
+
+
+0.8.153 (2021-09-28)
+--------------------
+
+* Improve phone/email handling when making new custorder.
+
+* Avoid "detach person" logic if not supported by view class.
+
+
+0.8.152 (2021-09-27)
+--------------------
+
+* Allow changing status, adding notes for customer order items.
+
+
+0.8.151 (2021-09-27)
+--------------------
+
+* Overhaul new custorder so contact may be either Person or Customer.
+
+* Add a dropdown of choices to the Department filter for Products grid.
+
+
+0.8.150 (2021-09-26)
+--------------------
+
+* Refactor several "field grids" per Buefy theme.
+
+* Display the Store field for Customer Orders.
+
+
+0.8.149 (2021-09-25)
+--------------------
+
+* Improve default autocomplete query logic, w/ multiple ILIKE.
+
+* Add placeholder to customer lookup for new order.
+
+* Invoke handler for customer autocomplete when making new custorder.
+
+* Improve "employees" list when viewing a department, for buefy themes.
+
+* Add products row grid for misc. org table views.
+
+
+0.8.148 (2021-09-22)
+--------------------
+
+* Add way to update Employee ID from profile view.
+
+
+0.8.147 (2021-09-22)
+--------------------
+
+* Add way to override grid action label rendering.
+
+
+0.8.146 (2021-09-21)
+--------------------
+
+* Misc. improvements for customer order views.
+
+
+0.8.145 (2021-09-19)
+--------------------
+
+* Allow setting the "exclusive" sequence of grid filters.
+
+
+0.8.144 (2021-09-16)
+--------------------
+
+* Invoke handler when request is made to merge 2 people.
+
+
+0.8.143 (2021-09-12)
+--------------------
+
+* Add way to customize product autocomplete for new custorder.
+
+
+0.8.142 (2021-09-09)
+--------------------
+
+* Set quantity type when viewing vendor lead times, order intervals.
+
+
+0.8.141 (2021-09-09)
+--------------------
+
+* Add /people API endpoint; allow for "native sort".
+
+* Allow override of "create" permission in API.
+
+* Add the ``Grid.remove()`` method, deprecate ``hide_column()`` etc.
+
+* Improve error handling for purchase batch.
+
+
+0.8.140 (2021-09-01)
+--------------------
+
+* Make it easier to override rendering grid component in master/index.
+
+* Always show all grid actions...for now.
+
+* Allow grid columns to be *invisible* (but still present in grid).
+
+* Improve UI, customization hooks for new custorder batch.
+
+* Add hover text for vendor ID column of pricing batch row grid.
+
+* Fix size of roles multi-select when editing user.
+
+* Allow "touch" action for employees.
+
+
+0.8.139 (2021-08-26)
+--------------------
+
+* Tweak how email preview is sent, and attempt "to" is displayed.
+
+* Move "merge 2 people" logic into People Handler.
+
+* Expose "merge request tracking" feature for People data.
+
+* Allow customization of row 'view' action url.
+
+* Require explicit opt-in for "clicking grid row checks box" feature.
+
+* Add ``before_render_index()`` customization hook for MasterView.
+
+
+0.8.138 (2021-08-04)
+--------------------
+
+* Let feedback forms define their own email key.
+
+
+0.8.137 (2021-07-15)
+--------------------
+
+* Set UPC renderer for delproduct batch row.
+
+* Expose ``pack_size`` for delproduct batch.
+
+
+0.8.136 (2021-06-18)
+--------------------
+
+* Include "is/not null" filters for GPC fields.
+
+
+0.8.135 (2021-06-15)
+--------------------
+
+* Add 'v' prefix for release package diff links.
+
+
+0.8.134 (2021-06-15)
+--------------------
+
+* Allow config to set favicon and header image.
+
+
+0.8.133 (2021-06-11)
+--------------------
+
+* Allow customization of rendering version diff values.
+
+* Allow direct creation of new label batches.
+
+* Allow generating project which integrates w/ LOC SMS.
+
+
+0.8.132 (2021-05-03)
+--------------------
+
+* Highlight "has inventory" rows for delete item batch.
+
+* Add csrftoken to TailboneForm js.
+
+* Freeze pyramid version at 1.x.
+
+
+0.8.131 (2021-04-12)
+--------------------
+
+* Show current price date range as hover text, for products grid.
+
+* Make it easier to extend "common" API views.
+
+* Accept any decimal numbers for API inventory batch counts.
+
+
+0.8.130 (2021-03-30)
+--------------------
+
+* Catch and show error, if one happens when making batch from product query.
+
+* Expose the new ``Store.archived`` flag.
+
+
+0.8.129 (2021-03-11)
+--------------------
+
+* Add support for ``inactivity_months`` field for delete product batch.
+
+* Expose new fields for Trainwreck.
+
+* Fix enum display for customer order status.
+
+
+0.8.128 (2021-03-05)
+--------------------
+
+* Allow per-user stylesheet for Buefy themes.
+
+* Expose ``date_created`` for delete product batches.
+
+
+0.8.127 (2021-03-02)
+--------------------
+
+* Use end time as default filter, sort for Trainwreck.
+
+* Avoid encoding values as string, for integer grid filters.
+
+* Fix message recipients for Reply / Reply-All, with Buefy themes.
+
+* Handle row click as if checkbox was clicked, for checkable grid.
+
+* Highlight delete product batch rows with "pending customer orders" status.
+
+* Add hover text for subdepartment name, in pricing batch row grid.
+
+
+0.8.126 (2021-02-18)
+--------------------
+
+* Allow customization of main Buefy CSS styles, for falafel theme.
+
+* Add special "contains any of" verb for string-based grid filters.
+
+* Add special "equal to any of" verb for UPC-related grid filters.
+
+* Tweaks per "delete products" batch.
+
+* Misc. tweaks for vendor catalog batch.
+
+* Add support for "default" trainwreck model.
+
+
+0.8.125 (2021-02-10)
+--------------------
+
+* Fix some permission bugs when showing batch tools etc.
+
+* Render batch execution description as markdown.
+
+* Cleanup default display for vendor catalog batches.
+
+* Make errors more obvious, when running batch commands as subprocess.
+
+* Add styles for field labels in profile view.
+
+
+0.8.124 (2021-02-04)
+--------------------
+
+* Fix bug when editing a Person.
+
+
+0.8.123 (2021-02-04)
+--------------------
+
+* Fix config defaults for PurchaseView.
+
+* Add stub methods for ``MasterView.template_kwargs_view()`` etc.
+
+* Update references to vendor catalog batches etc.
+
+* Fix display of handheld batch links, when viewing label batch.
+
+* Prevent updates to batch rows, if batch is immutable.
+
+
+0.8.122 (2021-02-01)
+--------------------
+
+* Normalize naming of all traditional master views.
+
+* Undo recent ``base.css`` changes for ``<p>`` tags.
+
+* Misc. improvements for ordering batches, purchases.
+
+* Purge things for legacy (jquery) mobile, and unused template themes.
+
+* Make handler responsible for possible receiving modes.
+
+* Split "new receiving batch" process into 2 steps: choose, create.
+
+* Add initial "scanning" feature for Ordering Batches.
+
+* Add support for "nested" menu items.
+
+* Add icon for Help button.
+
+
+0.8.121 (2021-01-28)
+--------------------
+
+* Tweak how vendor link is rendered for readonly field.
+
+* Use "People Handler" to update names, when editing person or user.
+
+
+0.8.120 (2021-01-27)
+--------------------
+
+* Initial support for adding items to, executing customer order batch.
+
+* Add changelog link for Theo, in upgrade package diff.
+
+* Hide "collect from wild" button for UOMs unless user has permission.
+
+
+0.8.119 (2021-01-25)
+--------------------
+
+* Don't create new person for new user, if one was selected.
+
+* Allow newer zope.sqlalchemy package.
+
+* Add variant transaction logic per zope.sqlalchemy 1.1 changes.
+
+* Add CSS styles for 'codehilite' a la Pygments.
+
+* Add feature to generate new features...
+
+* Add views for "delete product" batch.
+
+* Set ``self.model`` when constructing new View.
+
+* Add some generic render methods to MasterView.
+
+* Add custom ``base.css`` for falafel theme.
+
+* Add master view for Units of Measure mapping table.
+
+* Add woocommerce package links for sake of upgrade diff view.
+
+* Add basic web API app, for simple use cases.
+
+
+0.8.118 (2021-01-10)
+--------------------
+
+* Show node title in header for Login, About pages.
+
+* Allow changing protected user password when acting as root.
+
+* Allow specifying the size of a file, for ``readable_size()`` method.
+
+* Try to show existing filename, for upload widget.
+
+* Add basic support for "download" and "rawbytes" API views.
+
+
+0.8.117 (2020-12-16)
+--------------------
+
+* Add common "form poster" logic, to make CSRF token/header names configurable.
+
+* Refactor the feedback form to use common form poster logic.
+
+
+0.8.116 (2020-12-15)
+--------------------
+
+* Add basic views for IFPS PLU Codes.
+
+* Add very basic support for merging 2 People.
+
+* Tweak spacing for header logo + title, in falafel theme.
+
+
+0.8.115 (2020-12-04)
+--------------------
+
+* Add the "Employee Status" filter to People grid.
+
+* Add "is empty" and related verbs, for "string" type grid filters.
+
+* Assume composite PK when fetching instance for master view.
+
+
+0.8.114 (2020-12-01)
+--------------------
+
+* Misc. tweaks to vendor catalog views.
+
+* Tweak how an "enum" grid filter is initialized.
+
+* Add "generic" Employee tab feature, for profile view.
+
+
+0.8.113 (2020-10-13)
+--------------------
+
+* Tweak how global DB session is created.
+
+
+0.8.112 (2020-09-29)
+--------------------
+
+* Add support for "list" type of app settings (w/ textarea).
+
+* Add feature to "download rows for results" in master index view.
+
+* Fix "refresh results" for batches, in Buefy theme.
+
+
+0.8.111 (2020-09-25)
+--------------------
+
+* Allow alternate engine to act as 'default' when multiple are available.
+
+* Fix grid bug when paginator is not involved.
+
+
+0.8.110 (2020-09-24)
+--------------------
+
+* Add ``user_is_protected()`` method to core View class.
+
+* Change how we protect certain person, employee records.
+
+* Add global help URL to login template.
+
+* Fix bug when fetching partial versions data grid.
+
+
+0.8.109 (2020-09-22)
+--------------------
+
+* Add 'warning' class for 'delete' action in b-table grid.
+
+* Add "worksheet file" pattern for editing batches.
+
+* Avoid unhelpful error when perm check happens for "re-created" DB user.
+
+* Prompt user if they try to send email preview w/ no address.
+
+* Don't expose "timezone" for input when generating 'fabric' project.
+
+* Add some more field hints when generating 'fabric' project.
+
+* Show node title in header, for home page.
+
+* Remove unwanted columns for default Products grid.
+
+
+0.8.108 (2020-09-16)
+--------------------
+
+* Allow custom props for TailboneForm component.
+
+* Remove some custom field labels for Vendor.
+
+* Add support for generating new 'fabric' project.
+
+
+0.8.107 (2020-09-14)
+--------------------
+
+* Stop including 'complete' filter by default for purchasing batches.
+
+* Overhaul project changelog links for upgrade pkg diff table.
+
+* Add support/views for generating new custom projects, via handler.
+
+
+0.8.106 (2020-09-02)
+--------------------
+
+* Add progress for generating "results as CSV/XLSX" file to download.
+
+* Use utf8 encoding when downloading results as CSV.
+
+* Add new/flexible "download results" feature.
+
+* Fix spacing between components in "grid tools" section.
+
+* Add support for batch execution options in Buefy themes.
+
+* Improve auto-handling of "local" timestamps.
+
+* Expose ``Product.average_weight`` field.
+
+
 0.8.105 (2020-08-21)
 --------------------
 
@@ -2245,7 +4992,7 @@ and related technologies.
 0.6.47 (2017-11-08)
 -------------------
 
-* Fix manifest to include *.pt deform templates
+* Fix manifest to include ``*.pt`` deform templates
 
 
 0.6.46 (2017-11-08)
@@ -2578,13 +5325,13 @@ and related technologies.
 
 
 0.6.13 (2017-07-26)
-------------------
+-------------------
 
 * Allow master view to decide whether each grid checkbox is checked
 
 
 0.6.12 (2017-07-26)
-------------------
+-------------------
 
 * Add basic support for product inventory and status
 
@@ -2592,7 +5339,7 @@ and related technologies.
 
 
 0.6.11 (2017-07-18)
-------------------
+-------------------
 
 * Tweak some basic styles for forms/grids
 
@@ -2600,7 +5347,7 @@ and related technologies.
 
 
 0.6.10 (2017-07-18)
-------------------
+-------------------
 
 * Fix grid bug if "current page" becomes invalid
 
diff --git a/docs/api/db.rst b/docs/api/db.rst
new file mode 100644
index 00000000..ace21b68
--- /dev/null
+++ b/docs/api/db.rst
@@ -0,0 +1,6 @@
+
+``tailbone.db``
+===============
+
+.. automodule:: tailbone.db
+  :members:
diff --git a/docs/api/diffs.rst b/docs/api/diffs.rst
new file mode 100644
index 00000000..fb1bba71
--- /dev/null
+++ b/docs/api/diffs.rst
@@ -0,0 +1,6 @@
+
+``tailbone.diffs``
+==================
+
+.. automodule:: tailbone.diffs
+  :members:
diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst
new file mode 100644
index 00000000..33316903
--- /dev/null
+++ b/docs/api/forms.widgets.rst
@@ -0,0 +1,6 @@
+
+``tailbone.forms.widgets``
+==========================
+
+.. automodule:: tailbone.forms.widgets
+  :members:
diff --git a/docs/api/grids.core.rst b/docs/api/grids.core.rst
new file mode 100644
index 00000000..60155cb2
--- /dev/null
+++ b/docs/api/grids.core.rst
@@ -0,0 +1,6 @@
+
+``tailbone.grids.core``
+=======================
+
+.. automodule:: tailbone.grids.core
+  :members:
diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst
index 8b25c994..d28a1b15 100644
--- a/docs/api/subscribers.rst
+++ b/docs/api/subscribers.rst
@@ -3,5 +3,4 @@
 ========================
 
 .. automodule:: tailbone.subscribers
-
-.. autofunction:: new_request
+   :members:
diff --git a/docs/api/util.rst b/docs/api/util.rst
new file mode 100644
index 00000000..35e66ed3
--- /dev/null
+++ b/docs/api/util.rst
@@ -0,0 +1,6 @@
+
+``tailbone.util``
+=================
+
+.. automodule:: tailbone.util
+   :members:
diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst
index bf505b6c..e7de7170 100644
--- a/docs/api/views/master.rst
+++ b/docs/api/views/master.rst
@@ -81,6 +81,12 @@ override when defining your subclass.
       override this for certain views, if so that should be done within
       :meth:`get_help_url()`.
 
+   .. attribute:: MasterView.version_diff_factory
+
+      Optional factory to use for version diff objects.  By default
+      this is *not set* but a subclass is free to set it.  See also
+      :meth:`get_version_diff_factory()`.
+
 
 Methods to Override
 -------------------
@@ -88,6 +94,8 @@ Methods to Override
 The following is a list of methods which you can override when defining your
 subclass.
 
+   .. automethod:: MasterView.editable_instance
+
    .. .. automethod:: MasterView.get_settings
 
    .. automethod:: MasterView.get_csv_fields
@@ -95,3 +103,24 @@ subclass.
    .. automethod:: MasterView.get_csv_row
 
    .. automethod:: MasterView.get_help_url
+
+   .. automethod:: MasterView.get_model_key
+
+   .. automethod:: MasterView.get_version_diff_enums
+
+   .. automethod:: MasterView.get_version_diff_factory
+
+   .. automethod:: MasterView.make_version_diff
+
+   .. automethod:: MasterView.title_for_version
+
+
+Support Methods
+---------------
+
+The following is a list of methods you should (probably) not need to
+override, but may find useful:
+
+   .. automethod:: MasterView.default_edit_url
+
+   .. automethod:: MasterView.get_action_route_kwargs
diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst
new file mode 100644
index 00000000..6a9e9168
--- /dev/null
+++ b/docs/api/views/members.rst
@@ -0,0 +1,6 @@
+
+``tailbone.views.members``
+==========================
+
+.. automodule:: tailbone.views.members
+   :members:
diff --git a/docs/changelog.rst b/docs/changelog.rst
new file mode 100644
index 00000000..bbf94f4b
--- /dev/null
+++ b/docs/changelog.rst
@@ -0,0 +1,8 @@
+
+Changelog Archive
+=================
+
+.. toctree::
+   :maxdepth: 1
+
+   OLDCHANGES
diff --git a/docs/conf.py b/docs/conf.py
index 505396ed..ade4c92a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,38 +1,21 @@
-# -*- coding: utf-8; -*-
+# Configuration file for the Sphinx documentation builder.
 #
-# Tailbone documentation build configuration file, created by
-# sphinx-quickstart on Sat Feb 15 23:15:27 2014.
-#
-# This file is exec()d with the current directory set to its
-# containing dir.
-#
-# Note that not all possible configuration values are present in this
-# autogenerated file.
-#
-# All configuration values have a default; values that are commented out
-# serve to show the default.
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
 
-import sys
-import os
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
 
-import sphinx_rtd_theme
+from importlib.metadata import version as get_version
 
-exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
+project = 'Tailbone'
+copyright = '2010 - 2024, Lance Edgar'
+author = 'Lance Edgar'
+release = get_version('Tailbone')
 
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.insert(0, os.path.abspath('.'))
-
-# -- General configuration ------------------------------------------------
-
-# If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
-
-# Add any Sphinx extension module names here, as strings. They can be
-# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
-# ones.
 extensions = [
     'sphinx.ext.autodoc',
     'sphinx.ext.todo',
@@ -40,241 +23,30 @@ extensions = [
     'sphinx.ext.viewcode',
 ]
 
+templates_path = ['_templates']
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
 intersphinx_mapping = {
-    'rattail': ('https://rattailproject.org/docs/rattail/', None),
+    'rattail': ('https://docs.wuttaproject.org/rattail/', None),
     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
+    'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
+    'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
 }
 
-# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
-
-# The suffix of source filenames.
-source_suffix = '.rst'
-
-# The encoding of source files.
-#source_encoding = 'utf-8-sig'
-
-# The master toctree document.
-master_doc = 'index'
-
-# General information about the project.
-project = u'Tailbone'
-copyright = u'2010 - 2020, Lance Edgar'
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-# The short X.Y version.
-# version = '0.3'
-version = '.'.join(__version__.split('.')[:2])
-# The full version, including alpha/beta/rc tags.
-release = __version__
-
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-#language = None
-
-# There are two options for replacing |today|: either, you set today to some
-# non-false value, then it is used:
-#today = ''
-# Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
-exclude_patterns = ['_build']
-
-# The reST default role (used for this markup: `text`) to use for all
-# documents.
-#default_role = None
-
-# If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
-
-# If true, the current module name will be prepended to all description
-# unit titles (such as .. function::).
-#add_module_names = True
-
-# If true, sectionauthor and moduleauthor directives will be shown in the
-# output. They are ignored by default.
-#show_authors = False
-
-# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
-
-# A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
-
-# If true, keep warnings as "system message" paragraphs in the built documents.
-#keep_warnings = False
-
-# Allow todo entries to show up.
+# allow todo entries to show up
 todo_include_todos = True
 
 
-# -- Options for HTML output ----------------------------------------------
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
 
-# The theme to use for HTML and HTML Help pages.  See the documentation for
-# a list of builtin themes.
-# html_theme = 'classic'
-html_theme = 'sphinx_rtd_theme'
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further.  For a list of options available for each theme, see the
-# documentation.
-#html_theme_options = {}
-
-# Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
-html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-
-# The name for this set of Sphinx documents.  If None, it defaults to
-# "<project> v<release> documentation".
-#html_title = None
-
-# A shorter title for the navigation bar.  Default is the same as html_title.
-#html_short_title = None
+html_theme = 'furo'
+html_static_path = ['_static']
 
 # The name of an image file (relative to this directory) to place at the top
 # of the sidebar.
 #html_logo = None
-html_logo = 'images/rattail_avatar.png'
-
-# The name of an image file (within the static path) to use as favicon of the
-# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
-#html_favicon = None
-
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
-
-# Add any extra paths that contain custom files (such as robots.txt or
-# .htaccess) here, relative to this directory. These files are copied
-# directly to the root of the documentation.
-#html_extra_path = []
-
-# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
-# using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
-
-# If true, SmartyPants will be used to convert quotes and dashes to
-# typographically correct entities.
-#html_use_smartypants = True
-
-# Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
-
-# Additional templates that should be rendered to pages, maps page names to
-# template names.
-#html_additional_pages = {}
-
-# If false, no module index is generated.
-#html_domain_indices = True
-
-# If false, no index is generated.
-#html_use_index = True
-
-# If true, the index is split into individual pages for each letter.
-#html_split_index = False
-
-# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
-
-# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
-
-# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
-
-# If true, an OpenSearch description file will be output, and all pages will
-# contain a <link> tag referring to it.  The value of this option must be the
-# base URL from which the finished HTML is served.
-#html_use_opensearch = ''
-
-# This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+#html_logo = 'images/rattail_avatar.png'
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'Tailbonedoc'
-
-
-# -- Options for LaTeX output ---------------------------------------------
-
-latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
-
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
-
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
-}
-
-# Grouping the document tree into LaTeX files. List of tuples
-# (source start file, target name, title,
-#  author, documentclass [howto, manual, or own class]).
-latex_documents = [
-  ('index', 'Tailbone.tex', u'Tailbone Documentation',
-   u'Lance Edgar', 'manual'),
-]
-
-# The name of an image file (relative to this directory) to place at the top of
-# the title page.
-#latex_logo = None
-
-# For "manual" documents, if this is true, then toplevel headings are parts,
-# not chapters.
-#latex_use_parts = False
-
-# If true, show page references after internal links.
-#latex_show_pagerefs = False
-
-# If true, show URL addresses after external links.
-#latex_show_urls = False
-
-# Documents to append as an appendix to all manuals.
-#latex_appendices = []
-
-# If false, no module index is generated.
-#latex_domain_indices = True
-
-
-# -- Options for manual page output ---------------------------------------
-
-# One entry per manual page. List of tuples
-# (source start file, name, description, authors, manual section).
-man_pages = [
-    ('index', 'tailbone', u'Tailbone Documentation',
-     [u'Lance Edgar'], 1)
-]
-
-# If true, show URL addresses after external links.
-#man_show_urls = False
-
-
-# -- Options for Texinfo output -------------------------------------------
-
-# Grouping the document tree into Texinfo files. List of tuples
-# (source start file, target name, title, author,
-#  dir menu entry, description, category)
-texinfo_documents = [
-  ('index', 'Tailbone', u'Tailbone Documentation',
-   u'Lance Edgar', 'Tailbone', 'One line description of project.',
-   'Miscellaneous'),
-]
-
-# Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
-
-# If false, no module index is generated.
-#texinfo_domain_indices = True
-
-# How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
-
-# If true, do not generate a @detailmenu in the "Top" node's menu.
-#texinfo_no_detailmenu = False
+#htmlhelp_basename = 'Tailbonedoc'
diff --git a/docs/index.rst b/docs/index.rst
index ffa516e9..d964086f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -44,18 +44,32 @@ Package API:
 
    api/api/batch/core
    api/api/batch/ordering
+   api/db
+   api/diffs
    api/forms
+   api/forms.widgets
    api/grids
+   api/grids.core
    api/progress
    api/subscribers
+   api/util
    api/views/batch
    api/views/batch.vendorcatalog
    api/views/core
    api/views/master
+   api/views/members
    api/views/purchasing.batch
    api/views/purchasing.ordering
 
 
+Changelog:
+
+.. toctree::
+   :maxdepth: 1
+
+   changelog
+
+
 Documentation To-Do
 ===================
 
diff --git a/docs/structure.rst b/docs/structure.rst
index b741475e..5585f71a 100644
--- a/docs/structure.rst
+++ b/docs/structure.rst
@@ -117,7 +117,6 @@ of course supply the web app layer.
    │       │   │   └── foobatch/
    │       │   ├── customers/
    │       │   ├── menu.mako
-   │       │   ├── mobile/
    │       │   └── products/
    │       └── views/
    │           ├── __init__.py
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..a7214a8e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,103 @@
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+
+[project]
+name = "Tailbone"
+version = "0.22.7"
+description = "Backoffice Web Application for Rattail"
+readme = "README.md"
+authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
+license = {text = "GNU GPL v3+"}
+classifiers = [
+        "Development Status :: 4 - Beta",
+        "Environment :: Web Environment",
+        "Framework :: Pyramid",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
+        "Natural Language :: English",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
+        "Topic :: Internet :: WWW/HTTP",
+        "Topic :: Office/Business",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+]
+requires-python = ">= 3.8"
+dependencies = [
+        "asgiref",
+        "colander",
+        "ColanderAlchemy",
+        "cornice",
+        "cornice-swagger",
+        "deform",
+        "humanize",
+        "Mako",
+        "markdown",
+        "openpyxl",
+        "paginate",
+        "paginate_sqlalchemy",
+        "passlib",
+        "Pillow",
+        "pyramid>=2",
+        "pyramid_beaker",
+        "pyramid_deform",
+        "pyramid_exclog",
+        "pyramid_fanstatic",
+        "pyramid_mako",
+        "pyramid_retry",
+        "pyramid_tm",
+        "rattail[db,bouncer]>=0.20.1",
+        "sa-filters",
+        "simplejson",
+        "transaction",
+        "waitress",
+        "WebHelpers2",
+        "WuttaWeb>=0.21.0",
+        "zope.sqlalchemy>=1.5",
+]
+
+
+[project.optional-dependencies]
+docs = ["Sphinx", "furo"]
+tests = ["coverage", "mock", "pytest", "pytest-cov"]
+
+
+[project.entry-points."paste.app_factory"]
+main = "tailbone.app:main"
+webapi = "tailbone.webapi:main"
+
+
+[project.entry-points."rattail.cleaners"]
+beaker = "tailbone.cleanup:BeakerCleaner"
+
+
+[project.entry-points."rattail.config.extensions"]
+tailbone = "tailbone.config:ConfigExtension"
+
+
+[project.urls]
+Homepage = "https://rattailproject.org"
+Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
+Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
+Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
+
+
+[tool.commitizen]
+version_provider = "pep621"
+tag_format = "v$version"
+update_changelog_on_bump = true
+
+
+[tool.nosetests]
+nocapture = 1
+cover-package = "tailbone"
+cover-erase = 1
+cover-html = 1
+cover-html-dir = "htmlcov"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 7712ec72..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,6 +0,0 @@
-[nosetests]
-nocapture = 1
-cover-package = tailbone
-cover-erase = 1
-cover-html = 1
-cover-html-dir = htmlcov
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 2e0315b3..00000000
--- a/setup.py
+++ /dev/null
@@ -1,177 +0,0 @@
-# -*- coding: utf-8; -*-
-################################################################################
-#
-#  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
-#
-#  This file is part of Rattail.
-#
-#  Rattail is free software: you can redistribute it and/or modify it under the
-#  terms of the GNU General Public License as published by the Free Software
-#  Foundation, either version 3 of the License, or (at your option) any later
-#  version.
-#
-#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
-#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-#  details.
-#
-#  You should have received a copy of the GNU General Public License along with
-#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
-#
-################################################################################
-"""
-Setup script for Tailbone
-"""
-
-from __future__ import unicode_literals, absolute_import
-
-import os.path
-from setuptools import setup, find_packages
-
-
-here = os.path.abspath(os.path.dirname(__file__))
-exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
-README = open(os.path.join(here, 'README.rst')).read()
-
-
-requires = [
-    #
-    # Version numbers within comments below have specific meanings.
-    # Basically the 'low' value is a "soft low," and 'high' a "soft high."
-    # In other words:
-    #
-    # If either a 'low' or 'high' value exists, the primary point to be
-    # made about the value is that it represents the most current (stable)
-    # version available for the package (assuming typical public access
-    # methods) whenever this project was started and/or documented.
-    # Therefore:
-    #
-    # If a 'low' version is present, you should know that attempts to use
-    # versions of the package significantly older than the 'low' version
-    # may not yield happy results.  (A "hard" high limit may or may not be
-    # indicated by a true version requirement.)
-    #
-    # Similarly, if a 'high' version is present, and especially if this
-    # project has laid dormant for a while, you may need to refactor a bit
-    # when attempting to support a more recent version of the package.  (A
-    # "hard" low limit should be indicated by a true version requirement
-    # when a 'high' version is present.)
-    #
-    # In any case, developers and other users are encouraged to play
-    # outside the lines with regard to these soft limits.  If bugs are
-    # encountered then they should be filed as such.
-    #
-    # package                           # low                   high
-
-    # TODO: why do we need to cap this?  breaks tailbone.db zope stuff somehow
-    'zope.sqlalchemy<1.0',              # 0.7                   0.7.7
-
-    # TODO: apparently they jumped from 0.1 to 0.9 and that broke us...
-    # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27)
-    # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears)
-    # (still, probably a better idea is to refactor so we can use 0.9)
-    'webhelpers2_grid==0.1',            # 0.1
-
-    # TODO: remove version cap once we can drop support for python 2.x
-    'cornice<5.0',                      # 3.4.2                 4.0.1
-
-    'colander',                         # 1.7.0
-    'ColanderAlchemy',                  # 0.3.3
-    'deform',                           # 2.0.4
-    'humanize',                         # 0.5.1
-    'Mako',                             # 0.6.2
-    'openpyxl',                         # 2.4.7
-    'paginate',                         # 0.5.6
-    'paginate_sqlalchemy',              # 0.2.0
-    'passlib',                          # 1.7.1
-    'Pillow',                           # 5.3.0
-    'pyramid',                          # 1.3b2
-    'pyramid_beaker>=0.6',              #                       0.6.1
-    'pyramid_deform',                   # 0.2
-    'pyramid_exclog',                   # 0.6
-    'pyramid_mako',                     # 1.0.2
-    'pyramid_tm',                       # 0.3
-    'rattail[db,bouncer]',              # 0.5.0
-    'six',                              # 1.10.0
-    'sqlalchemy-filters',               # 0.8.0
-    'transaction',                      # 1.2.0
-    'waitress',                         # 0.8.1
-    'WebHelpers2',                      # 2.0
-    'WTForms',                          # 2.1
-]
-
-
-extras = {
-
-    'docs': [
-        #
-        # package                       # low                   high
-
-        'Sphinx',                       # 1.2
-        'sphinx-rtd-theme',             # 0.2.4
-    ],
-
-    'tests': [
-        #
-        # package                       # low                   high
-
-        'coverage',                     # 3.6
-        'fixture',                      # 1.5
-        'mock',                         # 1.0.1
-        'nose',                         # 1.3.0
-    ],
-}
-
-
-setup(
-    name = "Tailbone",
-    version = __version__,
-    author = "Lance Edgar",
-    author_email = "lance@edbob.org",
-    url = "http://rattailproject.org/",
-    license = "GNU GPL v3",
-    description = "Backoffice Web Application for Rattail",
-    long_description = README,
-
-    classifiers = [
-        'Development Status :: 4 - Beta',
-        'Environment :: Web Environment',
-        'Framework :: Pyramid',
-        'Intended Audience :: Developers',
-        'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
-        'Natural Language :: English',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.5',
-        'Topic :: Internet :: WWW/HTTP',
-        'Topic :: Office/Business',
-        'Topic :: Software Development :: Libraries :: Python Modules',
-    ],
-
-    install_requires = requires,
-    extras_require = extras,
-    tests_require = ['Tailbone[tests]'],
-    test_suite = 'nose.collector',
-
-    packages = find_packages(exclude=['tests.*', 'tests']),
-    include_package_data = True,
-    zip_safe = False,
-
-    entry_points = {
-
-        'paste.app_factory': [
-            'main = tailbone.app:main',
-        ],
-
-        'rattail.config.extensions': [
-            'tailbone = tailbone.config:ConfigExtension',
-        ],
-
-        'pyramid.scaffold': [
-            'rattail = tailbone.scaffolds:RattailTemplate',
-        ],
-    },
-)
diff --git a/tailbone/_version.py b/tailbone/_version.py
index 30e62aa6..7095f6c8 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,9 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.8.105'
+try:
+    from importlib.metadata import version
+except ImportError:
+    from importlib_metadata import version
+
+
+__version__ = version('Tailbone')
diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py
index 0b669b6c..1fae059f 100644
--- a/tailbone/api/__init__.py
+++ b/tailbone/api/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import
 
 from .core import APIView, api
 from .master import APIMasterView, SortColumn
+# TODO: remove this
 from .master2 import APIMasterView2
 
 
diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py
index 16e48e82..a710e30d 100644
--- a/tailbone/api/auth.py
+++ b/tailbone/api/auth.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Auth Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-from rattail.db.auth import authenticate_user, set_user_password, cache_permissions
-
 from cornice import Service
 
 from tailbone.api import APIView, api
@@ -44,11 +40,10 @@ class AuthenticationView(APIView):
         This will establish a server-side web session for the user if none
         exists.  Note that this also resets the user's session timer.
         """
-        data = {'ok': True}
+        data = {'ok': True, 'permissions': []}
         if self.request.user:
             data['user'] = self.get_user_info(self.request.user)
-
-        data['permissions'] = list(self.request.tailbone_cached_permissions)
+            data['permissions'] = list(self.request.user_permissions)
 
         # background color may be set per-request, by some apps
         if hasattr(self.request, 'background_color') and self.request.background_color:
@@ -57,6 +52,16 @@ class AuthenticationView(APIView):
             data['background_color'] = self.rattail_config.get(
                 'tailbone', 'background_color')
 
+        # TODO: this seems the best place to return some global app
+        # settings, but maybe not desirable in all cases..in which
+        # case should caller need to ask for these explicitly?  or
+        # make a different call altogether to get them..?
+        app = self.get_rattail_app()
+        customer_handler = app.get_clientele_handler()
+        data['settings'] = {
+            'customer_field_dropdown': customer_handler.choice_uses_dropdown(),
+        }
+
         return data
 
     @api
@@ -82,15 +87,20 @@ class AuthenticationView(APIView):
         if error:
             return {'error': error}
 
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
         login_user(self.request, user)
         return {
             'ok': True,
             'user': self.get_user_info(user),
-            'permissions': list(cache_permissions(Session(), user)),
+            'permissions': list(auth.get_permissions(Session(), user)),
         }
 
     def authenticate_user(self, username, password):
-        return authenticate_user(Session(), username, password)
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        return auth.authenticate_user(Session(), username, password)
 
     def why_cant_user_login(self, user):
         """
@@ -153,14 +163,18 @@ class AuthenticationView(APIView):
         if not self.request.user:
             raise self.forbidden()
 
+        if self.request.user.prevent_password_change and not self.request.is_root:
+            raise self.forbidden()
+
         data = self.request.json_body
 
         # first make sure "current" password is accurate
-        if not authenticate_user(Session(), self.request.user, data['current_password']):
+        if not self.authenticate_user(self.request.user, data['current_password']):
             return {'error': "The current/old password you provided is incorrect"}
 
         # okay then, set new password
-        set_user_password(self.request.user, data['new_password'])
+        auth = self.app.get_auth_handler()
+        auth.set_user_password(self.request.user, data['new_password'])
         return {
             'ok': True,
             'user': self.get_user_info(self.request.user),
@@ -204,5 +218,12 @@ class AuthenticationView(APIView):
         config.add_cornice_service(change_password)
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView'])
     AuthenticationView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py
index 1200f703..f7bc9333 100644
--- a/tailbone/api/batch/core.py
+++ b/tailbone/api/batch/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +24,12 @@
 Tailbone Web API - Batch Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import logging
+import warnings
 
-import six
+from cornice import Service
 
-from rattail.time import localtime
-from rattail.util import load_object
-
-from cornice import resource, Service
-
-from tailbone.api import APIMasterView2 as APIMasterView
+from tailbone.api import APIMasterView
 
 
 log = logging.getLogger(__name__)
@@ -70,10 +64,9 @@ class APIBatchMixin(object):
         table name, although technically it is whatever value returns from the
         ``batch_key`` attribute of the main batch model class.
         """
+        app = self.get_rattail_app()
         key = self.get_batch_class().batch_key
-        spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key),
-                                       default=self.default_handler_spec)
-        return load_object(spec)(self.rattail_config)
+        return app.get_batch_handler(key, default=self.default_handler_spec)
 
 
 class APIBatchView(APIBatchMixin, APIMasterView):
@@ -86,37 +79,45 @@ class APIBatchView(APIBatchMixin, APIMasterView):
 
     def __init__(self, request, **kwargs):
         super(APIBatchView, self).__init__(request, **kwargs)
-        self.handler = self.get_handler()
+        self.batch_handler = self.get_handler()
+
+    @property
+    def handler(self):
+        warnings.warn("the `handler` property is deprecated; "
+                      "please use `batch_handler` instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.batch_handler
 
     def normalize(self, batch):
-
-        created = localtime(self.rattail_config, batch.created, from_utc=True)
+        app = self.get_rattail_app()
+        created = app.localtime(batch.created, from_utc=True)
 
         executed = None
         if batch.executed:
-            executed = localtime(self.rattail_config, batch.executed, from_utc=True)
+            executed = app.localtime(batch.executed, from_utc=True)
 
         return {
             'uuid': batch.uuid,
-            '_str': six.text_type(batch),
+            '_str': str(batch),
             'id': batch.id,
             'id_str': batch.id_str,
             'description': batch.description,
             'notes': batch.notes,
             'params': batch.params or {},
             'rowcount': batch.rowcount,
-            'created': six.text_type(created),
+            'created': str(created),
             'created_display': self.pretty_datetime(created),
             'created_by_uuid': batch.created_by.uuid,
-            'created_by_display': six.text_type(batch.created_by),
+            'created_by_display': str(batch.created_by),
             'complete': batch.complete,
             'status_code': batch.status_code,
             'status_display': batch.STATUS.get(batch.status_code,
-                                               six.text_type(batch.status_code)),
-            'executed': six.text_type(executed) if executed else None,
+                                               str(batch.status_code)),
+            'executed': str(executed) if executed else None,
             'executed_display': self.pretty_datetime(executed) if executed else None,
             'executed_by_uuid': batch.executed_by_uuid,
-            'executed_by_display': six.text_type(batch.executed_by or ''),
+            'executed_by_display': str(batch.executed_by or ''),
+            'mutable': self.batch_handler.is_mutable(batch),
         }
 
     def create_object(self, data):
@@ -129,9 +130,9 @@ class APIBatchView(APIBatchMixin, APIMasterView):
         user = self.request.user
         kwargs = dict(data)
         kwargs['user'] = user
-        batch = self.handler.make_batch(self.Session(), **kwargs)
-        if self.handler.should_populate(batch):
-            self.handler.do_populate(batch, user)
+        batch = self.batch_handler.make_batch(self.Session(), **kwargs)
+        if self.batch_handler.should_populate(batch):
+            self.batch_handler.do_populate(batch, user)
         return batch
 
     def update_object(self, batch, data):
@@ -199,7 +200,7 @@ class APIBatchView(APIBatchMixin, APIMasterView):
         kwargs = dict(self.request.json_body)
         kwargs.pop('user', None)
         kwargs.pop('progress', None)
-        result = self.handler.do_execute(batch, self.request.user, **kwargs)
+        result = self.batch_handler.do_execute(batch, self.request.user, **kwargs)
         return {'ok': bool(result), 'batch': self.normalize(batch)}
 
     @classmethod
@@ -253,14 +254,21 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
 
     def __init__(self, request, **kwargs):
         super(APIBatchRowView, self).__init__(request, **kwargs)
-        self.handler = self.get_handler()
+        self.batch_handler = self.get_handler()
+
+    @property
+    def handler(self):
+        warnings.warn("the `handler` property is deprecated; "
+                      "please use `batch_handler` instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.batch_handler
 
     def normalize(self, row):
         batch = row.batch
         return {
             'uuid': row.uuid,
-            '_str': six.text_type(row),
-            '_parent_str': six.text_type(batch),
+            '_str': str(row),
+            '_parent_str': str(batch),
             '_parent_uuid': batch.uuid,
             'batch_uuid': batch.uuid,
             'batch_id': batch.id,
@@ -268,9 +276,10 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
             'batch_description': batch.description,
             'batch_complete': batch.complete,
             'batch_executed': bool(batch.executed),
+            'batch_mutable': self.batch_handler.is_mutable(batch),
             'sequence': row.sequence,
             'status_code': row.status_code,
-            'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)),
+            'status_display': row.STATUS.get(row.status_code, str(row.status_code)),
         }
 
     def update_object(self, row, data):
@@ -280,11 +289,14 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
         Invokes the batch handler's ``refresh_row()`` method after updating the
         row's field data per usual.
         """
+        if not self.batch_handler.is_mutable(row.batch):
+            return {'error': "Batch is not mutable"}
+
         # update row per usual
         row = super(APIBatchRowView, self).update_object(row, data)
 
         # okay now we apply handler refresh logic
-        self.handler.refresh_row(row)
+        self.batch_handler.refresh_row(row)
         return row
 
     def delete_object(self, row):
@@ -293,7 +305,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
 
         Delegates deletion of the row to the batch handler.
         """
-        self.handler.do_remove_row(row)
+        self.batch_handler.do_remove_row(row)
 
     def quick_entry(self):
         """
@@ -302,23 +314,26 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
         data = self.request.json_body
 
         uuid = data['batch_uuid']
-        batch = self.Session.query(self.get_batch_class()).get(uuid)
+        batch = self.Session.get(self.get_batch_class(), uuid)
         if not batch:
             raise self.notfound()
 
         entry = data['quick_entry']
 
         try:
-            row = self.handler.quick_entry(self.Session(), batch, entry)
+            row = self.batch_handler.quick_entry(self.Session(), batch, entry)
         except Exception as error:
             log.warning("quick entry failed for '%s' batch %s: %s",
-                        self.handler.batch_key, batch.id_str, entry,
+                        self.batch_handler.batch_key, batch.id_str, entry,
                         exc_info=True)
-            msg = six.text_type(error)
+            msg = str(error)
             if not msg and isinstance(error, NotImplementedError):
                 msg = "Feature is not implemented"
             return {'error': msg}
 
+        if not row:
+            return {'error': "Could not identify product"}
+
         self.Session.flush()
         result = self._get(obj=row)
         result['ok'] = True
@@ -334,13 +349,12 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
         route_prefix = cls.get_route_prefix()
         permission_prefix = cls.get_permission_prefix()
         collection_url_prefix = cls.get_collection_url_prefix()
-        object_url_prefix = cls.get_object_url_prefix()
 
         if cls.supports_quick_entry:
 
             # quick entry
-            config.add_route('{}.quick_entry'.format(route_prefix), '{}/quick-entry'.format(collection_url_prefix),
-                             request_method=('OPTIONS', 'POST'))
-            config.add_view(cls, attr='quick_entry', route_name='{}.quick_entry'.format(route_prefix),
-                            permission='{}.edit'.format(permission_prefix),
-                            renderer='json')
+            quick_entry = Service(name='{}.quick_entry'.format(route_prefix),
+                                  path='{}/quick-entry'.format(collection_url_prefix))
+            quick_entry.add_view('POST', 'quick_entry', klass=cls,
+                                 permission='{}.edit'.format(permission_prefix))
+            config.add_cornice_service(quick_entry)
diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py
index 40ab8ef6..22b67e54 100644
--- a/tailbone/api/batch/inventory.py
+++ b/tailbone/api/batch/inventory.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,12 @@
 Tailbone Web API - Inventory Batches
 """
 
-from __future__ import unicode_literals, absolute_import
+import decimal
 
-import six
+import sqlalchemy as sa
 
 from rattail import pod
-from rattail.db import model
-from rattail.util import pretty_quantity
+from rattail.db.model import InventoryBatch, InventoryBatchRow
 
 from cornice import Service
 
@@ -39,7 +38,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView
 
 class InventoryBatchViews(APIBatchView):
 
-    model_class = model.InventoryBatch
+    model_class = InventoryBatch
     default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
     route_prefix = 'inventory'
     permission_prefix = 'batch.inventory'
@@ -48,12 +47,12 @@ class InventoryBatchViews(APIBatchView):
     supports_toggle_complete = True
 
     def normalize(self, batch):
-        data = super(InventoryBatchViews, self).normalize(batch)
+        data = super().normalize(batch)
 
         data['mode'] = batch.mode
         data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
         if data['mode_display'] is None and batch.mode is not None:
-            data['mode_display'] = six.text_type(batch.mode)
+            data['mode_display'] = str(batch.mode)
 
         data['reason_code'] = batch.reason_code
 
@@ -65,9 +64,9 @@ class InventoryBatchViews(APIBatchView):
         """
         permission_prefix = self.get_permission_prefix()
         if self.request.is_root:
-            modes = self.handler.get_count_modes()
+            modes = self.batch_handler.get_count_modes()
         else:
-            modes = self.handler.get_allowed_count_modes(
+            modes = self.batch_handler.get_allowed_count_modes(
                 self.Session(), self.request.user,
                 permission_prefix=permission_prefix)
         return modes
@@ -77,7 +76,7 @@ class InventoryBatchViews(APIBatchView):
         Retrieve info about the available "reasons" for inventory adjustment
         batches.
         """
-        raw_reasons = self.handler.get_adjustment_reasons(self.Session())
+        raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session())
         reasons = []
         for reason in raw_reasons:
             reasons.append({
@@ -117,7 +116,7 @@ class InventoryBatchViews(APIBatchView):
 
 class InventoryBatchRowViews(APIBatchRowView):
 
-    model_class = model.InventoryBatchRow
+    model_class = InventoryBatchRow
     default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
     route_prefix = 'inventory.rows'
     permission_prefix = 'batch.inventory'
@@ -128,26 +127,27 @@ class InventoryBatchRowViews(APIBatchRowView):
 
     def normalize(self, row):
         batch = row.batch
-        data = super(InventoryBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
+        app = self.get_rattail_app()
 
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
         data['brand_name'] = row.brand_name
         data['description'] = row.description
         data['size'] = row.size
         data['full_description'] = row.product.full_description if row.product else row.description
         data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
-        data['case_quantity'] = pretty_quantity(row.case_quantity or 1)
+        data['case_quantity'] = app.render_quantity(row.case_quantity or 1)
 
         data['cases'] = row.cases
         data['units'] = row.units
         data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
         data['quantity_display'] = "{} {}".format(
-            pretty_quantity(row.cases or row.units),
+            app.render_quantity(row.cases or row.units),
             'CS' if row.cases else data['unit_uom'])
 
-        data['allow_cases'] = self.handler.allow_cases(batch)
+        data['allow_cases'] = self.batch_handler.allow_cases(batch)
 
         return data
 
@@ -157,23 +157,44 @@ class InventoryBatchRowViews(APIBatchRowView):
 
         Converts certain fields within the data, to proper "native" types.
         """
+        data = dict(data)
+
         # convert some data types as needed
         if 'cases' in data:
             if data['cases'] == '':
                 data['cases'] = None
             elif data['cases']:
-                data['cases'] = int(data['cases'])
+                data['cases'] = decimal.Decimal(data['cases'])
         if 'units' in data:
             if data['units'] == '':
                 data['units'] = None
             elif data['units']:
-                data['units'] = int(data['units'])
+                data['units'] = decimal.Decimal(data['units'])
 
         # update row per usual
-        row = super(InventoryBatchRowViews, self).update_object(row, data)
+        try:
+            row = super().update_object(row, data)
+        except sa.exc.DataError as error:
+            # detect when user scans barcode for cases/units field
+            if hasattr(error, 'orig'):
+                orig = type(error.orig)
+                if hasattr(orig, '__name__'):
+                    # nb. this particular error is from psycopg2
+                    if orig.__name__ == 'NumericValueOutOfRange':
+                        return {'error': "Numeric value out of range"}
+            raise
         return row
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews'])
     InventoryBatchViews.defaults(config)
+
+    InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews'])
     InventoryBatchRowViews.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py
index 0648a0c9..4f154b21 100644
--- a/tailbone/api/batch/labels.py
+++ b/tailbone/api/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Label Batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api.batch import APIBatchView, APIBatchRowView
@@ -56,16 +52,27 @@ class LabelBatchRowViews(APIBatchRowView):
 
     def normalize(self, row):
         batch = row.batch
-        data = super(LabelBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
 
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
+        data['brand_name'] = row.brand_name
         data['description'] = row.description
+        data['size'] = row.size
         data['full_description'] = row.product.full_description if row.product else row.description
         return data
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews'])
     LabelBatchViews.defaults(config)
+
+    LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews'])
     LabelBatchRowViews.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py
index 031bccdf..204be8ad 100644
--- a/tailbone/api/batch/ordering.py
+++ b/tailbone/api/batch/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,22 +27,24 @@ These views expose the basic CRUD interface to "ordering" batches, for the web
 API.
 """
 
-from __future__ import unicode_literals, absolute_import
+import datetime
+import logging
 
-import six
+import sqlalchemy as sa
 
-from rattail.core import Object
-from rattail.db import model
-from rattail.util import pretty_quantity
+from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 from cornice import Service
 
 from tailbone.api.batch import APIBatchView, APIBatchRowView
 
 
+log = logging.getLogger(__name__)
+
+
 class OrderingBatchViews(APIBatchView):
 
-    model_class = model.PurchaseBatch
+    model_class = PurchaseBatch
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'orderingbatchviews'
     permission_prefix = 'ordering'
@@ -58,18 +60,19 @@ class OrderingBatchViews(APIBatchView):
         Adds a condition to the query, to ensure only purchase batches with
         "ordering" mode are returned.
         """
-        query = super(OrderingBatchViews, self).base_query()
+        model = self.model
+        query = super().base_query()
         query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
         return query
 
     def normalize(self, batch):
-        data = super(OrderingBatchViews, self).normalize(batch)
+        data = super().normalize(batch)
 
         data['vendor_uuid'] = batch.vendor.uuid
-        data['vendor_display'] = six.text_type(batch.vendor)
+        data['vendor_display'] = str(batch.vendor)
 
         data['department_uuid'] = batch.department_uuid
-        data['department_display'] = six.text_type(batch.department) if batch.department else None
+        data['department_display'] = str(batch.department) if batch.department else None
 
         data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
         data['ship_method'] = batch.ship_method
@@ -83,8 +86,10 @@ class OrderingBatchViews(APIBatchView):
         Sets the mode to "ordering" for the new batch.
         """
         data = dict(data)
+        if not data.get('vendor_uuid'):
+            raise ValueError("You must specify the vendor")
         data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
-        batch = super(OrderingBatchViews, self).create_object(data)
+        batch = super().create_object(data)
         return batch
 
     def worksheet(self):
@@ -95,6 +100,8 @@ class OrderingBatchViews(APIBatchView):
         if batch.executed:
             raise self.forbidden()
 
+        app = self.get_rattail_app()
+
         # TODO: much of the logic below was copied from the traditional master
         # view for ordering batches.  should maybe let them share it somehow?
 
@@ -105,10 +112,10 @@ class OrderingBatchViews(APIBatchView):
 
         # organize vendor catalog costs by dept / subdept
         departments = {}
-        costs = self.handler.get_order_form_costs(self.Session(), batch.vendor)
-        costs = self.handler.sort_order_form_costs(costs)
+        costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor)
+        costs = self.batch_handler.sort_order_form_costs(costs)
         costs = list(costs)   # we must have a stable list for the rest of this
-        self.handler.decorate_order_form_costs(batch, costs)
+        self.batch_handler.decorate_order_form_costs(batch, costs)
         for cost in costs:
 
             department = cost.product.department
@@ -149,7 +156,7 @@ class OrderingBatchViews(APIBatchView):
             product = cost.product
             subdept_costs.append({
                 'uuid': cost.uuid,
-                'upc': six.text_type(product.upc),
+                'upc': str(product.upc),
                 'upc_pretty': product.upc.pretty() if product.upc else None,
                 'brand_name': product.brand.name if product.brand else None,
                 'description': product.description,
@@ -170,16 +177,28 @@ class OrderingBatchViews(APIBatchView):
 
         # sort the (sub)department groupings
         sorted_departments = []
-        for dept in sorted(six.itervalues(departments), key=lambda d: d['name']):
-            dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']),
+        for dept in sorted(departments.values(), key=lambda d: d['name']):
+            dept['subdepartments'] = sorted(dept['subdepartments'].values(),
                                             key=lambda s: s['name'])
             sorted_departments.append(dept)
 
         # fetch recent purchase history, sort/pad for template convenience
-        history = self.handler.get_order_form_history(batch, costs, 6)
+        history = self.batch_handler.get_order_form_history(batch, costs, 6)
         for i in range(6 - len(history)):
             history.append(None)
         history = list(reversed(history))
+        # must convert some date objects to string, for JSON sake
+        for h in history:
+            if not h:
+                continue
+            purchase = h.get('purchase')
+            if purchase:
+                dt = purchase.get('date_ordered')
+                if dt and isinstance(dt, datetime.date):
+                    purchase['date_ordered'] = app.render_date(dt)
+                dt = purchase.get('date_received')
+                if dt and isinstance(dt, datetime.date):
+                    purchase['date_received'] = app.render_date(dt)
 
         return {
             'batch': self.normalize(batch),
@@ -210,7 +229,7 @@ class OrderingBatchViews(APIBatchView):
 
 class OrderingBatchRowViews(APIBatchRowView):
 
-    model_class = model.PurchaseBatchRow
+    model_class = PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'ordering.rows'
     permission_prefix = 'ordering'
@@ -220,11 +239,12 @@ class OrderingBatchRowViews(APIBatchRowView):
     editable = True
 
     def normalize(self, row):
+        data = super().normalize(row)
+        app = self.get_rattail_app()
         batch = row.batch
-        data = super(OrderingBatchRowViews, self).normalize(row)
 
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
         data['brand_name'] = row.brand_name
         data['description'] = row.description
@@ -241,15 +261,15 @@ class OrderingBatchRowViews(APIBatchRowView):
         data['case_quantity'] = row.case_quantity
         data['cases_ordered'] = row.cases_ordered
         data['units_ordered'] = row.units_ordered
-        data['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False)
-        data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False)
+        data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False)
+        data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False)
 
         data['po_unit_cost'] = row.po_unit_cost
         data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None
         data['po_total_calculated'] = row.po_total_calculated
         data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None
         data['status_code'] = row.status_code
-        data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code))
+        data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code))
 
         return data
 
@@ -267,10 +287,32 @@ class OrderingBatchRowViews(APIBatchRowView):
 
         Note that the "normal" logic for this method is not invoked at all.
         """
-        self.handler.update_row_quantity(row, **data)
+        if not self.batch_handler.is_mutable(row.batch):
+            return {'error': "Batch is not mutable"}
+
+        try:
+            self.batch_handler.update_row_quantity(row, **data)
+            self.Session.flush()
+        except Exception as error:
+            log.warning("update_row_quantity failed", exc_info=True)
+            if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
+                error = str(error.orig)
+            else:
+                error = str(error)
+            return {'error': error}
+
         return row
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews'])
     OrderingBatchViews.defaults(config)
+
+    OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews'])
     OrderingBatchRowViews.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index 71a8bcba..b23bff55 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +24,14 @@
 Tailbone Web API - Receiving Batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import logging
 
-import six
 import humanize
+import sqlalchemy as sa
 
-from rattail import pod
-from rattail.db import model
-from rattail.time import make_utc
-from rattail.util import pretty_quantity
+from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
+from cornice import Service
 from deform import widget as dfwidget
 
 from tailbone import forms
@@ -48,7 +44,7 @@ log = logging.getLogger(__name__)
 
 class ReceivingBatchViews(APIBatchView):
 
-    model_class = model.PurchaseBatch
+    model_class = PurchaseBatch
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receivingbatchviews'
     permission_prefix = 'receiving'
@@ -58,30 +54,49 @@ class ReceivingBatchViews(APIBatchView):
     supports_execute = True
 
     def base_query(self):
-        query = super(ReceivingBatchViews, self).base_query()
+        model = self.app.model
+        query = super().base_query()
         query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
         return query
 
     def normalize(self, batch):
-        data = super(ReceivingBatchViews, self).normalize(batch)
+        data = super().normalize(batch)
 
         data['vendor_uuid'] = batch.vendor.uuid
-        data['vendor_display'] = six.text_type(batch.vendor)
+        data['vendor_display'] = str(batch.vendor)
 
         data['department_uuid'] = batch.department_uuid
-        data['department_display'] = six.text_type(batch.department) if batch.department else None
+        data['department_display'] = str(batch.department) if batch.department else None
 
+        data['po_number'] = batch.po_number
         data['po_total'] = batch.po_total
         data['invoice_total'] = batch.invoice_total
         data['invoice_total_calculated'] = batch.invoice_total_calculated
 
+        data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch)
+
         return data
 
     def create_object(self, data):
         data = dict(data)
+
+        # all about receiving mode here
         data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
-        batch = super(ReceivingBatchViews, self).create_object(data)
-        return batch
+
+        # assume "receive from PO" if given a PO key
+        if data.get('purchase_key'):
+            data['workflow'] = 'from_po'
+
+        return super().create_object(data)
+
+    def auto_receive(self):
+        """
+        View which handles auto-marking as received, all items within
+        a pending batch.
+        """
+        batch = self.get_object()
+        self.batch_handler.auto_receive_all_items(batch)
+        return self._get(obj=batch)
 
     def mark_receiving_complete(self):
         """
@@ -105,12 +120,13 @@ class ReceivingBatchViews(APIBatchView):
         return self._get(obj=batch)
 
     def eligible_purchases(self):
+        model = self.app.model
         uuid = self.request.params.get('vendor_uuid')
-        vendor = self.Session.query(model.Vendor).get(uuid) if uuid else None
+        vendor = self.Session.get(model.Vendor, uuid) if uuid else None
         if not vendor:
             return {'error': "Vendor not found"}
 
-        purchases = self.handler.get_eligible_purchases(
+        purchases = self.batch_handler.get_eligible_purchases(
             vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
 
         purchases = [self.normalize_eligible_purchase(p)
@@ -119,20 +135,10 @@ class ReceivingBatchViews(APIBatchView):
         return {'purchases': purchases}
 
     def normalize_eligible_purchase(self, purchase):
-        return {
-            'key': purchase.uuid,
-            'department_uuid': purchase.department_uuid,
-            'display': self.render_eligible_purchase(purchase),
-        }
+        return self.batch_handler.normalize_eligible_purchase(purchase)
 
     def render_eligible_purchase(self, purchase):
-        if purchase.status == self.enum.PURCHASE_STATUS_ORDERED:
-            date = purchase.date_ordered
-            total = purchase.po_total
-        elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED:
-            date = purchase.date_received
-            total = purchase.invoice_total
-        return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer)
+        return self.batch_handler.render_eligible_purchase(purchase)
 
     @classmethod
     def defaults(cls, config):
@@ -147,23 +153,31 @@ class ReceivingBatchViews(APIBatchView):
         collection_url_prefix = cls.get_collection_url_prefix()
         object_url_prefix = cls.get_object_url_prefix()
 
-        # mark receiving complete
-        config.add_route('{}.mark_receiving_complete'.format(route_prefix), '{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
-        config.add_view(cls, attr='mark_receiving_complete', route_name='{}.mark_receiving_complete'.format(route_prefix),
-                        permission='{}.edit'.format(permission_prefix),
-                        renderer='json')
+        # auto_receive
+        auto_receive = Service(name='{}.auto_receive'.format(route_prefix),
+                               path='{}/{{uuid}}/auto-receive'.format(object_url_prefix))
+        auto_receive.add_view('GET', 'auto_receive', klass=cls,
+                              permission='{}.auto_receive'.format(permission_prefix))
+        config.add_cornice_service(auto_receive)
+
+        # mark_receiving_complete
+        mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix),
+                                          path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
+        mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls,
+                                         permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(mark_receiving_complete)
 
         # eligible purchases
-        config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(collection_url_prefix),
-                         request_method='GET')
-        config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),
-                        permission='{}.create'.format(permission_prefix),
-                        renderer='json')
+        eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix),
+                                     path='{}/eligible-purchases'.format(collection_url_prefix))
+        eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls,
+                                    permission='{}.create'.format(permission_prefix))
+        config.add_cornice_service(eligible_purchases)
 
 
 class ReceivingBatchRowViews(APIBatchRowView):
 
-    model_class = model.PurchaseBatchRow
+    model_class = PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receiving.rows'
     permission_prefix = 'receiving'
@@ -172,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
     supports_quick_entry = True
 
     def make_filter_spec(self):
-        filters = super(ReceivingBatchRowViews, self).make_filter_spec()
+        model = self.app.model
+        filters = super().make_filter_spec()
         if filters:
 
             # must translate certain convenience filters
@@ -268,18 +283,30 @@ class ReceivingBatchRowViews(APIBatchRowView):
                         ]},
                     ])
 
+                # is_missing
+                elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True:
+                    filters.extend([
+                        {'or': [
+                            {'field': 'cases_missing', 'op': '!=', 'value': 0},
+                            {'field': 'units_missing', 'op': '!=', 'value': 0},
+                        ]},
+                    ])
+
                 else: # just some filter, use as-is
                     filters.append(filtr)
 
         return filters
 
     def normalize(self, row):
+        data = super().normalize(row)
+        model = self.app.model
+
         batch = row.batch
-        data = super(ReceivingBatchRowViews, self).normalize(row)
+        prodder = self.app.get_products_handler()
 
         data['product_uuid'] = row.product_uuid
         data['item_id'] = row.item_id
-        data['upc'] = six.text_type(row.upc)
+        data['upc'] = str(row.upc)
         data['upc_pretty'] = row.upc.pretty() if row.upc else None
         data['brand_name'] = row.brand_name
         data['description'] = row.description
@@ -288,7 +315,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
 
         # only provide image url if so configured
         if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
-            data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
+            data['image_url'] = prodder.get_image_url(product=row.product, upc=row.upc)
 
         # unit_uom can vary by product
         data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
@@ -311,14 +338,22 @@ class ReceivingBatchRowViews(APIBatchRowView):
         data['cases_expired'] = row.cases_expired
         data['units_expired'] = row.units_expired
 
+        data['cases_missing'] = row.cases_missing
+        data['units_missing'] = row.units_missing
+
+        cases, units = self.batch_handler.get_unconfirmed_counts(row)
+        data['cases_unconfirmed'] = cases
+        data['units_unconfirmed'] = units
+
         data['po_unit_cost'] = row.po_unit_cost
         data['po_total'] = row.po_total
 
+        data['invoice_number'] = row.invoice_number
         data['invoice_unit_cost'] = row.invoice_unit_cost
         data['invoice_total'] = row.invoice_total
         data['invoice_total_calculated'] = row.invoice_total_calculated
 
-        data['allow_cases'] = self.handler.allow_cases()
+        data['allow_cases'] = self.batch_handler.allow_cases()
 
         data['quick_receive'] = self.rattail_config.getbool(
             'rattail.batch', 'purchase.mobile_quick_receive',
@@ -336,13 +371,13 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 raise NotImplementedError("TODO: add CS support for quick_receive_all")
             else:
                 data['quick_receive_uom'] = data['unit_uom']
-                accounted_for = self.handler.get_units_accounted_for(row)
-                remainder = self.handler.get_units_ordered(row) - accounted_for
+                accounted_for = self.batch_handler.get_units_accounted_for(row)
+                remainder = self.batch_handler.get_units_ordered(row) - accounted_for
 
                 if accounted_for:
                     # some product accounted for; button should receive "remainder" only
                     if remainder:
-                        remainder = pretty_quantity(remainder)
+                        remainder = self.app.render_quantity(remainder)
                         data['quick_receive_quantity'] = remainder
                         data['quick_receive_text'] = "Receive Remainder ({} {})".format(
                             remainder, data['unit_uom'])
@@ -353,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 else: # nothing yet accounted for, button should receive "all"
                     if not remainder:
                         log.warning("quick receive remainder is empty for row %s", row.uuid)
-                    remainder = pretty_quantity(remainder)
+                    remainder = self.app.render_quantity(remainder)
                     data['quick_receive_quantity'] = remainder
                     data['quick_receive_text'] = "Receive ALL ({} {})".format(
                         remainder, data['unit_uom'])
@@ -379,9 +414,9 @@ class ReceivingBatchRowViews(APIBatchRowView):
                                                      default=False)
         if alert_received:
             data['received_alert'] = None
-            if self.handler.get_units_confirmed(row):
+            if self.batch_handler.get_units_confirmed(row):
                 msg = "You have already received some of this product; last update was {}.".format(
-                    humanize.naturaltime(make_utc() - row.modified))
+                    humanize.naturaltime(self.app.make_utc() - row.modified))
                 data['received_alert'] = msg
 
         return data
@@ -390,27 +425,37 @@ class ReceivingBatchRowViews(APIBatchRowView):
         """
         View which handles "receiving" against a particular batch row.
         """
+        model = self.app.model
+
         # first do basic input validation
         schema = ReceiveRow().bind(session=self.Session())
         form = forms.Form(schema=schema, request=self.request)
         # TODO: this seems hacky, but avoids "complex" date value parsing
         form.set_widget('expiration_date', dfwidget.TextInputWidget())
-        if not form.validate(newstyle=True):
-            log.debug("form did not validate: %s",
-                      form.make_deform_form().error)
+        if not form.validate():
+            log.warning("form did not validate: %s",
+                        form.make_deform_form().error)
             return {'error': "Form did not validate"}
 
         # fetch / validate row object
-        row = self.Session.query(model.PurchaseBatchRow).get(form.validated['row'])
+        row = self.Session.get(model.PurchaseBatchRow, form.validated['row'])
         if row is not self.get_object():
             return {'error': "Specified row does not match the route!"}
 
         # handler takes care of the row receiving logic for us
         kwargs = dict(form.validated)
         del kwargs['row']
-        self.handler.receive_row(row, **kwargs)
+        try:
+            self.batch_handler.receive_row(row, **kwargs)
+            self.Session.flush()
+        except Exception as error:
+            log.warning("receive() failed", exc_info=True)
+            if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
+                error = str(error.orig)
+            else:
+                error = str(error)
+            return {'error': error}
 
-        self.Session.flush()
         return self._get(obj=row)
 
     @classmethod
@@ -426,13 +471,22 @@ class ReceivingBatchRowViews(APIBatchRowView):
         object_url_prefix = cls.get_object_url_prefix()
 
         # receive (row)
-        config.add_route('{}.receive'.format(route_prefix), '{}/{{uuid}}/receive'.format(object_url_prefix),
-                         request_method=('OPTIONS', 'POST'))
-        config.add_view(cls, attr='receive', route_name='{}.receive'.format(route_prefix),
-                        permission='{}.edit_row'.format(permission_prefix),
-                        renderer='json')
+        receive = Service(name='{}.receive'.format(route_prefix),
+                          path='{}/{{uuid}}/receive'.format(object_url_prefix))
+        receive.add_view('POST', 'receive', klass=cls,
+                         permission='{}.edit_row'.format(permission_prefix))
+        config.add_cornice_service(receive)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews'])
+    ReceivingBatchViews.defaults(config)
+
+    ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews'])
+    ReceivingBatchRowViews.defaults(config)
 
 
 def includeme(config):
-    ReceivingBatchViews.defaults(config)
-    ReceivingBatchRowViews.defaults(config)
+    defaults(config)
diff --git a/tailbone/api/common.py b/tailbone/api/common.py
index 0552b68d..6cacfb06 100644
--- a/tailbone/api/common.py
+++ b/tailbone/api/common.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,16 +24,14 @@
 Tailbone Web API - "Common" Views
 """
 
-from __future__ import unicode_literals, absolute_import
+from collections import OrderedDict
 
-import rattail
-from rattail.db import model
-from rattail.mail import send_email
-from rattail.util import OrderedDict
+from rattail.util import get_pkg_version
 
 from cornice import Service
+from cornice.service import get_services
+from cornice_swagger import CorniceSwagger
 
-import tailbone
 from tailbone import forms
 from tailbone.forms.common import Feedback
 from tailbone.api import APIView, api
@@ -65,11 +63,12 @@ class CommonView(APIView):
         }
 
     def get_project_title(self):
-        return self.rattail_config.app_title(default="Tailbone")
+        app = self.get_rattail_app()
+        return app.get_title()
 
     def get_project_version(self):
-        import tailbone
-        return tailbone.__version__
+        app = self.get_rattail_app()
+        return app.get_version()
 
     def get_packages(self):
         """
@@ -77,8 +76,8 @@ class CommonView(APIView):
         'about' page.
         """
         return OrderedDict([
-            ('rattail', rattail.__version__),
-            ('Tailbone', tailbone.__version__),
+            ('rattail', get_pkg_version('rattail')),
+            ('Tailbone', get_pkg_version('Tailbone')),
         ])
 
     @api
@@ -86,31 +85,48 @@ class CommonView(APIView):
         """
         View to handle user feedback form submits.
         """
+        app = self.get_rattail_app()
+        model = self.model
         # TODO: this logic was copied from tailbone.views.common and is largely
         # identical; perhaps should merge somehow?
         schema = Feedback().bind(session=Session())
         form = forms.Form(schema=schema, request=self.request)
-        if form.validate(newstyle=True):
+        if form.validate():
             data = dict(form.validated)
 
             # figure out who the sending user is, if any
             if self.request.user:
                 data['user'] = self.request.user
             elif data['user']:
-                data['user'] = Session.query(model.User).get(data['user'])
+                data['user'] = Session.get(model.User, data['user'])
 
             # TODO: should provide URL to view user
             if data['user']:
                 data['user_url'] = '#' # TODO: could get from config?
 
             data['client_ip'] = self.request.client_addr
-            send_email(self.rattail_config, self.feedback_email_key, data=data)
+            email_key = data['email_key'] or self.feedback_email_key
+            app.send_email(email_key, data=data)
             return {'ok': True}
 
         return {'error': "Form did not validate!"}
 
+    def swagger(self):
+        doc = CorniceSwagger(get_services())
+        app = self.get_rattail_app()
+        spec = doc.generate(f"{app.get_node_title()} API docs",
+                            app.get_version(),
+                            base_path='/api') # TODO
+        return spec
+
     @classmethod
     def defaults(cls, config):
+        cls._common_defaults(config)
+
+    @classmethod
+    def _common_defaults(cls, config):
+        rattail_config = config.registry.settings.get('rattail_config')
+        app = rattail_config.get_app()
 
         # about
         about = Service(name='about', path='/about')
@@ -123,6 +139,21 @@ class CommonView(APIView):
                           permission='common.feedback')
         config.add_cornice_service(feedback)
 
+        # swagger
+        swagger = Service(name='swagger',
+                          path='/swagger.json',
+                          description=f"OpenAPI documentation for {app.get_title()}")
+        swagger.add_view('GET', 'swagger', klass=cls,
+                         permission='common.api_swagger')
+        config.add_cornice_service(swagger)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    CommonView = kwargs.get('CommonView', base['CommonView'])
+    CommonView.defaults(config)
+
 
 def includeme(config):
-    CommonView.defaults(config)
+    defaults(config)
diff --git a/tailbone/api/core.py b/tailbone/api/core.py
index 65aa9699..0d8eec32 100644
--- a/tailbone/api/core.py
+++ b/tailbone/api/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Core Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-from rattail.util import load_object
-
 from tailbone.views import View
 
 
@@ -102,24 +98,28 @@ class APIView(View):
                info.pop('short_name', None)
                return info
         """
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
         # basic / default info
-        is_admin = user.is_admin()
-        employee = user.employee
+        is_admin = auth.user_is_admin(user)
+        employee = app.get_employee(user)
         info = {
             'uuid': user.uuid,
             'username': user.username,
             'display_name': user.display_name,
-            'short_name': user.get_short_name(),
+            'short_name': auth.get_short_display_name(user),
             'is_admin': is_admin,
             'is_root': is_admin and self.request.session.get('is_root', False),
             'employee_uuid': employee.uuid if employee else None,
+            'email_address': app.get_contact_email_address(user),
         }
 
         # maybe get/use "extra" info
         extra = self.rattail_config.get('tailbone.api', 'extra_user_info',
                                         usedb=False)
         if extra:
-            extra = load_object(extra)
+            extra = app.load_object(extra)
             info = extra(self.request, user, **info)
 
         return info
diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py
index 2e0a9d4c..85d28c24 100644
--- a/tailbone/api/customers.py
+++ b/tailbone/api/customers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,9 @@
 Tailbone Web API - Customer Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
-from tailbone.api import APIMasterView2 as APIMasterView
+from tailbone.api import APIMasterView
 
 
 class CustomerView(APIMasterView):
@@ -40,15 +36,25 @@ class CustomerView(APIMasterView):
     model_class = model.Customer
     collection_url_prefix = '/customers'
     object_url_prefix = '/customer'
+    supports_autocomplete = True
+    autocomplete_fieldname = 'name'
 
     def normalize(self, customer):
         return {
             'uuid': customer.uuid,
-            '_str': six.text_type(customer),
+            '_str': str(customer),
             'id': customer.id,
+            'number': customer.number,
             'name': customer.name,
         }
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    CustomerView = kwargs.get('CustomerView', base['CustomerView'])
     CustomerView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/essentials.py b/tailbone/api/essentials.py
new file mode 100644
index 00000000..7b151578
--- /dev/null
+++ b/tailbone/api/essentials.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Essential views for convenient includes
+"""
+
+
+def defaults(config, **kwargs):
+    mod = lambda spec: kwargs.get(spec, spec)
+
+    config.include(mod('tailbone.api.auth'))
+    config.include(mod('tailbone.api.common'))
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/scaffolds.py b/tailbone/api/labels.py
similarity index 57%
rename from tailbone/scaffolds.py
rename to tailbone/api/labels.py
index 10bf9640..8bc11f8f 100644
--- a/tailbone/scaffolds.py
+++ b/tailbone/api/labels.py
@@ -1,8 +1,8 @@
-# -*- coding: utf-8 -*-
+# -*- coding: utf-8; -*-
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -21,25 +21,31 @@
 #
 ################################################################################
 """
-Pyramid scaffold templates
+Tailbone Web API - Label Views
 """
 
 from __future__ import unicode_literals, absolute_import
 
-from rattail.files import resource_path
-from rattail.util import prettify
+from rattail.db.model import LabelProfile
 
-from pyramid.scaffolds import PyramidTemplate
+from tailbone.api import APIMasterView
 
 
-class RattailTemplate(PyramidTemplate):
-    _template_dir = resource_path('rattail:data/project')
-    summary = "Starter project based on Rattail / Tailbone"
+class LabelProfileView(APIMasterView):
+    """
+    API views for Label Profile data
+    """
+    model_class = LabelProfile
+    collection_url_prefix = '/label-profiles'
+    object_url_prefix = '/label-profile'
 
-    def pre(self, command, output_dir, vars):
-        """
-        Adds some more variables to the template context.
-        """
-        vars['project_title'] = prettify(vars['project'])
-        vars['package_title'] = vars['package'].capitalize()
-        return super(RattailTemplate, self).pre(command, output_dir, vars)
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView'])
+    LabelProfileView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/master.py b/tailbone/api/master.py
index f215bee1..551d6428 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,28 +24,29 @@
 Tailbone Web API - Master View
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import json
-import six
 
-from rattail.config import parse_bool
+from rattail.db.util import get_fieldnames
 
-from tailbone.api import APIView, api
+from cornice import resource, Service
+
+from tailbone.api import APIView
 from tailbone.db import Session
-
-
-class SortColumn(object):
-
-    def __init__(self, field_name, model_name=None):
-        self.field_name = field_name
-        self.model_name = model_name
+from tailbone.util import SortColumn
 
 
 class APIMasterView(APIView):
     """
     Base class for data model REST API views.
     """
+    listable = True
+    creatable = True
+    viewable = True
+    editable = True
+    deletable = True
+    supports_autocomplete = False
+    supports_download = False
+    supports_rawbytes = False
 
     @property
     def Session(self):
@@ -120,6 +121,34 @@ class APIMasterView(APIView):
             return cls.collection_key
         return '{}s'.format(cls.get_object_key())
 
+    @classmethod
+    def establish_method(cls, method_name):
+        """
+        Establish the given HTTP method for this Cornice Resource.
+
+        Cornice will auto-register any class methods for a resource, if they
+        are named according to what it expects (i.e. 'get', 'collection_get'
+        etc.).  Tailbone API tries to make things automagical for the sake of
+        e.g. Poser logic, but in this case if we predefine all of these methods
+        and then some subclass view wants to *not* allow one, it's not clear
+        how to "undefine" it per se.  Or at least, the more straightforward
+        thing (I think) is to not define such a method in the first place, if
+        it was not wanted.
+
+        Enter ``establish_method()``, which is what finally "defines" each
+        resource method according to what the subclass has declared via its
+        various attributes (:attr:`creatable`, :attr:`deletable` etc.).
+
+        Note that you will not likely have any need to use this
+        ``establish_method()`` yourself!  But we describe its purpose here, for
+        clarity.
+        """
+        def method(self):
+            internal_method = getattr(self, '_{}'.format(method_name))
+            return internal_method()
+
+        setattr(cls, method_name, method)
+
     def make_filter_spec(self):
         if not self.request.GET.has_key('filters'):
             return []
@@ -129,6 +158,10 @@ class APIMasterView(APIView):
 
     def make_sort_spec(self):
 
+        # we prefer a "native sort"
+        if self.request.GET.has_key('nativeSort'):
+            return json.loads(self.request.GET.getone('nativeSort'))
+
         # these params are based on 'vuetable-2'
         # https://www.vuetable.com/guide/sorting.html#initial-sorting-order
         if 'sort' in self.request.params:
@@ -151,7 +184,7 @@ class APIMasterView(APIView):
             if sortcol:
                 spec = {
                     'field': sortcol.field_name,
-                    'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
+                    'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
                 }
                 if sortcol.model_name:
                     spec['model'] = sortcol.model_name
@@ -173,17 +206,14 @@ class APIMasterView(APIView):
         """
         return self.sortcol(order_by)
 
-    def sortcol(self, *args):
+    def sortcol(self, field_name, model_name=None):
         """
         Return a simple ``SortColumn`` object which denotes the field and
         optionally, the model, to be used when sorting.
         """
-        if len(args) == 1:
-            return SortColumn(args[0])
-        elif len(args) == 2:
-            return SortColumn(args[1], args[0])
-        else:
-            raise ValueError("must pass 1 arg (field_name) or 2 args (model_name, field_name)")
+        if not model_name:
+            model_name = self.model_class.__name__
+        return SortColumn(field_name, model_name)
 
     def join_for_sort_spec(self, query, sort_spec):
         """
@@ -230,8 +260,23 @@ class APIMasterView(APIView):
         query = self.Session.query(cls)
         return query
 
+    def get_fieldnames(self):
+        if not hasattr(self, '_fieldnames'):
+            self._fieldnames = get_fieldnames(
+                self.rattail_config, self.model_class,
+                columns=True, proxies=True, relations=False)
+        return self._fieldnames
+
+    def normalize(self, obj):
+        data = {'_str': str(obj)}
+
+        for field in self.get_fieldnames():
+            data[field] = getattr(obj, field)
+
+        return data
+
     def _collection_get(self):
-        from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination
+        from sa_filters import apply_filters, apply_sort, apply_pagination
 
         query = self.base_query()
         context = {}
@@ -287,7 +332,7 @@ class APIMasterView(APIView):
         if not uuid:
             uuid = self.request.matchdict['uuid']
 
-        obj = self.Session.query(self.get_model_class()).get(uuid)
+        obj = self.Session.get(self.get_model_class(), uuid)
         if obj:
             return obj
 
@@ -309,9 +354,13 @@ class APIMasterView(APIView):
         data = self.request.json_body
 
         # add instance to session, and return data for it
-        obj = self.create_object(data)
-        self.Session.flush()
-        return self._get(obj)
+        try:
+            obj = self.create_object(data)
+        except Exception as error:
+            return self.json_response({'error': str(error)})
+        else:
+            self.Session.flush()
+            return self._get(obj)
 
     def create_object(self, data):
         """
@@ -338,15 +387,19 @@ class APIMasterView(APIView):
         """
         if not uuid:
             uuid = self.request.matchdict['uuid']
-        obj = self.Session.query(self.get_model_class()).get(uuid)
+        obj = self.Session.get(self.get_model_class(), uuid)
         if not obj:
             raise self.notfound()
 
         # assume our data comes only from request JSON body
         data = self.request.json_body
 
-        # update and return data for object
+        # try to update data for object, returning error as necessary
         obj = self.update_object(obj, data)
+        if isinstance(obj, dict) and 'error' in obj:
+            return {'error': obj['error']}
+
+        # return data for object
         self.Session.flush()
         return self._get(obj)
 
@@ -366,6 +419,70 @@ class APIMasterView(APIView):
         # that's all we can do here, subclass must override if more needed
         return obj
 
+    ##############################
+    # delete
+    ##############################
+
+    def _delete(self):
+        """
+        View to handle DELETE action for an existing record/object.
+        """
+        obj = self.get_object()
+        self.delete_object(obj)
+
+    def delete_object(self, obj):
+        """
+        Delete the object, or mark it as deleted, or whatever you need to do.
+        """
+        # flush immediately to force any pending integrity errors etc.
+        self.Session.delete(obj)
+        self.Session.flush()
+
+    ##############################
+    # download
+    ##############################
+
+    def download(self):
+        """
+        GET view allowing for download of a single file, which is attached to a
+        given record.
+        """
+        obj = self.get_object()
+
+        filename = self.request.GET.get('filename', None)
+        if not filename:
+            raise self.notfound()
+        path = self.download_path(obj, filename)
+
+        response = self.file_response(path)
+        return response
+
+    def download_path(self, obj, filename):
+        """
+        Should return absolute path on disk, for the given object and filename.
+        Result will be used to return a file response to client.
+        """
+        raise NotImplementedError
+
+    def rawbytes(self):
+        """
+        GET view allowing for direct access to the raw bytes of a file, which
+        is attached to a given record.  Basically the same as 'download' except
+        this does not come as an attachment.
+        """
+        obj = self.get_object()
+
+        # TODO: is this really needed?
+        # filename = self.request.GET.get('filename', None)
+        # if filename:
+        #     path = self.download_path(obj, filename)
+        #     return self.file_response(path, attachment=False)
+
+        return self.rawbytes_response(obj)
+
+    def rawbytes_response(self, obj):
+        raise NotImplementedError
+
     ##############################
     # autocomplete
     ##############################
@@ -421,3 +538,81 @@ class APIMasterView(APIView):
         autocomplete query.
         """
         return term
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+
+    @classmethod
+    def _defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        collection_url_prefix = cls.get_collection_url_prefix()
+        object_url_prefix = cls.get_object_url_prefix()
+
+        # first, the primary resource API
+
+        # list/search
+        if cls.listable:
+            cls.establish_method('collection_get')
+            resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix))
+
+        # create
+        if cls.creatable:
+            cls.establish_method('collection_post')
+            if hasattr(cls, 'permission_to_create'):
+                permission = cls.permission_to_create
+            else:
+                permission = '{}.create'.format(permission_prefix)
+            resource.add_view(cls.collection_post, permission=permission)
+
+        # view
+        if cls.viewable:
+            cls.establish_method('get')
+            resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
+
+        # edit
+        if cls.editable:
+            cls.establish_method('post')
+            resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix))
+
+        # delete
+        if cls.deletable:
+            cls.establish_method('delete')
+            resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix))
+
+        # register primary resource API via cornice
+        object_resource = resource.add_resource(
+            cls,
+            collection_path=collection_url_prefix,
+            # TODO: probably should allow for other (composite?) key fields
+            path='{}/{{uuid}}'.format(object_url_prefix))
+        config.add_cornice_resource(object_resource)
+
+        # now for some more "custom" things, which are still somewhat generic
+
+        # autocomplete
+        if cls.supports_autocomplete:
+            autocomplete = Service(name='{}.autocomplete'.format(route_prefix),
+                                   path='{}/autocomplete'.format(collection_url_prefix))
+            autocomplete.add_view('GET', 'autocomplete', klass=cls,
+                                  permission='{}.list'.format(permission_prefix))
+            config.add_cornice_service(autocomplete)
+
+        # download
+        if cls.supports_download:
+            download = Service(name='{}.download'.format(route_prefix),
+                               # TODO: probably should allow for other (composite?) key fields
+                               path='{}/{{uuid}}/download'.format(object_url_prefix))
+            download.add_view('GET', 'download', klass=cls,
+                              permission='{}.download'.format(permission_prefix))
+            config.add_cornice_service(download)
+
+        # rawbytes
+        if cls.supports_rawbytes:
+            rawbytes = Service(name='{}.rawbytes'.format(route_prefix),
+                               # TODO: probably should allow for other (composite?) key fields
+                               path='{}/{{uuid}}/rawbytes'.format(object_url_prefix))
+            rawbytes.add_view('GET', 'rawbytes', klass=cls,
+                              permission='{}.download'.format(permission_prefix))
+            config.add_cornice_service(rawbytes)
diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py
index a062343f..4a5abb3e 100644
--- a/tailbone/api/master2.py
+++ b/tailbone/api/master2.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,7 +26,7 @@ Tailbone Web API - Master View (v2)
 
 from __future__ import unicode_literals, absolute_import
 
-from cornice import resource, Service
+import warnings
 
 from tailbone.api import APIMasterView
 
@@ -35,107 +35,9 @@ class APIMasterView2(APIMasterView):
     """
     Base class for data model REST API views.
     """
-    listable = True
-    creatable = True
-    viewable = True
-    editable = True
-    deletable = True
-    supports_autocomplete = False
 
-    @classmethod
-    def establish_method(cls, method_name):
-        """
-        Establish the given HTTP method for this Cornice Resource.
-
-        Cornice will auto-register any class methods for a resource, if they
-        are named according to what it expects (i.e. 'get', 'collection_get'
-        etc.).  Tailbone API tries to make things automagical for the sake of
-        e.g. Poser logic, but in this case if we predefine all of these methods
-        and then some subclass view wants to *not* allow one, it's not clear
-        how to "undefine" it per se.  Or at least, the more straightforward
-        thing (I think) is to not define such a method in the first place, if
-        it was not wanted.
-
-        Enter ``establish_method()``, which is what finally "defines" each
-        resource method according to what the subclass has declared via its
-        various attributes (:attr:`creatable`, :attr:`deletable` etc.).
-
-        Note that you will not likely have any need to use this
-        ``establish_method()`` yourself!  But we describe its purpose here, for
-        clarity.
-        """
-        def method(self):
-            internal_method = getattr(self, '_{}'.format(method_name))
-            return internal_method()
-
-        setattr(cls, method_name, method)
-
-    def _delete(self):
-        """
-        View to handle DELETE action for an existing record/object.
-        """
-        obj = self.get_object()
-        self.delete_object(obj)
-
-    def delete_object(self, obj):
-        """
-        Delete the object, or mark it as deleted, or whatever you need to do.
-        """
-        # flush immediately to force any pending integrity errors etc.
-        self.Session.delete(obj)
-        self.Session.flush()
-
-    @classmethod
-    def defaults(cls, config):
-        cls._defaults(config)
-
-    @classmethod
-    def _defaults(cls, config):
-        route_prefix = cls.get_route_prefix()
-        permission_prefix = cls.get_permission_prefix()
-        collection_url_prefix = cls.get_collection_url_prefix()
-        object_url_prefix = cls.get_object_url_prefix()
-
-        # first, the primary resource API
-
-        # list/search
-        if cls.listable:
-            cls.establish_method('collection_get')
-            resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix))
-
-        # create
-        if cls.creatable:
-            cls.establish_method('collection_post')
-            resource.add_view(cls.collection_post, permission='{}.create'.format(permission_prefix))
-
-        # view
-        if cls.viewable:
-            cls.establish_method('get')
-            resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
-
-        # edit
-        if cls.editable:
-            cls.establish_method('post')
-            resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix))
-
-        # delete
-        if cls.deletable:
-            cls.establish_method('delete')
-            resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix))
-
-        # register primary resource API via cornice
-        object_resource = resource.add_resource(
-            cls,
-            collection_path=collection_url_prefix,
-            # TODO: probably should allow for other (composite?) key fields
-            path='{}/{{uuid}}'.format(object_url_prefix))
-        config.add_cornice_resource(object_resource)
-
-        # now for some more "custom" things, which are still somewhat generic
-
-        # autocomplete
-        if cls.supports_autocomplete:
-            autocomplete = Service(name='{}.autocomplete'.format(route_prefix),
-                                   path='{}/autocomplete'.format(collection_url_prefix))
-            autocomplete.add_view('GET', 'autocomplete', klass=cls)
-            config.add_cornice_service(autocomplete)
+    def __init__(self, request, context=None):
+        warnings.warn("APIMasterView2 class is deprecated; please use "
+                      "APIMasterView instead",
+                      DeprecationWarning, stacklevel=2)
+        super(APIMasterView2, self).__init__(request, context=context)
diff --git a/tailbone/api/people.py b/tailbone/api/people.py
new file mode 100644
index 00000000..f7c08dfa
--- /dev/null
+++ b/tailbone/api/people.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Tailbone Web API - Person Views
+"""
+
+from rattail.db import model
+
+from tailbone.api import APIMasterView
+
+
+class PersonView(APIMasterView):
+    """
+    API views for Person data
+    """
+    model_class = model.Person
+    permission_prefix = 'people'
+    collection_url_prefix = '/people'
+    object_url_prefix = '/person'
+
+    def normalize(self, person):
+        return {
+            'uuid': person.uuid,
+            '_str': str(person),
+            'first_name': person.first_name,
+            'last_name': person.last_name,
+            'display_name': person.display_name,
+        }
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PersonView = kwargs.get('PersonView', base['PersonView'])
+    PersonView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/products.py b/tailbone/api/products.py
index d7aeabcd..3f29ff54 100644
--- a/tailbone/api/products.py
+++ b/tailbone/api/products.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,19 @@
 Tailbone Web API - Product Views
 """
 
-from __future__ import unicode_literals, absolute_import
+import logging
 
-import six
 import sqlalchemy as sa
 from sqlalchemy import orm
 
+from cornice import Service
+
 from rattail.db import model
 
-from tailbone.api import APIMasterView2 as APIMasterView
+from tailbone.api import APIMasterView
+
+
+log = logging.getLogger(__name__)
 
 
 class ProductView(APIMasterView):
@@ -44,20 +48,49 @@ class ProductView(APIMasterView):
     object_url_prefix = '/product'
     supports_autocomplete = True
 
+    def __init__(self, request, context=None):
+        super(ProductView, self).__init__(request, context=context)
+        app = self.get_rattail_app()
+        self.products_handler = app.get_products_handler()
+
     def normalize(self, product):
+
+        # get what we can from handler
+        data = self.products_handler.normalize_product(product, fields=[
+            'brand_name',
+            'full_description',
+            'department_name',
+            'unit_price_display',
+            'sale_price',
+            'sale_price_display',
+            'sale_ends',
+            'sale_ends_display',
+            'tpr_price',
+            'tpr_price_display',
+            'tpr_ends',
+            'tpr_ends_display',
+            'current_price',
+            'current_price_display',
+            'current_ends',
+            'current_ends_display',
+            'vendor_name',
+            'costs',
+            'image_url',
+        ])
+
+        # but must supplement
         cost = product.cost
-        return {
-            'uuid': product.uuid,
-            '_str': six.text_type(product),
-            'upc': six.text_type(product.upc),
+        data.update({
+            'upc': str(product.upc),
             'scancode': product.scancode,
             'item_id': product.item_id,
             'item_type': product.item_type,
-            'description': product.description,
             'status_code': product.status_code,
             'default_unit_cost': cost.unit_cost if cost else None,
             'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None,
-        }
+        })
+
+        return data
 
     def make_autocomplete_query(self, term):
         query = self.Session.query(model.Product)\
@@ -77,6 +110,111 @@ class ProductView(APIMasterView):
     def autocomplete_display(self, product):
         return product.full_description
 
+    def quick_lookup(self):
+        """
+        View for handling "quick lookup" user input, for index page.
+        """
+        data = self.request.GET
+        entry = data['entry']
+
+        product = self.products_handler.locate_product_for_entry(self.Session(),
+                                                                 entry)
+        if not product:
+            return {'error': "Product not found"}
+
+        return {'ok': True,
+                'product': self.normalize(product)}
+
+    def label_profiles(self):
+        """
+        Returns the set of label profiles available for use with
+        printing label for product.
+        """
+        app = self.get_rattail_app()
+        label_handler = app.get_label_handler()
+        model = self.model
+
+        profiles = []
+        for profile in label_handler.get_label_profiles(self.Session()):
+            profiles.append({
+                'uuid': profile.uuid,
+                'description': profile.description,
+            })
+
+        return {'label_profiles': profiles}
+
+    def print_labels(self):
+        app = self.get_rattail_app()
+        label_handler = app.get_label_handler()
+        model = self.model
+        data = self.request.json_body
+
+        uuid = data.get('label_profile_uuid')
+        profile = self.Session.get(model.LabelProfile, uuid) if uuid else None
+        if not profile:
+            return {'error': "Label profile not found"}
+
+        uuid = data.get('product_uuid')
+        product = self.Session.get(model.Product, uuid) if uuid else None
+        if not product:
+            return {'error': "Product not found"}
+
+        try:
+            quantity = int(data.get('quantity'))
+        except:
+            return {'error': "Quantity must be integer"}
+
+        printer = label_handler.get_printer(profile)
+        if not printer:
+            return {'error': "Couldn't get printer from label profile"}
+
+        try:
+            printer.print_labels([({'product': product}, quantity)])
+        except Exception as error:
+            log.warning("error occurred while printing labels", exc_info=True)
+            return {'error': str(error)}
+
+        return {'ok': True}
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._product_defaults(config)
+
+    @classmethod
+    def _product_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        collection_url_prefix = cls.get_collection_url_prefix()
+
+        # quick lookup
+        quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix),
+                               path='{}/quick-lookup'.format(collection_url_prefix))
+        quick_lookup.add_view('GET', 'quick_lookup', klass=cls,
+                              permission='{}.list'.format(permission_prefix))
+        config.add_cornice_service(quick_lookup)
+
+        # label profiles
+        label_profiles = Service(name=f'{route_prefix}.label_profiles',
+                                 path=f'{collection_url_prefix}/label-profiles')
+        label_profiles.add_view('GET', 'label_profiles', klass=cls,
+                                permission=f'{permission_prefix}.print_labels')
+        config.add_cornice_service(label_profiles)
+
+        # print labels
+        print_labels = Service(name='{}.print_labels'.format(route_prefix),
+                               path='{}/print-labels'.format(collection_url_prefix))
+        print_labels.add_view('POST', 'print_labels', klass=cls,
+                              permission='{}.print_labels'.format(permission_prefix))
+        config.add_cornice_service(print_labels)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ProductView = kwargs.get('ProductView', base['ProductView'])
+    ProductView.defaults(config)
+
 
 def includeme(config):
-    ProductView.defaults(config)
+    defaults(config)
diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py
index 85e4a91e..467c8a0d 100644
--- a/tailbone/api/upgrades.py
+++ b/tailbone/api/upgrades.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,9 @@
 Tailbone Web API - Upgrade Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
-from tailbone.api import APIMasterView2 as APIMasterView
+from tailbone.api import APIMasterView
 
 
 class UpgradeView(APIMasterView):
@@ -53,9 +49,16 @@ class UpgradeView(APIMasterView):
             data['status_code'] = None
         else:
             data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
-                                                               six.text_type(upgrade.status_code))
+                                                               str(upgrade.status_code))
         return data
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
     UpgradeView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/users.py b/tailbone/api/users.py
index 8474fd97..a6bcad57 100644
--- a/tailbone/api/users.py
+++ b/tailbone/api/users.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,9 @@
 Tailbone Web API - User Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
-from tailbone.api import APIMasterView2 as APIMasterView
+from tailbone.api import APIMasterView
 
 
 class UserView(APIMasterView):
@@ -59,6 +55,17 @@ class UserView(APIMasterView):
             query = query.outerjoin(model.Person)
         return query
 
+    def update_object(self, user, data):
+        # TODO: should ensure prevent_password_change is respected
+        return super(UserView, self).update_object(user, data)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    UserView = kwargs.get('UserView', base['UserView'])
+    UserView.defaults(config)
+
 
 def includeme(config):
-    UserView.defaults(config)
+    defaults(config)
diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py
index ce885e07..64311b1b 100644
--- a/tailbone/api/vendors.py
+++ b/tailbone/api/vendors.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,9 @@
 Tailbone Web API - Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
-from tailbone.api import APIMasterView2 as APIMasterView
+from tailbone.api import APIMasterView
 
 
 class VendorView(APIMasterView):
@@ -44,11 +40,18 @@ class VendorView(APIMasterView):
     def normalize(self, vendor):
         return {
             'uuid': vendor.uuid,
-            '_str': six.text_type(vendor),
+            '_str': str(vendor),
             'id': vendor.id,
             'name': vendor.name,
         }
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    VendorView = kwargs.get('VendorView', base['VendorView'])
     VendorView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py
new file mode 100644
index 00000000..19def6c4
--- /dev/null
+++ b/tailbone/api/workorders.py
@@ -0,0 +1,234 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Tailbone Web API - Work Order Views
+"""
+
+import datetime
+
+from rattail.db.model import WorkOrder
+
+from cornice import Service
+
+from tailbone.api import APIMasterView
+
+
+class WorkOrderView(APIMasterView):
+
+    model_class = WorkOrder
+    collection_url_prefix = '/workorders'
+    object_url_prefix = '/workorder'
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        app = self.get_rattail_app()
+        self.workorder_handler = app.get_workorder_handler()
+
+    def normalize(self, workorder):
+        data = super().normalize(workorder)
+        data.update({
+            'customer_name': workorder.customer.name,
+            'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
+            'date_submitted': str(workorder.date_submitted or ''),
+            'date_received': str(workorder.date_received or ''),
+            'date_released': str(workorder.date_released or ''),
+            'date_delivered': str(workorder.date_delivered or ''),
+        })
+        return data
+
+    def create_object(self, data):
+
+        # invoke the handler instead of normal API CRUD logic
+        workorder = self.workorder_handler.make_workorder(self.Session(), **data)
+        return workorder
+
+    def update_object(self, workorder, data):
+        date_fields = [
+            'date_submitted',
+            'date_received',
+            'date_released',
+            'date_delivered',
+        ]
+
+        # coerce date field values to proper datetime.date objects
+        for field in date_fields:
+            if field in data:
+                if data[field] == '':
+                    data[field] = None
+                elif not isinstance(data[field], datetime.date):
+                    date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date()
+                    data[field] = date
+
+        # coerce status code value to proper integer
+        if 'status_code' in data:
+            data['status_code'] = int(data['status_code'])
+
+        return super().update_object(workorder, data)
+
+    def status_codes(self):
+        """
+        Retrieve all info about possible work order status codes.
+        """
+        return self.workorder_handler.status_codes()
+
+    def receive(self):
+        """
+        Sets work order status to "received".
+        """
+        workorder = self.get_object()
+        self.workorder_handler.receive(workorder)
+        self.Session.flush()
+        return self.normalize(workorder)
+
+    def await_estimate(self):
+        """
+        Sets work order status to "awaiting estimate confirmation".
+        """
+        workorder = self.get_object()
+        self.workorder_handler.await_estimate(workorder)
+        self.Session.flush()
+        return self.normalize(workorder)
+
+    def await_parts(self):
+        """
+        Sets work order status to "awaiting parts".
+        """
+        workorder = self.get_object()
+        self.workorder_handler.await_parts(workorder)
+        self.Session.flush()
+        return self.normalize(workorder)
+
+    def work_on_it(self):
+        """
+        Sets work order status to "working on it".
+        """
+        workorder = self.get_object()
+        self.workorder_handler.work_on_it(workorder)
+        self.Session.flush()
+        return self.normalize(workorder)
+
+    def release(self):
+        """
+        Sets work order status to "released".
+        """
+        workorder = self.get_object()
+        self.workorder_handler.release(workorder)
+        self.Session.flush()
+        return self.normalize(workorder)
+
+    def deliver(self):
+        """
+        Sets work order status to "delivered".
+        """
+        workorder = self.get_object()
+        self.workorder_handler.deliver(workorder)
+        self.Session.flush()
+        return self.normalize(workorder)
+
+    def cancel(self):
+        """
+        Sets work order status to "canceled".
+        """
+        workorder = self.get_object()
+        self.workorder_handler.cancel(workorder)
+        self.Session.flush()
+        return self.normalize(workorder)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._workorder_defaults(config)
+
+    @classmethod
+    def _workorder_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        collection_url_prefix = cls.get_collection_url_prefix()
+        object_url_prefix = cls.get_object_url_prefix()
+
+        # status codes
+        status_codes = Service(name='{}.status_codes'.format(route_prefix),
+                               path='{}/status-codes'.format(collection_url_prefix))
+        status_codes.add_view('GET', 'status_codes', klass=cls,
+                              permission='{}.list'.format(permission_prefix))
+        config.add_cornice_service(status_codes)
+
+        # receive
+        receive = Service(name='{}.receive'.format(route_prefix),
+                          path='{}/{{uuid}}/receive'.format(object_url_prefix))
+        receive.add_view('POST', 'receive', klass=cls,
+                         permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(receive)
+
+        # await estimate confirmation
+        await_estimate = Service(name='{}.await_estimate'.format(route_prefix),
+                                 path='{}/{{uuid}}/await-estimate'.format(object_url_prefix))
+        await_estimate.add_view('POST', 'await_estimate', klass=cls,
+                                permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(await_estimate)
+
+        # await parts
+        await_parts = Service(name='{}.await_parts'.format(route_prefix),
+                              path='{}/{{uuid}}/await-parts'.format(object_url_prefix))
+        await_parts.add_view('POST', 'await_parts', klass=cls,
+                             permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(await_parts)
+
+        # work on it
+        work_on_it = Service(name='{}.work_on_it'.format(route_prefix),
+                             path='{}/{{uuid}}/work-on-it'.format(object_url_prefix))
+        work_on_it.add_view('POST', 'work_on_it', klass=cls,
+                            permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(work_on_it)
+
+        # release
+        release = Service(name='{}.release'.format(route_prefix),
+                          path='{}/{{uuid}}/release'.format(object_url_prefix))
+        release.add_view('POST', 'release', klass=cls,
+                         permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(release)
+
+        # deliver
+        deliver = Service(name='{}.deliver'.format(route_prefix),
+                          path='{}/{{uuid}}/deliver'.format(object_url_prefix))
+        deliver.add_view('POST', 'deliver', klass=cls,
+                         permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(deliver)
+
+        # cancel
+        cancel = Service(name='{}.cancel'.format(route_prefix),
+                         path='{}/{{uuid}}/cancel'.format(object_url_prefix))
+        cancel.add_view('POST', 'cancel', klass=cls,
+                        permission='{}.edit'.format(permission_prefix))
+        config.add_cornice_service(cancel)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView'])
+    WorkOrderView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/app.py b/tailbone/app.py
index 44d9976f..d2d0c5ef 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,27 +24,23 @@
 Application Entry Point
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
-import warnings
 
-import six
-import sqlalchemy as sa
 from sqlalchemy.orm import sessionmaker, scoped_session
 
-from rattail.config import make_config, parse_list
+from wuttjamaican.util import parse_list
+
+from rattail.config import make_config
 from rattail.exceptions import ConfigurationError
-from rattail.db.types import GPCType
 
 from pyramid.config import Configurator
-from pyramid.authentication import SessionAuthenticationPolicy
 from zope.sqlalchemy import register
 
 import tailbone.db
-from tailbone.auth import TailboneAuthorizationPolicy
-
+from tailbone.auth import TailboneSecurityPolicy
+from tailbone.config import csrf_token_name, csrf_header_name
 from tailbone.util import get_effective_theme, get_theme_template_path
+from tailbone.providers import get_all_providers
 
 
 def make_rattail_config(settings):
@@ -62,16 +58,33 @@ def make_rattail_config(settings):
                                      "to the path of your config file.  Lame, but necessary.")
         rattail_config = make_config(path)
         settings['rattail_config'] = rattail_config
-    rattail_config.configure_logging()
+
+    # nb. this is for compaibility with wuttaweb
+    settings['wutta_config'] = rattail_config
+
+    # must import all sqlalchemy models before things get rolling,
+    # otherwise can have errors about continuum TransactionMeta class
+    # not yet mapped, when relevant pages are first requested...
+    # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
+    # hat tip to https://stackoverflow.com/a/59241485
+    if getattr(rattail_config, 'tempmon_engine', None):
+        from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
+        tempmon_session = TempmonSession()
+        tempmon_session.query(tempmon_model.Appliance).first()
+        tempmon_session.close()
 
     # configure database sessions
-    if hasattr(rattail_config, 'rattail_engine'):
-        tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
+    if hasattr(rattail_config, 'appdb_engine'):
+        tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
     if hasattr(rattail_config, 'trainwreck_engine'):
         tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
     if hasattr(rattail_config, 'tempmon_engine'):
         tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
 
+    # maybe set "future" behavior for SQLAlchemy
+    if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False):
+        tailbone.db.Session.configure(future=True)
+
     # create session wrappers for each "extra" Trainwreck engine
     for key, engine in rattail_config.trainwreck_engines.items():
         if key != 'default':
@@ -115,31 +128,52 @@ def make_pyramid_config(settings, configure_csrf=True):
     """
     Make a Pyramid config object from the given settings.
     """
+    rattail_config = settings['rattail_config']
+
     config = settings.pop('pyramid_config', None)
     if config:
         config.set_root_factory(Root)
     else:
 
+        # declare this web app of the "classic" variety
+        settings.setdefault('tailbone.classic', 'true')
+
         # we want the new themes feature!
         establish_theme(settings)
 
+        settings.setdefault('fanstatic.versioning', 'true')
         settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
         config = Configurator(settings=settings, root_factory=Root)
 
-    # configure user authorization / authentication
-    config.set_authorization_policy(TailboneAuthorizationPolicy())
-    config.set_authentication_policy(SessionAuthenticationPolicy())
+    # add rattail config directly to registry, for access throughout the app
+    config.registry['rattail_config'] = rattail_config
 
-    # always require CSRF token protection
+    # configure user authorization / authentication
+    config.set_security_policy(TailboneSecurityPolicy())
+
+    # maybe require CSRF token protection
     if configure_csrf:
-        config.set_default_csrf_options(require_csrf=True, token='_csrf')
+        config.set_default_csrf_options(require_csrf=True,
+                                        token=csrf_token_name(rattail_config),
+                                        header=csrf_header_name(rattail_config))
 
     # Bring in some Pyramid goodies.
     config.include('tailbone.beaker')
     config.include('pyramid_deform')
+    config.include('pyramid_fanstatic')
     config.include('pyramid_mako')
     config.include('pyramid_tm')
 
+    # TODO: this may be a good idea some day, if wanting to leverage
+    # deform resources for component JS?  cf. also base.mako template
+    # # override default script mapping for deform
+    # from deform import Field
+    # from deform.widget import ResourceRegistry, default_resources
+    # registry = ResourceRegistry(use_defaults=False)
+    # for key in default_resources:
+    #     registry.set_js_resources(key, None, {'js': []})
+    # Field.set_default_resource_registry(registry)
+
     # bring in the pyramid_retry logic, if available
     # TODO: pretty soon we can require this package, hopefully..
     try:
@@ -149,13 +183,127 @@ def make_pyramid_config(settings, configure_csrf=True):
     else:
         config.include('pyramid_retry')
 
-    # Add some permissions magic.
-    config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-    config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
+    # fetch all tailbone providers
+    providers = get_all_providers(rattail_config)
+    for provider in providers.values():
+
+        # configure DB sessions associated with transaction manager
+        provider.configure_db_sessions(rattail_config, config)
+
+        # add any static includes
+        includes = provider.get_static_includes()
+        if includes:
+            for spec in includes:
+                config.include(spec)
+
+    # add some permissions magic
+    config.add_directive('add_wutta_permission_group',
+                         'wuttaweb.auth.add_permission_group')
+    config.add_directive('add_wutta_permission',
+                         'wuttaweb.auth.add_permission')
+    # TODO: deprecate / remove these
+    config.add_directive('add_tailbone_permission_group',
+                         'wuttaweb.auth.add_permission_group')
+    config.add_directive('add_tailbone_permission',
+                         'wuttaweb.auth.add_permission')
+
+    # and some similar magic for certain master views
+    config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
+    config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page')
+    config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view')
+    config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement')
+
+    config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket')
 
     return config
 
 
+def add_websocket(config, name, view, attr=None):
+    """
+    Register a websocket entry point for the app.
+    """
+    def action():
+        rattail_config = config.registry.settings['rattail_config']
+        rattail_app = rattail_config.get_app()
+
+        if isinstance(view, str):
+            view_callable = rattail_app.load_object(view)
+        else:
+            view_callable = view
+        view_callable = view_callable(config)
+        if attr:
+            view_callable = getattr(view_callable, attr)
+
+        # register route
+        path = '/ws/{}'.format(name)
+        route_name = 'ws.{}'.format(name)
+        config.add_route(route_name, path, static=True)
+
+        # register view callable
+        websockets = config.registry.setdefault('tailbone_websockets', {})
+        websockets[path] = view_callable
+
+    config.action('tailbone-add-websocket-{}'.format(name), action,
+                  # nb. since this action adds routes, it must happen
+                  # sooner in the order than it normally would, hence
+                  # we declare that
+                  order=-20)
+
+
+def add_index_page(config, route_name, label, permission):
+    """
+    Register a config page for the app.
+    """
+    def action():
+        pages = config.get_settings().get('tailbone_index_pages', [])
+        pages.append({'label': label, 'route': route_name,
+                      'permission': permission})
+        config.add_settings({'tailbone_index_pages': pages})
+    config.action(None, action)
+
+
+def add_config_page(config, route_name, label, permission):
+    """
+    Register a config page for the app.
+    """
+    def action():
+        pages = config.get_settings().get('tailbone_config_pages', [])
+        pages.append({'label': label, 'route': route_name,
+                      'permission': permission})
+        config.add_settings({'tailbone_config_pages': pages})
+    config.action(None, action)
+
+
+def add_model_view(config, model_name, label, route_prefix, permission_prefix):
+    """
+    Register a model view for the app.
+    """
+    def action():
+        all_views = config.get_settings().get('tailbone_model_views', {})
+
+        model_views = all_views.setdefault(model_name, [])
+        model_views.append({
+            'label': label,
+            'route_prefix': route_prefix,
+            'permission_prefix': permission_prefix,
+        })
+
+        config.add_settings({'tailbone_model_views': all_views})
+
+    config.action(None, action)
+
+
+def add_view_supplement(config, route_prefix, cls):
+    """
+    Register a master view supplement for the app.
+    """
+    def action():
+        supplements = config.get_settings().get('tailbone_view_supplements', {})
+        supplements.setdefault(route_prefix, []).append(cls)
+        config.add_settings({'tailbone_view_supplements': supplements})
+    config.action(None, action)
+
+
 def establish_theme(settings):
     rattail_config = settings['rattail_config']
 
@@ -163,7 +311,7 @@ def establish_theme(settings):
     settings['tailbone.theme'] = theme
 
     directories = settings['mako.directories']
-    if isinstance(directories, six.string_types):
+    if isinstance(directories, str):
         directories = parse_list(directories)
 
     path = get_theme_template_path(rattail_config)
@@ -184,7 +332,8 @@ def main(global_config, **settings):
     """
     This function returns a Pyramid WSGI application.
     """
-    settings.setdefault('mako.directories', ['tailbone:templates'])
+    settings.setdefault('mako.directories', ['tailbone:templates',
+                                             'wuttaweb:templates'])
     rattail_config = make_rattail_config(settings)
     pyramid_config = make_pyramid_config(settings)
     pyramid_config.include('tailbone')
diff --git a/tailbone/asgi.py b/tailbone/asgi.py
new file mode 100644
index 00000000..1afbe12a
--- /dev/null
+++ b/tailbone/asgi.py
@@ -0,0 +1,110 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+ASGI App Utilities
+"""
+
+import os
+import configparser
+import logging
+
+from rattail.util import load_object
+
+from asgiref.wsgi import WsgiToAsgi
+
+
+log = logging.getLogger(__name__)
+
+
+class TailboneWsgiToAsgi(WsgiToAsgi):
+    """
+    Custom WSGI -> ASGI wrapper, to add routing for websockets.
+    """
+
+    async def __call__(self, scope, *args, **kwargs):
+        protocol = scope['type']
+        path = scope['path']
+
+        # strip off the root path, if non-empty.  needed for serving
+        # under /poser or anything other than true site root
+        root_path = scope['root_path']
+        if root_path and path.startswith(root_path):
+            path = path[len(root_path):]
+
+        if protocol == 'websocket':
+            websockets = self.wsgi_application.registry.get(
+                'tailbone_websockets', {})
+            if path in websockets:
+                await websockets[path](scope, *args, **kwargs)
+
+        try:
+            await super().__call__(scope, *args, **kwargs)
+        except ValueError as e:
+            # The developer may wish to improve handling of this exception.
+            # See https://github.com/Pylons/pyramid_cookbook/issues/225 and
+            # https://asgi.readthedocs.io/en/latest/specs/www.html#websocket
+            pass
+        except Exception as e:
+            raise e
+
+
+def make_asgi_app(main_app=None):
+    """
+    This function returns an ASGI application.
+    """
+    path = os.environ.get('TAILBONE_ASGI_CONFIG')
+    if not path:
+        raise RuntimeError("You must define TAILBONE_ASGI_CONFIG env variable.")
+
+    # make a config parser good enough to load pyramid settings
+    configdir = os.path.dirname(path)
+    parser = configparser.ConfigParser(defaults={'__file__': path,
+                                                 'here': configdir})
+
+    # read the config file
+    parser.read(path)
+
+    # parse the settings needed for pyramid app
+    settings = dict(parser.items('app:main'))
+
+    if isinstance(main_app, str):
+        make_wsgi_app = load_object(main_app)
+    elif callable(main_app):
+        make_wsgi_app = main_app
+    else:
+        if main_app:
+            log.warning("specified main app of unknown type: %s", main_app)
+        make_wsgi_app = load_object('tailbone.app:main')
+
+    # construct a pyramid app "per usual"
+    app = make_wsgi_app({}, **settings)
+
+    # then wrap it with ASGI
+    return TailboneWsgiToAsgi(app)
+
+
+def asgi_main():
+    """
+    This function returns an ASGI application.
+    """
+    return make_asgi_app()
diff --git a/tailbone/auth.py b/tailbone/auth.py
index 9db292ad..95bf90ba 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,32 +24,31 @@
 Authentication & Authorization
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import logging
+import re
 
-from rattail import enum
-from rattail.util import prettify, NOTSET
+from wuttjamaican.util import UNSPECIFIED
 
-from zope.interface import implementer
-from pyramid.interfaces import IAuthorizationPolicy
-from pyramid.security import remember, forget, Everyone, Authenticated
+from pyramid.security import remember, forget
 
+from wuttaweb.auth import WuttaSecurityPolicy
 from tailbone.db import Session
 
 
 log = logging.getLogger(__name__)
 
 
-def login_user(request, user, timeout=NOTSET):
+def login_user(request, user, timeout=UNSPECIFIED):
     """
     Perform the steps necessary to login the given user.  Note that this
     returns a ``headers`` dict which you should pass to the redirect.
     """
-    user.record_event(enum.USER_EVENT_LOGIN)
+    config = request.rattail_config
+    app = config.get_app()
+    user.record_event(app.enum.USER_EVENT_LOGIN)
     headers = remember(request, user.uuid)
-    if timeout is NOTSET:
-        timeout = session_timeout_for_user(user)
+    if timeout is UNSPECIFIED:
+        timeout = session_timeout_for_user(config, user)
     log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
     set_session_timeout(request, timeout)
     return headers
@@ -60,24 +59,28 @@ def logout_user(request):
     Perform the logout action for the given request.  Note that this returns a
     ``headers`` dict which you should pass to the redirect.
     """
+    app = request.rattail_config.get_app()
     user = request.user
     if user:
-        user.record_event(enum.USER_EVENT_LOGOUT)
+        user.record_event(app.enum.USER_EVENT_LOGOUT)
     request.session.delete()
     request.session.invalidate()
     headers = forget(request)
     return headers
 
 
-def session_timeout_for_user(user):
+def session_timeout_for_user(config, user):
     """
     Returns the "max" session timeout for the user, according to roles
     """
-    from rattail.db.auth import authenticated_role
+    app = config.get_app()
+    auth = app.get_auth_handler()
 
-    roles = user.roles + [authenticated_role(Session())]
+    authenticated = auth.get_role_authenticated(Session())
+    roles = user.roles + [authenticated]
     timeouts = [role.session_timeout for role in roles
                 if role.session_timeout is not None]
+
     if timeouts and 0 not in timeouts:
         return max(timeouts)
 
@@ -89,53 +92,42 @@ def set_session_timeout(request, timeout):
     request.session['_timeout'] = timeout or None
 
 
-@implementer(IAuthorizationPolicy)
-class TailboneAuthorizationPolicy(object):
+class TailboneSecurityPolicy(WuttaSecurityPolicy):
 
-    def permits(self, context, principals, permission):
-        from rattail.db import model
-        from rattail.db.auth import has_permission
+    def __init__(self, db_session=None, api_mode=False, **kwargs):
+        kwargs['db_session'] = db_session or Session()
+        super().__init__(**kwargs)
+        self.api_mode = api_mode
 
-        for userid in principals:
-            if userid not in (Everyone, Authenticated):
-                if context.request.user and context.request.user.uuid == userid:
-                    return context.request.has_perm(permission)
-                else:
-                    assert False # should no longer happen..right?
-                    user = Session.query(model.User).get(userid)
-                    if user:
-                        if has_permission(Session(), user, permission):
-                            return True
-        if Everyone in principals:
-            return has_permission(Session(), None, permission)
-        return False
+    def load_identity(self, request):
+        config = request.registry.settings.get('rattail_config')
+        app = config.get_app()
+        user = None
 
-    def principals_allowed_by_permission(self, context, permission):
-        raise NotImplementedError
+        if self.api_mode:
 
+            # determine/load user from header token if present
+            credentials = request.headers.get('Authorization')
+            if credentials:
+                match = re.match(r'^Bearer (\S+)$', credentials)
+                if match:
+                    token = match.group(1)
+                    auth = app.get_auth_handler()
+                    user = auth.authenticate_user_token(self.db_session, token)
 
-def add_permission_group(config, key, label=None, overwrite=True):
-    """
-    Add a permission group to the app configuration.
-    """
-    def action():
-        perms = config.get_settings().get('tailbone_permissions', {})
-        if key not in perms or overwrite:
-            group = perms.setdefault(key, {'key': key})
-            group['label'] = label or prettify(key)
-        config.add_settings({'tailbone_permissions': perms})
-    config.action(None, action)
+        if not user:
 
+            # fetch user uuid from current session
+            uuid = self.session_helper.authenticated_userid(request)
+            if not uuid:
+                return
 
-def add_permission(config, groupkey, key, label=None):
-    """
-    Add a permission to the app configuration.
-    """
-    def action():
-        perms = config.get_settings().get('tailbone_permissions', {})
-        group = perms.setdefault(groupkey, {'key': groupkey})
-        group.setdefault('label', prettify(groupkey))
-        perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
-        perm['label'] = label or prettify(key)
-        config.add_settings({'tailbone_permissions': perms})
-    config.action(None, action)
+            # fetch user object from db
+            model = app.model
+            user = self.db_session.get(model.User, uuid)
+            if not user:
+                return
+
+        # this user is responsible for data changes in current request
+        self.db_session.set_continuum_user(user)
+        return user
diff --git a/tailbone/beaker.py b/tailbone/beaker.py
index 1f7f20c5..25a450df 100644
--- a/tailbone/beaker.py
+++ b/tailbone/beaker.py
@@ -1,8 +1,8 @@
-# -*- coding: utf-8 -*-
+# -*- coding: utf-8; -*-
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,10 +27,12 @@ Note that most of the code for this module was copied from the beaker and
 pyramid_beaker projects.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import time
+from pkg_resources import parse_version
 
+from rattail.util import get_pkg_version
+
+import beaker
 from beaker.session import Session
 from beaker.util import coerce_session_params
 from pyramid.settings import asbool
@@ -45,6 +47,10 @@ class TailboneSession(Session):
 
     def load(self):
         "Loads the data from this session from persistent storage"
+
+        # are we using older version of beaker?
+        old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12')
+
         self.namespace = self.namespace_class(self.id,
             data_dir=self.data_dir,
             digest_filenames=False,
@@ -60,8 +66,12 @@ class TailboneSession(Session):
             try:
                 session_data = self.namespace['session']
 
-                if (session_data is not None and self.encrypt_key):
-                    session_data = self._decrypt_data(session_data)
+                if old_beaker:
+                    if (session_data is not None and self.encrypt_key):
+                        session_data = self._decrypt_data(session_data)
+                else: # beaker >= 1.12
+                    if session_data is not None:
+                        session_data = self._decrypt_data(session_data)
 
                 # Memcached always returns a key, its None when its not
                 # present
@@ -90,6 +100,7 @@ class TailboneSession(Session):
             # for this module entirely...
             timeout = session_data.get('_timeout', self.timeout)
             if timeout is not None and \
+               '_accessed_time' in session_data and \
                now - session_data['_accessed_time'] > timeout:
                 timed_out = True
             else:
@@ -103,9 +114,6 @@ class TailboneSession(Session):
                 # Update the current _accessed_time
                 session_data['_accessed_time'] = now
 
-                # Set the path if applicable
-                if '_path' in session_data:
-                    self._path = session_data['_path']
                 self.update(session_data)
                 self.accessed_dict = session_data.copy()
         finally:
diff --git a/tailbone/cleanup.py b/tailbone/cleanup.py
new file mode 100644
index 00000000..0ed5d026
--- /dev/null
+++ b/tailbone/cleanup.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Cleanup logic
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+import os
+import logging
+import time
+
+from rattail.cleanup import Cleaner
+
+
+log = logging.getLogger(__name__)
+
+
+class BeakerCleaner(Cleaner):
+    """
+    Cleanup logic for old Beaker session files.
+    """
+
+    def get_session_dir(self):
+        session_dir = self.config.get('rattail.cleanup', 'beaker.session_dir')
+        if session_dir and os.path.isdir(session_dir):
+            return session_dir
+
+        session_dir = os.path.join(self.config.appdir(), 'sessions')
+        if os.path.isdir(session_dir):
+            return session_dir
+
+    def cleanup(self, session, dry_run=False, progress=None, **kwargs):
+        session_dir = self.get_session_dir()
+        if not session_dir:
+            return
+
+        data_dir = os.path.join(session_dir, 'data')
+        lock_dir = os.path.join(session_dir, 'lock')
+
+        # looking for files older than X days
+        days = self.config.getint('rattail.cleanup',
+                                  'beaker.session_cutoff_days',
+                                  default=30)
+        cutoff = time.time() - 3600 * 24 * days
+
+        for topdir in (data_dir, lock_dir):
+            if not os.path.isdir(topdir):
+                continue
+
+            for dirpath, dirnames, filenames in os.walk(topdir):
+                for fname in filenames:
+                    path = os.path.join(dirpath, fname)
+                    ts = os.path.getmtime(path)
+                    if ts <= cutoff:
+                        if dry_run:
+                            log.debug("would delete file: %s", path)
+                        else:
+                            os.remove(path)
+                            log.debug("deleted file: %s", path)
diff --git a/tailbone/config.py b/tailbone/config.py
index 29359e06..8392ba0a 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,16 @@
 Rattail config extension for Tailbone
 """
 
-from __future__ import unicode_literals, absolute_import
+import warnings
+
+from wuttjamaican.conf import WuttaConfigExtension
 
-from rattail.config import ConfigExtension as BaseExtension
 from rattail.db.config import configure_session
 
 from tailbone.db import Session
 
 
-class ConfigExtension(BaseExtension):
+class ConfigExtension(WuttaConfigExtension):
     """
     Rattail config extension for Tailbone.  Does the following:
 
@@ -49,19 +50,29 @@ class ConfigExtension(BaseExtension):
         configure_session(config, Session)
 
         # provide default theme selection
-        config.setdefault('tailbone', 'themes', 'default, falafel')
+        config.setdefault('tailbone', 'themes.keys', 'default, butterball')
         config.setdefault('tailbone', 'themes.expose_picker', 'true')
 
+        # override oruga detection
+        config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
+
+
+def csrf_token_name(config):
+    return config.get('tailbone', 'csrf_token_name', default='_csrf')
+
+
+def csrf_header_name(config):
+    return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN')
+
 
 def global_help_url(config):
     return config.get('tailbone', 'global_help_url')
 
 
-def legacy_mobile_enabled(config):
-    return config.getbool('tailbone', 'legacy_mobile.enabled',
-                          default=True)
-
-
 def protected_usernames(config):
     return config.getlist('tailbone', 'protected_usernames')
 
+
+def should_expose_websockets(config):
+    return config.getbool('tailbone', 'expose_websockets',
+                          usedb=False, default=False)
diff --git a/tailbone/db.py b/tailbone/db.py
index 1cbf61ec..8b37f399 100644
--- a/tailbone/db.py
+++ b/tailbone/db.py
@@ -1,8 +1,8 @@
-# -*- coding: utf-8 -*-
+# -*- coding: utf-8; -*-
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -21,11 +21,9 @@
 #
 ################################################################################
 """
-Database Stuff
+Database sessions etc.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import sqlalchemy as sa
 from zope.sqlalchemy import datamanager
 import sqlalchemy_continuum as continuum
@@ -35,7 +33,7 @@ from rattail.db import SessionBase
 from rattail.db.continuum import versioning_manager
 
 
-Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False, expire_on_commit=False))
+Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, expire_on_commit=False))
 
 # not necessarily used, but here if you need it
 TempmonSession = scoped_session(sessionmaker())
@@ -46,16 +44,26 @@ ExtraTrainwreckSessions = {}
 
 
 class TailboneSessionDataManager(datamanager.SessionDataManager):
-    """Integrate a top level sqlalchemy session transaction into a zope transaction
+    """
+    Integrate a top level sqlalchemy session transaction into a zope
+    transaction
 
     One phase variant.
 
     .. note::
-       This class appears to be necessary in order for the Continuum
-       integration to work alongside the Zope transaction integration.
+
+       This class appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It subclasses
+       ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
+       some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
+       is sort of monkey-patched into the mix.
     """
 
     def tpc_vote(self, trans):
+        """ """
         # for a one phase data manager commit last in tpc_vote
         if self.tx is not None:  # there may have been no work to do
 
@@ -67,25 +75,42 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
             self._finish('committed')
 
 
-def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
-    """Join a session to a transaction using the appropriate datamanager.
+def join_transaction(
+        session,
+        initial_state=datamanager.STATUS_ACTIVE,
+        transaction_manager=datamanager.zope_transaction.manager,
+        keep_session=False,
+):
+    """
+    Join a session to a transaction using the appropriate datamanager.
 
-    It is safe to call this multiple times, if the session is already joined
-    then it just returns.
+    It is safe to call this multiple times, if the session is already
+    joined then it just returns.
 
-    `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
+    `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
+    STATUS_READONLY
 
-    If using the default initial status of STATUS_ACTIVE, you must ensure that
-    mark_changed(session) is called when data is written to the database.
+    If using the default initial status of STATUS_ACTIVE, you must
+    ensure that mark_changed(session) is called when data is written
+    to the database.
 
-    The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
-    called automatically after session write operations.
+    The ZopeTransactionExtesion SessionExtension can be used to ensure
+    that this is called automatically after session write operations.
 
     .. note::
-       This function is copied from upstream, and tweaked so that our custom
-       :class:`TailboneSessionDataManager` will be used.
+
+       This function appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
+       to ensure the custom :class:`TailboneSessionDataManager` is
+       used, and is sort of monkey-patched into the mix.
     """
-    if datamanager._SESSION_STATE.get(id(session), None) is None:
+    # the upstream internals of this function has changed a little over time.
+    # unfortunately for us, that means we must include each variant here.
+
+    if datamanager._SESSION_STATE.get(session, None) is None:
         if session.twophase:
             DataManager = datamanager.TwoPhaseSessionDataManager
         else:
@@ -93,44 +118,74 @@ def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transacti
         DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
 
 
-class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
-    """Record that a flush has occurred on a session's connection. This allows
-    the DataManager to rollback rather than commit on read only transactions.
+class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
+    """
+    Record that a flush has occurred on a session's connection. This
+    allows the DataManager to rollback rather than commit on read only
+    transactions.
 
     .. note::
-       This class is copied from upstream, and tweaked so that our custom
-       :func:`join_transaction()` will be used.
+
+       This class appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It subclasses
+       ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
+       overrides various methods to ensure the custom
+       :func:`join_transaction()` is called, and is sort of
+       monkey-patched into the mix.
     """
 
     def after_begin(self, session, transaction, connection):
-        join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
 
     def after_attach(self, session, instance):
-        join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
+
+    def join_transaction(self, session):
+        """ """
+        join_transaction(session, self.initial_state,
+                         self.transaction_manager, self.keep_session)
 
 
-def register(session, initial_state=datamanager.STATUS_ACTIVE,
-             transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
-    """Register ZopeTransaction listener events on the
-    given Session or Session factory/class.
+def register(
+        session,
+        initial_state=datamanager.STATUS_ACTIVE,
+        transaction_manager=datamanager.zope_transaction.manager,
+        keep_session=False,
+):
+    """
+    Register ZopeTransaction listener events on the given Session or
+    Session factory/class.
 
-    This function requires at least SQLAlchemy 0.7 and makes use
-    of the newer sqlalchemy.event package in order to register event listeners
-    on the given Session.
+    This function requires at least SQLAlchemy 0.7 and makes use of
+    the newer sqlalchemy.event package in order to register event
+    listeners on the given Session.
 
     The session argument here may be a Session class or subclass, a
-    sessionmaker or scoped_session instance, or a specific Session instance.
-    Event listening will be specific to the scope of the type of argument
-    passed, including specificity to its subclass as well as its identity.
+    sessionmaker or scoped_session instance, or a specific Session
+    instance.  Event listening will be specific to the scope of the
+    type of argument passed, including specificity to its subclass as
+    well as its identity.
 
     .. note::
-       This function is copied from upstream, and tweaked so that our custom
-       :class:`ZopeTransactionExtension` will be used.
+
+       This function appears to be necessary in order for the
+       SQLAlchemy-Continuum integration to work alongside the Zope
+       transaction integration.
+
+       It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
+       ensure the custom :class:`ZopeTransactionEvents` is used.
     """
     from sqlalchemy import event
 
-    ext = ZopeTransactionExtension(
-        initial_state=initial_state,         
+    ext = ZopeTransactionEvents(
+        initial_state=initial_state,
         transaction_manager=transaction_manager,
         keep_session=keep_session,
     )
@@ -142,6 +197,9 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE,
     event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
     event.listen(session, "before_commit", ext.before_commit)
 
+    if datamanager.SA_GE_14:
+        event.listen(session, "do_orm_execute", ext.do_orm_execute)
+
 
 register(Session)
 register(TempmonSession)
diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index d4031b1f..2e582b15 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,7 +24,8 @@
 Tools for displaying data diffs
 """
 
-from __future__ import unicode_literals, absolute_import
+import sqlalchemy as sa
+import sqlalchemy_continuum as continuum
 
 from pyramid.renderers import render
 from webhelpers2.html import HTML
@@ -33,16 +34,41 @@ from webhelpers2.html import HTML
 class Diff(object):
     """
     Core diff class.  In sore need of documentation.
+
+    You must provide the old and new data sets, and the set of
+    relevant fields as well, if they cannot be easily introspected.
+
+    :param old_data: Dict of "old" data values.
+
+    :param new_data: Dict of "old" data values.
+
+    :param fields: Sequence of relevant field names.  Note that
+       both data dicts are expected to have keys which match these
+       field names.  If you do not specify the fields then they
+       will (hopefully) be introspected from the old or new data
+       sets; however this will not work if they are both empty.
+
+    :param monospace: If true, this flag will cause the value
+       columns to be rendered in monospace font.  This is assumed
+       to be helpful when comparing "raw" data values which are
+       shown as e.g. ``repr(val)``.
+
+    :param enums: Optional dict of enums for use when displaying field
+       values.  If specified, keys should be field names and values
+       should be enum dicts.
     """
 
-    def __init__(self, old_data, new_data, columns=None, fields=None, render_field=None, render_value=None, monospace=False,
-                 extra_row_attrs=None):
+    def __init__(self, old_data, new_data, columns=None, fields=None, enums=None,
+                 render_field=None, render_value=None, nature='dirty',
+                 monospace=False, extra_row_attrs=None):
         self.old_data = old_data
         self.new_data = new_data
         self.columns = columns or ["field name", "old value", "new value"]
         self.fields = fields or self.make_fields()
+        self.enums = enums or {}
         self._render_field = render_field or self.render_field_default
         self.render_value = render_value or self.render_value_default
+        self.nature = nature
         self.monospace = monospace
         self.extra_row_attrs = extra_row_attrs
 
@@ -69,7 +95,7 @@ class Diff(object):
         for the given field.  May be an empty string, or a snippet of HTML
         attribute syntax, e.g.:
 
-        .. code-highlight:: none
+        .. code-block:: none
 
            class="diff" foo="bar"
 
@@ -105,3 +131,161 @@ class Diff(object):
     def render_new_value(self, field):
         value = self.new_value(field)
         return self.render_value(field, value)
+
+
+class VersionDiff(Diff):
+    """
+    Special diff class, for use with version history views.  Note that
+    while based on :class:`Diff`, this class uses a different
+    signature for the constructor.
+
+    :param version: Reference to a Continuum version record (object).
+
+    :param \*args: Typical usage will not require positional args
+       beyond the ``version`` param, in which case ``old_data`` and
+       ``new_data`` params will be auto-determined based on the
+       ``version``.  But if you specify positional args then nothing
+       automatic is done, they are passed as-is to the parent
+       :class:`Diff` constructor.
+
+    :param \*\*kwargs: Remaining kwargs are passed as-is to the
+       :class:`Diff` constructor.
+    """
+
+    def __init__(self, version, *args, **kwargs):
+        self.version = version
+        self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
+        self.version_mapper = sa.inspect(type(self.version))
+        self.title = kwargs.pop('title', None)
+
+        if 'nature' not in kwargs:
+            if version.previous and version.operation_type == continuum.Operation.DELETE:
+                kwargs['nature'] = 'deleted'
+            elif version.previous:
+                kwargs['nature'] = 'dirty'
+            else:
+                kwargs['nature'] = 'new'
+
+        if 'fields' not in kwargs:
+            kwargs['fields'] = self.get_default_fields()
+
+        if not args:
+            old_data = {}
+            new_data = {}
+            for field in kwargs['fields']:
+                if version.previous:
+                    old_data[field] = getattr(version.previous, field)
+                new_data[field] = getattr(version, field)
+            args = (old_data, new_data)
+
+        super().__init__(*args, **kwargs)
+
+    def get_default_fields(self):
+        fields = sorted(self.version_mapper.columns.keys())
+
+        unwanted = [
+            'transaction_id',
+            'end_transaction_id',
+            'operation_type',
+        ]
+
+        return [field for field in fields
+                if field not in unwanted]
+
+    def render_version_value(self, field, value, version):
+        """
+        Render the cell value text for the given version/field info.
+
+        Note that this method is used to render both sides of the diff
+        (before and after values).
+
+        :param field: Name of the field, as string.
+
+        :param value: Raw value for the field, as obtained from ``version``.
+
+        :param version: Reference to the Continuum version object.
+
+        :returns: Rendered text as string, or ``None``.
+        """
+        text = HTML.tag('span', c=[repr(value)],
+                        style='font-family: monospace;')
+
+        # assume the enum display is all we need, if enum exists for the field
+        if field in self.enums:
+
+            # but skip the enum display if None
+            display = self.enums[field].get(value)
+            if display is None and value is None:
+                return text
+
+            # otherwise show enum display to the right of raw value
+            display = self.enums[field].get(value, str(value))
+            return HTML.tag('span', c=[
+                text,
+                HTML.tag('span', c=[display],
+                         style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
+            ])
+
+        # next we look for a relationship and may render the foreign object
+        for prop in self.mapper.relationships:
+            if prop.uselist:
+                continue
+
+            for col in prop.local_columns:
+                if col.name != field:
+                    continue
+
+                if not hasattr(version, prop.key):
+                    continue
+
+                if col in self.mapper.primary_key:
+                    continue
+
+                ref = getattr(version, prop.key)
+                if ref:
+                    ref = getattr(ref, 'version_parent', None)
+                    if ref:
+                        return HTML.tag('span', c=[
+                            text,
+                            HTML.tag('span', c=[str(ref)],
+                                     style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
+                        ])
+
+        return text
+
+    def render_old_value(self, field):
+        if self.nature == 'new':
+            return ''
+        value = self.old_value(field)
+        return self.render_version_value(field, value, self.version.previous)
+
+    def render_new_value(self, field):
+        if self.nature == 'deleted':
+            return ''
+        value = self.new_value(field)
+        return self.render_version_value(field, value, self.version)
+
+    def as_struct(self):
+        values = {}
+        for field in self.fields:
+            values[field] = {'before': self.render_old_value(field),
+                             'after': self.render_new_value(field)}
+
+        operation = None
+        if self.version.operation_type == continuum.Operation.INSERT:
+            operation = 'INSERT'
+        elif self.version.operation_type == continuum.Operation.UPDATE:
+            operation = 'UPDATE'
+        elif self.version.operation_type == continuum.Operation.DELETE:
+            operation = 'DELETE'
+        else:
+            operation = self.version.operation_type
+
+        return {
+            'key': id(self.version),
+            'model_title': self.title,
+            'operation': operation,
+            'diff_class': self.nature,
+            'fields': self.fields,
+            'values': values,
+        }
diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py
index beea1366..3468562a 100644
--- a/tailbone/exceptions.py
+++ b/tailbone/exceptions.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Exceptions
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.exceptions import RattailError
 
 
@@ -37,7 +33,6 @@ class TailboneError(RattailError):
     """
 
 
-@six.python_2_unicode_compatible
 class TailboneJSONFieldError(TailboneError):
     """
     Error raised when JSON serialization of a form field results in an error.
diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py
index a368f2d1..34b34a6c 100644
--- a/tailbone/forms/__init__.py
+++ b/tailbone/forms/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,7 @@
 Forms Library
 """
 
-from __future__ import unicode_literals, absolute_import
-
-from . import types
+# nb. import widgets before types, b/c types may refer to widgets
 from . import widgets
+from . import types
 from .core import Form, SimpleFileImport
diff --git a/tailbone/forms/common.py b/tailbone/forms/common.py
index 9cc145dd..6183d17f 100644
--- a/tailbone/forms/common.py
+++ b/tailbone/forms/common.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Common Forms
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 import colander
@@ -35,7 +33,7 @@ import colander
 def validate_user(node, kw):
     session = kw['session']
     def validate(node, value):
-        user = session.query(model.User).get(value)
+        user = session.get(model.User, value)
         if not user:
             raise colander.Invalid(node, "User not found")
         return user.uuid
@@ -46,6 +44,9 @@ class Feedback(colander.Schema):
     """
     Form schema for user feedback.
     """
+    email_key = colander.SchemaNode(colander.String(),
+                                    missing=colander.null)
+
     referrer = colander.SchemaNode(colander.String())
 
     user = colander.SchemaNode(colander.String(),
@@ -55,4 +56,7 @@ class Feedback(colander.Schema):
     user_name = colander.SchemaNode(colander.String(),
                                     missing=colander.null)
 
+    please_reply_to = colander.SchemaNode(colander.String(),
+                                          missing=colander.null)
+
     message = colander.SchemaNode(colander.String())
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index cf7dd49e..4024557b 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,20 +24,19 @@
 Forms Core
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import hashlib
 import json
-import datetime
 import logging
+import warnings
+from collections import OrderedDict
 
-import six
 import sqlalchemy as sa
 from sqlalchemy import orm
 from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
+from wuttjamaican.util import UNSPECIFIED
 
-from rattail.time import localtime
-from rattail.util import prettify, pretty_boolean, pretty_hours, pretty_quantity
-from rattail.core import UNSPECIFIED
+from rattail.util import pretty_boolean
+from rattail.db.util import get_fieldnames
 
 import colander
 import deform
@@ -48,9 +47,14 @@ from pyramid_deform import SessionFileUploadTempStore
 from pyramid.renderers import render
 from webhelpers2.html import tags, HTML
 
-from tailbone.util import raw_datetime
-from . import types
-from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget
+from wuttaweb.util import FieldList, get_form_data, make_json_safe
+
+from tailbone.db import Session
+from tailbone.util import raw_datetime, render_markdown
+from tailbone.forms import types
+from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
+                                    JQueryDateWidget, JQueryTimeWidget,
+                                    FileUploadWidget, MultiFileUploadWidget)
 from tailbone.exceptions import TailboneJSONFieldError
 
 
@@ -112,22 +116,14 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
         for the given association proxy field name.  Typically this will refer
         to the "extension" model class.
         """
-        proxy = self.association_proxy(field)
-        if proxy:
-            proxy_target = self.inspector.get_property(proxy.target_collection)
-            if isinstance(proxy_target, orm.RelationshipProperty) and not proxy_target.uselist:
-                return proxy_target
+        return get_association_proxy_target(self.inspector, field)
 
     def association_proxy_column(self, field):
         """
         Returns the property on the proxy target class, for the column which is
         reflected by the proxy.
         """
-        proxy_target = self.association_proxy_target(field)
-        if proxy_target:
-            prop = proxy_target.mapper.get_property(field)
-            if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column):
-                return prop
+        return get_association_proxy_column(self.inspector, field)
 
     def supported_association_proxy(self, field):
         """
@@ -230,7 +226,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
         if excludes:
             overrides['excludes'] = excludes
 
-        return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides)
+        return super().get_schema_from_relationship(prop, overrides)
 
     def dictify(self, obj):
         """ Return a dictified version of `obj` using schema information.
@@ -239,7 +235,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
            This method was copied from upstream and modified to add automatic
            handling of "association proxy" fields.
         """
-        dict_ = super(CustomSchemaNode, self).dictify(obj)
+        dict_ = super().dictify(obj)
         for node in self:
 
             name = node.name
@@ -332,26 +328,32 @@ class Form(object):
     """
     Base class for all forms.
     """
-    save_label = "Save"
+    save_label = "Submit"
     update_label = "Save"
     show_cancel = True
     auto_disable = True
     auto_disable_save = True
     auto_disable_cancel = True
 
-    def __init__(self, fields=None, schema=None, request=None, mobile=False, readonly=False, readonly_fields=[],
-                 model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, renderers=None,
+    def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[],
+                 model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
+                 assume_local_times=False, renderers=None, renderer_kwargs={},
                  hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
-                 action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form'):
-
+                 action_url=None, cancel_url=None,
+                 vue_tagname=None,
+                 vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
+                 # TODO: ugh this is getting out hand!
+                 can_edit_help=False, edit_help_url=None, route_prefix=None,
+                 **kwargs
+    ):
         self.fields = None
         if fields is not None:
             self.set_fields(fields)
         self.schema = schema
         if self.fields is None and self.schema:
             self.set_fields([f.name for f in self.schema])
+        self.grouping = None
         self.request = request
-        self.mobile = mobile
         self.readonly = readonly
         self.readonly_fields = set(readonly_fields or [])
         self.model_instance = model_instance
@@ -364,26 +366,94 @@ class Form(object):
         self.nodes = nodes or {}
         self.enums = enums or {}
         self.labels = labels or {}
+        self.assume_local_times = assume_local_times
         if renderers is None and self.model_class:
             self.renderers = self.make_renderers()
         else:
             self.renderers = renderers or {}
+        self.renderer_kwargs = renderer_kwargs or {}
         self.hidden = hidden or {}
         self.widgets = widgets or {}
         self.defaults = defaults or {}
         self.validators = validators or {}
         self.required = required or {}
         self.helptext = helptext or {}
+        self.dynamic_helptext = {}
         self.focus_spec = focus_spec
         self.action_url = action_url
         self.cancel_url = cancel_url
-        self.use_buefy = use_buefy
-        self.component = component
+
+        # vue_tagname
+        self.vue_tagname = vue_tagname
+        if not self.vue_tagname and kwargs.get('component'):
+            warnings.warn("component kwarg is deprecated for Form(); "
+                          "please use vue_tagname param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.vue_tagname = kwargs['component']
+        if not self.vue_tagname:
+            self.vue_tagname = 'tailbone-form'
+
+        self.vuejs_component_kwargs = vuejs_component_kwargs or {}
+        self.vuejs_field_converters = vuejs_field_converters or {}
+        self.json_data = json_data or {}
+        self.included_templates = included_templates or {}
+        self.can_edit_help = can_edit_help
+        self.edit_help_url = edit_help_url
+        self.route_prefix = route_prefix
+
+        self.button_icon_submit = kwargs.get('button_icon_submit', 'save')
+
+    def __iter__(self):
+        return iter(self.fields)
+
+    @property
+    def vue_component(self):
+        """
+        String name for the Vue component, e.g. ``'TailboneGrid'``.
+
+        This is a generated value based on :attr:`vue_tagname`.
+        """
+        words = self.vue_tagname.split('-')
+        return ''.join([word.capitalize() for word in words])
+
+    @property
+    def component(self):
+        """
+        DEPRECATED - use :attr:`vue_tagname` instead.
+        """
+        warnings.warn("Form.component is deprecated; "
+                      "please use vue_tagname instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_tagname
 
     @property
     def component_studly(self):
-        words = self.component.split('-')
-        return ''.join([word.capitalize() for word in words])
+        """
+        DEPRECATED - use :attr:`vue_component` instead.
+        """
+        warnings.warn("Form.component_studly is deprecated; "
+                      "please use vue_component instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_component
+
+    def get_button_label_submit(self):
+        """ """
+        if hasattr(self, '_button_label_submit'):
+            return self._button_label_submit
+
+        label = getattr(self, 'submit_label', None)
+        if label:
+            return label
+
+        return self.save_label
+
+    def set_button_label_submit(self, value):
+        """ """
+        self._button_label_submit = value
+
+    # wutta compat
+    button_label_submit = property(get_button_label_submit,
+                                   set_button_label_submit)
 
     def __contains__(self, item):
         return item in self.fields
@@ -398,19 +468,11 @@ class Form(object):
         if not self.model_class:
             raise ValueError("Must define model_class to use make_fields()")
 
-        mapper = orm.class_mapper(self.model_class)
+        return get_fieldnames(self.request.rattail_config, self.model_class,
+                              columns=True, proxies=True, relations=True)
 
-        # first add primary column fields
-        fields = FieldList([prop.key for prop in mapper.iterate_properties
-                            if not prop.key.startswith('_')
-                            and prop.key != 'versions'])
-
-        # then add association proxy fields
-        for key, desc in sa.inspect(self.model_class).all_orm_descriptors.items():
-            if desc.extension_type == ASSOCIATION_PROXY:
-                fields.append(key)
-
-        return fields
+    def set_grouping(self, items):
+        self.grouping = OrderedDict(items)
 
     def make_renderers(self):
         """
@@ -430,7 +492,10 @@ class Form(object):
                 if len(prop.columns) == 1:
                     column = prop.columns[0]
                     if isinstance(column.type, sa.DateTime):
-                        renderers[prop.key] = self.render_datetime
+                        if self.assume_local_times:
+                            renderers[prop.key] = self.render_datetime_local
+                        else:
+                            renderers[prop.key] = self.render_datetime
                     elif isinstance(column.type, sa.Boolean):
                         renderers[prop.key] = self.render_boolean
 
@@ -450,6 +515,9 @@ class Form(object):
     def append(self, field):
         self.fields.append(field)
 
+    def insert(self, index, field):
+        self.fields.insert(index, field)
+
     def insert_before(self, field, newfield):
         self.fields.insert_before(field, newfield)
 
@@ -536,7 +604,10 @@ class Form(object):
 
             # apply any validators
             for key, validator in self.validators.items():
-                if key in schema:
+                if key is None:
+                    # this one is form-wide
+                    schema.validator = validator
+                elif key in schema:
                     schema[key].validator = validator
 
             # apply required flags
@@ -559,7 +630,9 @@ class Form(object):
             self.schema[key].title = label
 
     def get_label(self, key):
-        return self.labels.get(key, prettify(key))
+        config = self.request.rattail_config
+        app = config.get_app()
+        return self.labels.get(key, app.make_title(key))
 
     def set_readonly(self, key, readonly=True):
         if readonly:
@@ -576,9 +649,23 @@ class Form(object):
             node = colander.SchemaNode(nodeinfo, **kwargs)
         self.nodes[key] = node
 
+        # must explicitly replace node, if we already have a schema
+        if self.schema:
+            self.schema[key] = node
+
     def set_type(self, key, type_, **kwargs):
+
         if type_ == 'datetime':
             self.set_renderer(key, self.render_datetime)
+
+        elif type_ == 'datetime_falafel':
+            self.set_renderer(key, self.render_datetime)
+            self.set_node(key, types.FalafelDateTime(request=self.request))
+            if kwargs.get('helptext'):
+                app = self.request.rattail_config.get_app()
+                timezone = app.get_timezone()
+                self.set_helptext(key, f"NOTE: all times are local to {timezone}")
+
         elif type_ == 'datetime_local':
             self.set_renderer(key, self.render_datetime_local)
         elif type_ == 'date_plain':
@@ -587,9 +674,14 @@ class Form(object):
             # TODO: is this safe / a good idea?
             # self.set_node(key, colander.Date())
             self.set_widget(key, JQueryDateWidget())
+
         elif type_ == 'time_jquery':
             self.set_node(key, types.JQueryTime())
             self.set_widget(key, JQueryTimeWidget())
+
+        elif type_ == 'time_falafel':
+            self.set_node(key, types.FalafelTime(request=self.request))
+
         elif type_ == 'duration':
             self.set_renderer(key, self.render_duration)
         elif type_ == 'boolean':
@@ -611,16 +703,45 @@ class Form(object):
         elif type_ == 'text':
             self.set_renderer(key, self.render_pre_sans_serif)
             self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
+        elif type_ == 'text_wrapped':
+            self.set_renderer(key, self.render_pre_sans_serif_wrapped)
+            self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
         elif type_ == 'file':
             tmpstore = SessionFileUploadTempStore(self.request)
-            kw = {'widget': dfwidget.FileUploadWidget(tmpstore),
+            kw = {'widget': FileUploadWidget(tmpstore, request=self.request),
                   'title': self.get_label(key)}
             if 'required' in kwargs and not kwargs['required']:
                 kw['missing'] = colander.null
             self.set_node(key, colander.SchemaNode(deform.FileData(), **kw))
+        elif type_ == 'multi_file':
+            tmpstore = SessionFileUploadTempStore(self.request)
+            file_node = colander.SchemaNode(deform.FileData(),
+                                            name='upload')
+
+            kw = {'name': key,
+                  'title': self.get_label(key),
+                  'widget': MultiFileUploadWidget(tmpstore)}
+            # if 'required' in kwargs and not kwargs['required']:
+            #     kw['missing'] = colander.null
+            if kwargs.get('validate_unique'):
+                kw['validator'] = self.validate_multiple_files_unique
+            files_node = colander.SequenceSchema(file_node, **kw)
+            self.set_node(key, files_node)
         else:
             raise ValueError("unknown type for '{}' field: {}".format(key, type_))
 
+    def validate_multiple_files_unique(self, node, value):
+
+        # get SHA256 hash for each file; error if duplicates encountered
+        hashes = {}
+        for fileinfo in value:
+            fp = fileinfo['fp']
+            fp.seek(0)
+            filehash = hashlib.sha256(fp.read()).hexdigest()
+            if filehash in hashes:
+                node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}")
+            hashes[filehash] = fileinfo
+
     def set_enum(self, key, enum, empty=None):
         if enum:
             self.enums[key] = enum
@@ -648,6 +769,22 @@ class Form(object):
         else:
             self.renderers[key] = renderer
 
+    def add_renderer_kwargs(self, key, kwargs):
+        self.renderer_kwargs.setdefault(key, {}).update(kwargs)
+
+    def get_renderer_kwargs(self, key):
+        return self.renderer_kwargs.get(key, {})
+
+    def set_renderer_kwargs(self, key, kwargs):
+        self.renderer_kwargs[key] = kwargs
+
+    def set_input_handler(self, key, value):
+        """
+        Convenience method to assign "input handler" callback code for
+        the given field.
+        """
+        self.add_renderer_kwargs(key, {'input_handler': value})
+
     def set_hidden(self, key, hidden=True):
         self.hidden[key] = hidden
 
@@ -659,8 +796,26 @@ class Form(object):
             self.schema[key].widget = widget
 
     def set_validator(self, key, validator):
+        """
+        Set the validator for the schema node represented by the given
+        key.
+
+        :param key: Normally this the name of one of the fields
+           contained in the form.  It can also be ``None`` in which
+           case the validator pertains to the form at large instead of
+           one of the fields.
+
+        :param validator: Callable which accepts ``(node, value)``
+           args.
+        """
         self.validators[key] = validator
 
+        # we normally apply the validator when creating the schema, so
+        # if this form already has a schema, then go ahead and apply
+        # the validator to it
+        if self.schema and key in self.schema:
+            self.schema[key].validator = validator
+
     def set_required(self, key, required=True):
         """
         Set whether or not value is required for a given field.
@@ -673,11 +828,16 @@ class Form(object):
         """
         self.defaults[key] = value
 
-    def set_helptext(self, key, value):
+    def set_helptext(self, key, value, dynamic=False):
         """
         Set the help text for a given field.
         """
-        self.helptext[key] = value
+        # nb. must avoid newlines, they cause some weird "blank page" error?!
+        self.helptext[key] = value.replace('\n', ' ')
+        if value and dynamic:
+            self.dynamic_helptext[key] = True
+        else:
+            self.dynamic_helptext.pop(key, None)
 
     def has_helptext(self, key):
         """
@@ -690,17 +850,22 @@ class Form(object):
         """
         Render the help text for the given field.
         """
-        return self.helptext[key]
+        text = self.helptext[key]
+        text = text.replace('"', '&quot;')
+        return HTML.literal(text)
 
-    def render(self, template=None, **kwargs):
-        if not template:
-            if self.readonly and not self.use_buefy:
-                template = '/forms/form_readonly.mako'
-            else:
-                template = '/forms/form.mako'
-        context = kwargs
-        context['form'] = self
-        return render(template, context)
+    def set_vuejs_field_converter(self, field, converter):
+        self.vuejs_field_converters[field] = converter
+
+    def render(self, **kwargs):
+        warnings.warn("Form.render() is deprecated (for now?); "
+                      "please use Form.render_deform() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.render_deform(**kwargs)
+
+    def get_deform(self):
+        """ """
+        return self.make_deform_form()
 
     def make_deform_form(self):
         if not hasattr(self, 'deform_form'):
@@ -740,30 +905,35 @@ class Form(object):
 
         return self.deform_form
 
+    def render_vue_template(self, template='/forms/deform.mako', **context):
+        """ """
+        output = self.render_deform(template=template, **context)
+        return HTML.literal(output)
+
     def render_deform(self, dform=None, template=None, **kwargs):
         if not template:
-            if self.use_buefy:
-                template = '/forms/deform_buefy.mako'
-            else:
-                template = '/forms/deform.mako'
+            template = '/forms/deform.mako'
 
         if dform is None:
             dform = self.make_deform_form()
 
         # TODO: would perhaps be nice to leverage deform's default rendering
         # someday..? i.e. using Chameleon *.pt templates
-        # return form.render()
+        # return dform.render()
 
         context = kwargs
         context['form'] = self
         context['dform'] = dform
+        context.setdefault('can_edit_help', self.can_edit_help)
+        if context['can_edit_help']:
+            context.setdefault('edit_help_url', self.edit_help_url)
+            context['field_labels'] = self.get_field_labels()
+            context['field_markdowns'] = self.get_field_markdowns()
         context.setdefault('form_kwargs', {})
         # TODO: deprecate / remove the latter option here
         if self.auto_disable_save or self.auto_disable:
-            if self.use_buefy:
-                context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly)
-            else:
-                context['form_kwargs']['class_'] = 'autodisable'
+            context['form_kwargs'].setdefault('ref', self.vue_component)
+            context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component)
         if self.focus_spec:
             context['form_kwargs']['data-focus'] = self.focus_spec
         context['request'] = self.request
@@ -771,28 +941,89 @@ class Form(object):
         context['render_field_readonly'] = self.render_field_readonly
         return render(template, context)
 
+    def get_field_labels(self):
+        return dict([(field, self.get_label(field))
+                     for field in self])
+
+    def get_field_markdowns(self, session=None):
+        app = self.request.rattail_config.get_app()
+        model = app.model
+        session = session or Session()
+
+        if not hasattr(self, 'field_markdowns'):
+            infos = session.query(model.TailboneFieldInfo)\
+                           .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
+                           .all()
+            self.field_markdowns = dict([(info.field_name, info.markdown_text)
+                                         for info in infos])
+
+        return self.field_markdowns
+
+    def get_vue_field_value(self, key):
+        """ """
+        if key not in self.fields:
+            return
+
+        dform = self.get_deform()
+        if key not in dform:
+            return
+
+        field = dform[key]
+        return make_json_safe(field.cstruct)
+
     def get_vuejs_model_value(self, field):
         """
         This method must return "raw" JS which will be assigned as the initial
         model value for the given field.  This JS will be written as part of
         the overall response, to be interpreted on the client side.
         """
-        if isinstance(field.schema.typ, deform.FileData):
-            # TODO: don't recall why "always null" here?
-            return 'null'
+        if field.name in self.vuejs_field_converters:
+            convert = self.vuejs_field_converters[field.name]
+            value = convert(field.cstruct)
+            return json.dumps(value)
 
         if isinstance(field.schema.typ, colander.Set):
             if field.cstruct is colander.null:
                 return '[]'
 
-        if field.cstruct is colander.null:
-            return 'null'
-
         try:
-            return json.dumps(field.cstruct)
+            return self.jsonify_value(field.cstruct)
         except Exception as error:
             raise TailboneJSONFieldError(field.name, error)
 
+    def jsonify_value(self, value):
+        """
+        Take a Python value and convert to JSON
+        """
+        if value is colander.null:
+            return 'null'
+
+        if isinstance(value, dfwidget.filedict):
+            # TODO: we used to always/only return 'null' here but hopefully
+            # this also works, to show existing filename when present
+            if value and value['filename']:
+                return json.dumps({'name': value['filename']})
+            return 'null'
+
+        elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict)
+                                              for f in value]):
+            return json.dumps([{'name': f['filename']}
+                               for f in value])
+
+        app = self.request.rattail_config.get_app()
+        value = app.json_friendly(value)
+        return json.dumps(value)
+
+    def get_error_messages(self, field):
+        if field.error:
+            return field.error.messages()
+
+        error = self.make_deform_form().error
+        if error:
+            if isinstance(error, colander.Invalid):
+                if error.node.name == field.name:
+                    return error.messages()
+
     def messages_json(self, messages):
         dump = json.dumps(messages)
         dump = dump.replace("'", '&apos;')
@@ -803,6 +1034,208 @@ class Form(object):
             return False
         return True
 
+    def set_vuejs_component_kwargs(self, **kwargs):
+        self.vuejs_component_kwargs.update(kwargs)
+
+    def render_vue_tag(self, **kwargs):
+        """ """
+        return self.render_vuejs_component(**kwargs)
+
+    def render_vuejs_component(self, **kwargs):
+        """
+        Render the Vue.js component HTML for the form.
+
+        Most typically this is something like:
+
+        .. code-block:: html
+
+           <tailbone-form :configure-fields-help="configureFieldsHelp">
+           </tailbone-form>
+        """
+        kw = dict(self.vuejs_component_kwargs)
+        kw.update(kwargs)
+        if self.can_edit_help:
+            kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
+        return HTML.tag(self.vue_tagname, **kw)
+
+    def set_json_data(self, key, value):
+        """
+        Establish a data value for use in client-side JS.  This value
+        will be JSON-encoded and made available to the
+        `<tailbone-form>` component within the client page.
+        """
+        self.json_data[key] = value
+
+    def include_template(self, template, context):
+        """
+        Declare a JS template as required by the current form.  This
+        template will then be included in the final page, so all
+        widgets behave correctly.
+        """
+        self.included_templates[template] = context
+
+    def render_included_templates(self):
+        templates = []
+        for template, context in self.included_templates.items():
+            context = dict(context)
+            context['form'] = self
+            templates.append(HTML.literal(render(template, context)))
+        return HTML.literal('\n').join(templates)
+
+    def render_vue_field(self, fieldname, **kwargs):
+        """ """
+        return self.render_field_complete(fieldname, **kwargs)
+
+    def render_field_complete(self, fieldname, bfield_attrs={},
+                              session=None):
+        """
+        Render the given field completely, i.e. with ``<b-field>``
+        wrapper.  Note that this is meant to render *editable* fields,
+        i.e. showing a widget, unless the field input is hidden.  In
+        other words it's not for "readonly" fields.
+        """
+        dform = self.make_deform_form()
+        field = dform[fieldname] if fieldname in dform else None
+
+        include = bool(field)
+        if self.readonly or (not field and fieldname in self.readonly_fields):
+            include = True
+        if not include:
+            return
+
+        if self.field_visible(fieldname):
+            label = self.get_label(fieldname)
+            markdowns = self.get_field_markdowns(session=session)
+
+            # these attrs will be for the <b-field> (*not* the widget)
+            attrs = {
+                ':horizontal': 'true',
+            }
+
+            # add some magic for file input fields
+            if field and isinstance(field.schema.typ, deform.FileData):
+                attrs['class_'] = 'file'
+
+            # next we will build array of messages to display..some
+            # fields always show a "helptext" msg, and some may have
+            # validation errors..
+            field_type = None
+            messages = []
+
+            # show errors if present
+            error_messages = self.get_error_messages(field) if field else None
+            if error_messages:
+                field_type = 'is-danger'
+                messages.extend(error_messages)
+
+            # show helptext if present
+            # TODO: older logic did this only if field was *not*
+            # readonly, perhaps should add that back..
+            if self.has_helptext(fieldname):
+                messages.append(self.render_helptext(fieldname))
+
+            # ..okay now we can declare the field messages and type
+            if field_type:
+                attrs['type'] = field_type
+            if messages:
+                if len(messages) == 1:
+                    msg = messages[0]
+                    if msg.startswith('`') and msg.endswith('`'):
+                        attrs[':message'] = msg
+                    else:
+                        attrs['message'] = msg
+                else:
+                    # nb. must pass an array as JSON string
+                    attrs[':message'] = '[{}]'.format(', '.join([
+                        "'{}'".format(msg.replace("'", r"\'"))
+                        for msg in messages]))
+
+            # merge anything caller provided
+            attrs.update(bfield_attrs)
+
+            # render the field widget or whatever
+            if self.readonly or fieldname in self.readonly_fields:
+                html = self.render_field_value(fieldname) or HTML.tag('span')
+                if type(html) is str:
+                    html = HTML.tag('span', c=[html])
+            elif field:
+                html = field.serialize(**self.get_renderer_kwargs(fieldname))
+                html = HTML.literal(html)
+
+            # may need a complex label
+            label_contents = [label]
+
+            # add 'help' icon/tooltip if defined
+            if markdowns.get(fieldname):
+                icon = HTML.tag('b-icon', size='is-small', pack='fas',
+                                icon='question-circle')
+                tooltip = render_markdown(markdowns[fieldname])
+
+                # nb. must apply hack to get <template #content> as final result
+                tooltip_template = HTML.tag('template', c=[tooltip],
+                                            **{'#content': 1})
+                tooltip_template = tooltip_template.replace(
+                    HTML.literal('<template #content="1"'),
+                    HTML.literal('<template #content'))
+
+                tooltip = HTML.tag('b-tooltip',
+                                   type='is-white',
+                                   size='is-large',
+                                   multilined='multilined',
+                                   c=[icon, tooltip_template])
+                label_contents.append(HTML.literal('&nbsp; &nbsp;'))
+                label_contents.append(tooltip)
+
+            # add 'configure' icon if allowed
+            if self.can_edit_help:
+                icon = HTML.tag('b-icon', size='is-small', pack='fas',
+                                icon='cog')
+                icon = HTML.tag('a', title="Configure field", c=[icon],
+                                **{'@click.prevent': "configureFieldInit('{}')".format(fieldname),
+                                   'v-show': 'configureFieldsHelp'})
+                label_contents.append(HTML.literal('&nbsp; &nbsp;'))
+                label_contents.append(icon)
+
+            # only declare label template if it's complex
+            html = [html]
+            # TODO: figure out why complex label does not work for oruga
+            if self.request.use_oruga:
+                attrs['label'] = label
+            else:
+                if len(label_contents) > 1:
+
+                    # nb. must apply hack to get <template #label> as final result
+                    label_template = HTML.tag('template', c=label_contents,
+                                              **{'#label': 1})
+                    label_template = label_template.replace(
+                        HTML.literal('<template #label="1"'),
+                        HTML.literal('<template #label'))
+                    html.insert(0, label_template)
+
+                else: # simple label
+                    attrs['label'] = label
+
+            # and finally wrap it all in a <b-field>
+            return HTML.tag('b-field', c=html, **attrs)
+
+        elif field: # hidden field
+
+            # can just do normal thing for these
+            # TODO: again, why does serialize() not return literal?
+            return HTML.literal(field.serialize())
+
+    # TODO: this was copied from wuttaweb; can remove when we align
+    # Form class structure
+    def render_vue_finalize(self):
+        """ """
+        set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
+        make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
+        return HTML.tag('script', c=['\n',
+                                     HTML.literal(set_data),
+                                     '\n',
+                                     HTML.literal(make_component),
+                                     '\n'])
+
     def render_field_readonly(self, field_name, **kwargs):
         """
         Render the given field completely, but in read-only fashion.
@@ -813,17 +1246,30 @@ class Form(object):
         if field_name not in self.fields:
             return ''
 
-        # TODO: fair bit of duplication here, should merge with deform.mako
-        label = HTML.tag('label', self.get_label(field_name), for_=field_name)
-        field = self.render_field_value(field_name) or ''
-        field_div = HTML.tag('div', class_='field', c=[field])
-        contents = [label, field_div]
+        label = kwargs.get('label')
+        if not label:
+            label = self.get_label(field_name)
 
-        if self.has_helptext(field_name):
-            contents.append(HTML.tag('span', class_='instructions',
-                                     c=[self.render_helptext(field_name)]))
+        value = self.render_field_value(field_name) or ''
 
-        return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
+        if not self.request.use_oruga:
+
+            label = HTML.tag('label', label, for_=field_name)
+            field_div = HTML.tag('div', class_='field', c=[value])
+            contents = [label, field_div]
+
+            if self.has_helptext(field_name):
+                contents.append(HTML.tag('span', class_='instructions',
+                                         c=[self.render_helptext(field_name)]))
+
+            return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
+
+        # nb. for some reason we must wrap once more for oruga,
+        # otherwise it splits up the field?!
+        value = HTML.tag('span', c=[value])
+
+        # oruga uses <o-field>
+        return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'})
 
     def render_field_value(self, field_name):
         record = self.model_instance
@@ -835,7 +1281,7 @@ class Form(object):
         value = self.obtain_value(record, field_name)
         if value is None:
             return ""
-        return six.text_type(value)
+        return str(value)
 
     def render_datetime(self, record, field_name):
         value = self.obtain_value(record, field_name)
@@ -847,14 +1293,16 @@ class Form(object):
         value = self.obtain_value(record, field_name)
         if value is None:
             return ""
-        value = localtime(self.request.rattail_config, value)
+        app = self.request.rattail_config.get_app()
+        value = app.localtime(value)
         return raw_datetime(self.request.rattail_config, value)
 
     def render_duration(self, record, field_name):
-        value = self.obtain_value(record, field_name)
-        if value is None:
+        seconds = self.obtain_value(record, field_name)
+        if seconds is None:
             return ""
-        return pretty_hours(datetime.timedelta(seconds=value))
+        app = self.request.rattail_config.get_app()
+        return app.render_duration(seconds=seconds)
 
     def render_boolean(self, record, field_name):
         value = self.obtain_value(record, field_name)
@@ -869,19 +1317,19 @@ class Form(object):
                 return "(${:0,.2f})".format(0 - value)
             return "${:0,.2f}".format(value)
         except ValueError:
-            return six.text_type(value)
+            return str(value)
 
     def render_quantity(self, obj, field):
         value = self.obtain_value(obj, field)
         if value is None:
             return ""
-        return pretty_quantity(value)
+        app = self.request.rattail_config.get_app()
+        return app.render_quantity(value)
 
     def render_percent(self, obj, field):
+        app = self.request.rattail_config.get_app()
         value = self.obtain_value(obj, field)
-        if value is None:
-            return ""
-        return "{:0.3f} %".format(value * 100)
+        return app.render_percent(value, places=3)
 
     def render_gpc(self, obj, field):
         value = self.obtain_value(obj, field)
@@ -895,8 +1343,8 @@ class Form(object):
             return ""
         enum = self.enums.get(field_name)
         if enum and value in enum:
-            return six.text_type(enum[value])
-        return six.text_type(value)
+            return str(enum[value])
+        return str(value)
 
     def render_codeblock(self, record, field_name):
         value = self.obtain_value(record, field_name)
@@ -904,89 +1352,93 @@ class Form(object):
             return ""
         return HTML.tag('pre', value)
 
-    def render_pre_sans_serif(self, record, field_name):
+    def render_pre_sans_serif(self, record, field_name, wrapped=False):
         value = self.obtain_value(record, field_name)
         if value is None:
             return ""
-        # this uses a Bulma helper class, for which we also add custom styles
-        # to our "default" base.css (for jquery theme)
-        return HTML.tag('pre', class_='is-family-sans-serif',
-                        c=value)
+
+        kwargs = {
+            'c': value,
+            # this uses a Bulma helper class, for which we also add
+            # custom styles to our "default" base.css (for jquery
+            # theme)
+            'class_': 'is-family-sans-serif',
+        }
+
+        if wrapped:
+            kwargs['style'] = 'white-space: pre-wrap;'
+
+        return HTML.tag('pre', **kwargs)
+
+    def render_pre_sans_serif_wrapped(self, record, field_name):
+        return self.render_pre_sans_serif(record, field_name, wrapped=True)
 
     def obtain_value(self, record, field_name):
         if record:
+
+            if isinstance(record, dict):
+                return record[field_name]
+
+            try:
+                return getattr(record, field_name)
+            except AttributeError:
+                pass
+
             try:
                 return record[field_name]
             except TypeError:
-                return getattr(record, field_name, None)
+                pass
 
         # TODO: is this always safe to do?
         elif self.defaults and field_name in self.defaults:
             return self.defaults[field_name]
 
     def validate(self, *args, **kwargs):
-        if kwargs.pop('newstyle', False):
-            # yay, new behavior!
-            if hasattr(self, 'validated'):
-                del self.validated
-            if self.request.method != 'POST':
-                return False
+        """
+        Try to validate the form.
 
-            # use POST or JSON body, whichever is present
-            # TODO: per docs, some JS libraries may not set this flag?
-            # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
-            if self.request.is_xhr and not self.request.POST:
-                controls = self.request.json_body.items()
+        This should work whether data was submitted as classic POST
+        data, or as JSON body.
 
-                # unfortunately the normal form logic (i.e. peppercorn) is
-                # expecting all values to be strings, whereas the JSON body we
-                # just parsed, may have given us some Pythonic objects.  so
-                # here we must convert them *back* to strings...
-                # TODO: this seems like a hack, i must be missing something
-                controls = [[key, val] for key, val in controls]
-                for i in range(len(controls)):
-                    key, value = controls[i]
-                    if value is None:
-                        controls[i][1] = ''
-                    elif value is True:
-                        controls[i][1] = 'true'
-                    elif value is False:
-                        controls[i][1] = 'false'
-                    elif not isinstance(value, six.string_types):
-                        controls[i][1] = six.text_type(value)
+        :returns: ``True`` if form data is valid, otherwise ``False``.
+        """
+        if 'newstyle' in kwargs:
+            warnings.warn("the `newstyle` kwarg is no longer used "
+                          "for Form.validate()",
+                          DeprecationWarning, stacklevel=2)
 
-            else:
-                controls = self.request.POST.items()
+        if hasattr(self, 'validated'):
+            del self.validated
+        if self.request.method != 'POST':
+            return False
 
-            dform = self.make_deform_form()
-            try:
-                self.validated = dform.validate(controls)
-                return True
-            except deform.ValidationFailure:
-                return False
+        controls = get_form_data(self.request).items()
 
-        else: # legacy behavior
-            raise_error = kwargs.pop('raise_error', True)
-            dform = self.make_deform_form()
-            try:
-                return dform.validate(*args, **kwargs)
-            except deform.ValidationFailure:
-                if raise_error:
-                    raise
+        # unfortunately the normal form logic (i.e. peppercorn) is
+        # expecting all values to be strings, whereas if our data
+        # came from JSON body, may have given us some Pythonic
+        # objects.  so here we must convert them *back* to strings
+        # TODO: this seems like a hack, i must be missing something
+        # TODO: also this uses same "JSON" check as get_form_data()
+        if self.request.is_xhr and not self.request.POST:
+            controls = [[key, val] for key, val in controls]
+            for i in range(len(controls)):
+                key, value = controls[i]
+                if value is None:
+                    controls[i][1] = ''
+                elif value is True:
+                    controls[i][1] = 'true'
+                elif value is False:
+                    controls[i][1] = 'false'
+                elif not isinstance(value, str):
+                    controls[i][1] = str(value)
 
-
-class FieldList(list):
-    """
-    Convenience wrapper for a form's field list.
-    """
-
-    def insert_before(self, field, newfield):
-        i = self.index(field)
-        self.insert(i, newfield)
-
-    def insert_after(self, field, newfield):
-        i = self.index(field)
-        self.insert(i + 1, newfield)
+        dform = self.make_deform_form()
+        try:
+            self.validated = dform.validate(controls)
+            return True
+        except deform.ValidationFailure:
+            return False
 
 
 @colander.deferred
diff --git a/tailbone/forms/receiving.py b/tailbone/forms/receiving.py
index 40fa35fe..9f5706c7 100644
--- a/tailbone/forms/receiving.py
+++ b/tailbone/forms/receiving.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Forms for Receiving
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 import colander
@@ -35,7 +33,7 @@ import colander
 def valid_purchase_batch_row(node, kw):
     session = kw['session']
     def validate(node, value):
-        row = session.query(model.PurchaseBatchRow).get(value)
+        row = session.get(model.PurchaseBatchRow, value)
         if not row:
             raise colander.Invalid(node, "Batch row not found")
         if row.batch.executed:
@@ -54,6 +52,7 @@ class ReceiveRow(colander.MappingSchema):
                                    'received',
                                    'damaged',
                                    'expired',
+                                   'missing',
                                    # 'mispick',
                                ]))
 
diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py
index d9f7e828..ac7f2d43 100644
--- a/tailbone/forms/types.py
+++ b/tailbone/forms/types.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,9 @@
 Form Schema Types
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import re
 import datetime
-
-import six
+import json
 
 from rattail.db import model
 from rattail.gpc import GPC
@@ -37,6 +34,7 @@ from rattail.gpc import GPC
 import colander
 
 from tailbone.db import Session
+from tailbone.forms import widgets
 
 
 class JQueryTime(colander.Time):
@@ -76,6 +74,76 @@ class DateTimeBoolean(colander.Boolean):
             return datetime.datetime.utcnow()
 
 
+class FalafelDateTime(colander.DateTime):
+    """
+    Custom schema node type for rattail UTC datetimes
+    """
+    widget_maker = widgets.FalafelDateTimeWidget
+
+    def __init__(self, *args, **kwargs):
+        request = kwargs.pop('request')
+        super().__init__(*args, **kwargs)
+        self.request = request
+
+    def serialize(self, node, appstruct):
+        if not appstruct:
+            return {}
+
+        # cant use isinstance; dt subs date
+        if type(appstruct) is datetime.date:
+            appstruct = datetime.datetime.combine(appstruct, datetime.time())
+
+        if not isinstance(appstruct, datetime.datetime):
+            raise colander.Invalid(node, f'"{appstruct}" is not a datetime object')
+
+        if appstruct.tzinfo is None:
+            appstruct = appstruct.replace(tzinfo=self.default_tzinfo)
+
+        app = self.request.rattail_config.get_app()
+        dt = app.localtime(appstruct, from_utc=True)
+
+        return {
+            'date': str(dt.date()),
+            'time': str(dt.time()),
+        }
+
+    def deserialize(self, node, cstruct):
+        if not cstruct:
+            return colander.null
+
+        if not cstruct['date'] and not cstruct['time']:
+            return colander.null
+
+        try:
+            date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date()
+        except:
+            node.raise_invalid("Missing or invalid date")
+
+        try:
+            time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time()
+        except:
+            node.raise_invalid("Missing or invalid time")
+
+        result = datetime.datetime.combine(date, time)
+
+        app = self.request.rattail_config.get_app()
+        result = app.localtime(result)
+        result = app.make_utc(result)
+        return result
+
+
+class FalafelTime(colander.Time):
+    """
+    Custom schema node type for simple time fields
+    """
+    widget_maker = widgets.FalafelTimeWidget
+
+    def __init__(self, *args, **kwargs):
+        request = kwargs.pop('request')
+        super().__init__(*args, **kwargs)
+        self.request = request
+
+
 class GPCType(colander.SchemaType):
     """
     Schema type for product GPC data.
@@ -84,7 +152,7 @@ class GPCType(colander.SchemaType):
     def serialize(self, node, appstruct):
         if appstruct is colander.null:
             return colander.null
-        return six.text_type(appstruct)
+        return str(appstruct)
 
     def deserialize(self, node, cstruct):
         if not cstruct:
@@ -95,7 +163,7 @@ class GPCType(colander.SchemaType):
         try:
             return GPC(digits)
         except Exception as err:
-            raise colander.Invalid(node, six.text_type(err))
+            raise colander.Invalid(node, str(err))
 
 
 class ProductQuantity(colander.MappingSchema):
@@ -133,12 +201,12 @@ class ModelType(colander.SchemaType):
     def serialize(self, node, appstruct):
         if appstruct is colander.null:
             return colander.null
-        return six.text_type(appstruct)
+        return str(appstruct)
 
     def deserialize(self, node, cstruct):
         if not cstruct:
             return None
-        obj = self.session.query(self.model_class).get(cstruct)
+        obj = self.session.get(self.model_class, cstruct)
         if not obj:
             raise colander.Invalid(node, "{} not found".format(self.model_title))
         return obj
diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py
index d8976337..8c16726d 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,19 +24,16 @@
 Form Widgets
 """
 
-from __future__ import unicode_literals, absolute_import, division
-
 import json
 import datetime
 import decimal
-
-import six
+import re
 
 import colander
 from deform import widget as dfwidget
 from webhelpers2.html import tags, HTML
 
-from tailbone.forms.types import ProductQuantity
+from tailbone.db import Session
 
 
 class ReadonlyWidget(dfwidget.HiddenWidget):
@@ -44,6 +41,7 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
     readonly = True
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct in (colander.null, None):
             cstruct = ''
         # TODO: is this hacky?
@@ -60,11 +58,11 @@ class NumberInputWidget(dfwidget.TextInputWidget):
 
 class NumericInputWidget(NumberInputWidget):
     """
-    This widget only supports Buefy themes for now.  It uses a
-    ``<numeric-input>`` component, which will leverage the ``numeric.js``
-    functions to ensure user doesn't enter any non-numeric values.  Note that
-    this still uses a normal "text" input on the HTML side, as opposed to a
-    "number" input, since the latter is a bit ugly IMHO.
+    This widget uses a ``<numeric-input>`` component, which will
+    leverage the ``numeric.js`` functions to ensure user doesn't enter
+    any non-numeric values.  Note that this still uses a normal "text"
+    input on the HTML side, as opposed to a "number" input, since the
+    latter is a bit ugly IMHO.
     """
     template = 'numericinput'
     allow_enter = True
@@ -81,15 +79,17 @@ class PercentInputWidget(dfwidget.TextInputWidget):
     autocomplete = 'off'
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct not in (colander.null, None):
             # convert "traditional" value to "human-friendly"
             value = decimal.Decimal(cstruct) * 100
             value = value.quantize(decimal.Decimal('0.001'))
-            cstruct = six.text_type(value)
-        return super(PercentInputWidget, self).serialize(field, cstruct, **kw)
+            cstruct = str(value)
+        return super().serialize(field, cstruct, **kw)
 
     def deserialize(self, field, pstruct):
-        pstruct = super(PercentInputWidget, self).deserialize(field, pstruct)
+        """ """
+        pstruct = super().deserialize(field, pstruct)
         if pstruct is colander.null:
             return colander.null
         # convert "human-friendly" value to "traditional"
@@ -99,7 +99,7 @@ class PercentInputWidget(dfwidget.TextInputWidget):
             raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
         value = value.quantize(decimal.Decimal('0.00001'))
         value /= 100
-        return six.text_type(value)
+        return str(value)
 
 
 class CasesUnitsWidget(dfwidget.Widget):
@@ -112,6 +112,7 @@ class CasesUnitsWidget(dfwidget.Widget):
     one_amount_only = False
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct in (colander.null, None):
             cstruct = ''
         readonly = kw.get('readonly', self.readonly)
@@ -122,6 +123,9 @@ class CasesUnitsWidget(dfwidget.Widget):
         return field.renderer(template, **values)
 
     def deserialize(self, field, pstruct):
+        """ """
+        from tailbone.forms.types import ProductQuantity
+
         if pstruct is colander.null:
             return colander.null
 
@@ -150,6 +154,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
     template = 'checkbox_dynamic'
 
 
+# TODO: deprecate / remove this
 class PlainSelectWidget(dfwidget.SelectWidget):
     template = 'select_plain'
 
@@ -168,7 +173,7 @@ class CustomSelectWidget(dfwidget.SelectWidget):
         self.extra_template_values.update(kw)
 
     def get_template_values(self, field, cstruct, kw):
-        values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw)
+        values = super().get_template_values(field, cstruct, kw)
         if hasattr(self, 'extra_template_values'):
             values.update(self.extra_template_values)
         return values
@@ -211,6 +216,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
     )
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if cstruct in (colander.null, None):
             cstruct = ''
         readonly = kw.get('readonly', self.readonly)
@@ -238,6 +244,48 @@ class JQueryTimeWidget(dfwidget.TimeInputWidget):
     )
 
 
+class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
+    """
+    Custom widget for rattail UTC datetimes
+    """
+    template = 'datetime_falafel'
+
+    new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$')
+
+    def serialize(self, field, cstruct, **kw):
+        """ """
+        readonly = kw.get('readonly', self.readonly)
+        values = self.get_template_values(field, cstruct, kw)
+        template = self.readonly_template if readonly else self.template
+        return field.renderer(template, **values)
+
+    def deserialize(self, field, pstruct):
+        """ """
+        if pstruct  == '':
+            return colander.null
+
+        # nb. we now allow '4:20:00 PM' on the widget side, but the
+        # true node needs it to be '16:20:00' instead
+        if self.new_pattern.match(pstruct['time']):
+            time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p')
+            pstruct['time'] = time.strftime('%H:%M:%S')
+
+        return pstruct
+
+
+class FalafelTimeWidget(dfwidget.TimeInputWidget):
+    """
+    Custom widget for simple time fields
+    """
+    template = 'time_falafel'
+
+    def deserialize(self, field, pstruct):
+        """ """
+        if pstruct  == '':
+            return colander.null
+        return pstruct
+
+
 class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
     """ 
     Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
@@ -246,9 +294,13 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
     template = 'autocomplete_jquery'
     requirements = None
     field_display = ""
+    assigned_label = None
     service_url = None
     cleared_callback = None
     selected_callback = None
+    input_callback = None
+    new_label_callback = None
+    ref = None
 
     default_options = (
         ('autoFocus', True),
@@ -256,6 +308,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
     options = None
 
     def serialize(self, field, cstruct, **kw):
+        """ """
         if 'delay' in kw or getattr(self, 'delay', None):
             raise ValueError(
                 'AutocompleteWidget does not support *delay* parameter '
@@ -274,7 +327,333 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
         kw['options'] = json.dumps(options)
         kw['field_display'] = self.field_display
         kw['cleared_callback'] = self.cleared_callback
+        kw['assigned_label'] = self.assigned_label
+        kw['input_callback'] = self.input_callback
+        kw['new_label_callback'] = self.new_label_callback
+        kw['ref'] = self.ref
         kw.setdefault('selected_callback', self.selected_callback)
         tmpl_values = self.get_template_values(field, cstruct, kw)
         template = readonly and self.readonly_template or self.template
         return field.renderer(template, **tmpl_values)
+
+
+class FileUploadWidget(dfwidget.FileUploadWidget):
+    """
+    Widget to handle file upload.  Must override to add ``use_oruga``
+    to field template context.
+    """
+
+    def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop('request')
+        super().__init__(*args, **kwargs)
+
+    def get_template_values(self, field, cstruct, kw):
+        values = super().get_template_values(field, cstruct, kw)
+        if self.request:
+            values['use_oruga'] = self.request.use_oruga
+        return values
+
+
+class MultiFileUploadWidget(dfwidget.FileUploadWidget):
+    """
+    Widget to handle multiple (arbitrary number) of file uploads.
+    """
+    template = 'multi_file_upload'
+    requirements = ()
+
+    def serialize(self, field, cstruct, **kw):
+        """ """
+        if cstruct in (colander.null, None):
+            cstruct = []
+
+        if cstruct:
+            for fileinfo in cstruct:
+                uid = fileinfo['uid']
+                if uid not in self.tmpstore:
+                    self.tmpstore[uid] = fileinfo
+
+        readonly = kw.get("readonly", self.readonly)
+        template = readonly and self.readonly_template or self.template
+        values = self.get_template_values(field, cstruct, kw)
+        return field.renderer(template, **values)
+
+    def deserialize(self, field, pstruct):
+        """ """
+        if pstruct is colander.null:
+            return colander.null
+
+        # TODO: why is this a thing?  pstruct == [b'']
+        if len(pstruct) == 1 and pstruct[0] == b'':
+            return colander.null
+
+        files_data = []
+        for upload in pstruct:
+
+            data = self.deserialize_upload(upload)
+            if data:
+                files_data.append(data)
+
+        if not files_data:
+            return colander.null
+
+        return files_data
+
+    def deserialize_upload(self, upload):
+        """ """
+        # nb. this logic was copied from parent class and adapted
+        # to allow for multiple files.  needs some more love.
+
+        uid = None              # TODO?
+
+        if hasattr(upload, "file"):
+            # the upload control had a file selected
+            data = dfwidget.filedict()
+            data["fp"] = upload.file
+            filename = upload.filename
+            # sanitize IE whole-path filenames
+            filename = filename[filename.rfind("\\") + 1 :].strip()
+            data["filename"] = filename
+            data["mimetype"] = upload.type
+            data["size"] = upload.length
+            if uid is None:
+                # no previous file exists
+                while 1:
+                    uid = self.random_id()
+                    if self.tmpstore.get(uid) is None:
+                        data["uid"] = uid
+                        self.tmpstore[uid] = data
+                        preview_url = self.tmpstore.preview_url(uid)
+                        self.tmpstore[uid]["preview_url"] = preview_url
+                        break
+            else:
+                # a previous file exists
+                data["uid"] = uid
+                self.tmpstore[uid] = data
+                preview_url = self.tmpstore.preview_url(uid)
+                self.tmpstore[uid]["preview_url"] = preview_url
+        else:
+            # the upload control had no file selected
+            if uid is None:
+                # no previous file exists
+                return colander.null
+            else:
+                # a previous file should exist
+                data = self.tmpstore.get(uid)
+                # but if it doesn't, don't blow up
+                if data is None:
+                    return colander.null
+        return data
+
+
+def make_customer_widget(request, **kwargs):
+    """
+    Make a customer widget; will be either autocomplete or dropdown
+    depending on config.
+    """
+    # use autocomplete widget by default
+    factory = CustomerAutocompleteWidget
+
+    # caller may request dropdown widget
+    if kwargs.pop('dropdown', False):
+        factory = CustomerDropdownWidget
+
+    else: # or, config may say to use dropdown
+        if request.rattail_config.getbool(
+                'rattail', 'customers.choice_uses_dropdown',
+                default=False):
+            factory = CustomerDropdownWidget
+
+    # instantiate whichever
+    return factory(request, **kwargs)
+
+
+class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
+    """
+    Autocomplete widget for a
+    :class:`~rattail:rattail.db.model.customers.Customer` reference
+    field.
+    """
+
+    def __init__(self, request, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.request = request
+        app = self.request.rattail_config.get_app()
+        model = app.model
+
+        # must figure out URL providing autocomplete service
+        if 'service_url' not in kwargs:
+
+            # caller can just pass 'url' instead of 'service_url'
+            if 'url' in kwargs:
+                self.service_url = kwargs['url']
+
+            else: # use default url
+                self.service_url = self.request.route_url('customers.autocomplete')
+
+        # TODO
+        if 'input_callback' not in kwargs:
+            if 'input_handler' in kwargs:
+                self.input_callback = input_handler
+
+    def serialize(self, field, cstruct, **kw):
+        """ """
+        # fetch customer to provide button label, if we have a value
+        if cstruct:
+            app = self.request.rattail_config.get_app()
+            model = app.model
+            customer = Session.get(model.Customer, cstruct)
+            if customer:
+                self.field_display = str(customer)
+
+        return super().serialize(
+            field, cstruct, **kw)
+
+
+class CustomerDropdownWidget(dfwidget.SelectWidget):
+    """
+    Dropdown widget for a
+    :class:`~rattail:rattail.db.model.customers.Customer` reference
+    field.
+    """
+
+    def __init__(self, request, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.request = request
+        app = self.request.rattail_config.get_app()
+
+        # must figure out dropdown values, if they weren't given
+        if 'values' not in kwargs:
+
+            # use what caller gave us, if they did
+            if 'customers' in kwargs:
+                customers = kwargs['customers']
+                if callable(customers):
+                    customers = customers()
+
+            else: # default customer list
+                customers = app.get_clientele_handler()\
+                               .get_all_customers(Session())
+
+            # convert customer list to option values
+            self.values = [(c.uuid, c.name)
+                           for c in customers]
+
+
+class DepartmentWidget(dfwidget.SelectWidget):
+    """
+    Custom select widget for a Department reference field.
+
+    Constructor accepts the normal ``values`` kwarg but if not
+    provided then the widget will fetch department list from Rattail
+    DB.
+
+    Constructor also accepts ``required`` kwarg, which defaults to
+    true unless specified.
+    """
+
+    def __init__(self, request, **kwargs):
+
+        if 'values' not in kwargs:
+            app = request.rattail_config.get_app()
+            model = app.model
+            departments = Session.query(model.Department)\
+                                 .order_by(model.Department.number)
+            values = [(dept.uuid, str(dept))
+                      for dept in departments]
+            if not kwargs.pop('required', True):
+                values.insert(0, ('', "(none)"))
+            kwargs['values'] = values
+
+        super().__init__(**kwargs)
+
+
+def make_vendor_widget(request, **kwargs):
+    """
+    Make a vendor widget; will be either autocomplete or dropdown
+    depending on config.
+    """
+    # use autocomplete widget by default
+    factory = VendorAutocompleteWidget
+
+    # caller may request dropdown widget
+    if kwargs.pop('dropdown', False):
+        factory = VendorDropdownWidget
+
+    else: # or, config may say to use dropdown
+        app = request.rattail_config.get_app()
+        vendor_handler = app.get_vendor_handler()
+        if vendor_handler.choice_uses_dropdown():
+            factory = VendorDropdownWidget
+
+    # instantiate whichever
+    return factory(request, **kwargs)
+
+
+class VendorAutocompleteWidget(JQueryAutocompleteWidget):
+    """
+    Autocomplete widget for a Vendor reference field.
+    """
+
+    def __init__(self, request, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.request = request
+        app = self.request.rattail_config.get_app()
+        model = app.model
+
+        # must figure out URL providing autocomplete service
+        if 'service_url' not in kwargs:
+
+            # caller can just pass 'url' instead of 'service_url'
+            if 'url' in kwargs:
+                self.service_url = kwargs['url']
+
+            else: # use default url
+                self.service_url = self.request.route_url('vendors.autocomplete')
+
+        # # TODO
+        # if 'input_callback' not in kwargs:
+        #     if 'input_handler' in kwargs:
+        #         self.input_callback = input_handler
+
+    def serialize(self, field, cstruct, **kw):
+        """ """
+        # fetch vendor to provide button label, if we have a value
+        if cstruct:
+            app = self.request.rattail_config.get_app()
+            model = app.model
+            vendor = Session.get(model.Vendor, cstruct)
+            if vendor:
+                self.field_display = str(vendor)
+
+        return super().serialize(
+            field, cstruct, **kw)
+
+
+class VendorDropdownWidget(dfwidget.SelectWidget):
+    """
+    Dropdown widget for a Vendor reference field.
+    """
+
+    def __init__(self, request, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.request = request
+
+        # must figure out dropdown values, if they weren't given
+        if 'values' not in kwargs:
+
+            # use what caller gave us, if they did
+            if 'vendors' in kwargs:
+                vendors = kwargs['vendors']
+                if callable(vendors):
+                    vendors = vendors()
+
+            else: # default vendor list
+                app = self.request.rattail_config.get_app()
+                model = app.model
+                vendors = Session.query(model.Vendor)\
+                                   .order_by(model.Vendor.name)\
+                                   .all()
+
+            # convert vendor list to option values
+            self.values = [(c.uuid, c.name)
+                           for c in vendors]
diff --git a/tailbone/grids/__init__.py b/tailbone/grids/__init__.py
index 0d4970c8..7db22b26 100644
--- a/tailbone/grids/__init__.py
+++ b/tailbone/grids/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2021 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -28,4 +28,3 @@ from __future__ import unicode_literals, absolute_import
 
 from . import filters
 from .core import Grid, GridAction
-from .mobile import MobileGrid
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index d475370c..56b97b86 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,69 +24,251 @@
 Core Grid Classes
 """
 
-from __future__ import unicode_literals, absolute_import
+import inspect
+import logging
+import warnings
+from urllib.parse import urlencode
 
-import datetime
-from six.moves import urllib
-
-import six
 import sqlalchemy as sa
 from sqlalchemy import orm
 
-from rattail.db import api
+from wuttjamaican.util import UNSPECIFIED
 from rattail.db.types import GPCType
-from rattail.util import prettify, pretty_boolean, pretty_quantity, pretty_hours
-from rattail.time import localtime
+from rattail.util import prettify, pretty_boolean
 
-import webhelpers2_grid
 from pyramid.renderers import render
 from webhelpers2.html import HTML, tags
 from paginate_sqlalchemy import SqlalchemyOrmPage
 
+from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo
+from wuttaweb.util import FieldList
 from . import filters as gridfilters
 from tailbone.db import Session
 from tailbone.util import raw_datetime
 
 
-class FieldList(list):
+log = logging.getLogger(__name__)
+
+
+class Grid(WuttaGrid):
     """
-    Convenience wrapper for a field list.
+    Base class for all grids.
+
+    This is now a subclass of
+    :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add
+    customizations which have traditionally been part of Tailbone.
+
+    Some of these customizations are still undocumented.  Some will
+    eventually be moved to the upstream/parent class, and possibly
+    some will be removed outright.  What docs we have, are shown here.
+
+    .. _Buefy docs: https://buefy.org/documentation/table/
+
+    .. attribute:: checkable
+
+       Optional callback to determine if a given row is checkable,
+       i.e. this allows hiding checkbox for certain rows if needed.
+
+       This may be either a Python callable, or string representing a
+       JS callable.  If the latter, according to the `Buefy docs`_:
+
+       .. code-block:: none
+
+          Custom method to verify if a row is checkable, works when is
+          checkable.
+
+          Function (row: Object)
+
+       In other words this JS callback would be invoked for each data
+       row in the client-side grid.
+
+       But if a Python callable is used, then it will be invoked for
+       each row object in the server-side grid.  For instance::
+
+          def checkable(obj):
+              if obj.some_property == True:
+                  return True
+              return False
+
+          grid.checkable = checkable
+
+    .. attribute:: check_handler
+
+       Optional JS callback for the ``@check`` event of the underlying
+       Buefy table component.  See the `Buefy docs`_ for more info,
+       but for convenience they say this (as of writing):
+
+       .. code-block:: none
+
+          Triggers when the checkbox in a row is clicked and/or when
+          the header checkbox is clicked
+
+       For instance, you might set ``grid.check_handler =
+       'rowChecked'`` and then define the handler within your template
+       (e.g. ``/widgets/index.mako``) like so:
+
+       .. code-block:: none
+
+          <%def name="modify_this_page_vars()">
+            ${parent.modify_this_page_vars()}
+            <script type="text/javascript">
+
+              TailboneGrid.methods.rowChecked = function(checkedList, row) {
+                  if (!row) {
+                      console.log("no row, so header checkbox was clicked")
+                  } else {
+                      console.log(row)
+                      if (checkedList.includes(row)) {
+                          console.log("clicking row checkbox ON")
+                      } else {
+                          console.log("clicking row checkbox OFF")
+                      }
+                  }
+                  console.log(checkedList)
+              }
+
+            </script>
+          </%def>
+
+    .. attribute:: raw_renderers
+
+       Dict of "raw" field renderers.  See also
+       :meth:`set_raw_renderer()`.
+
+       When present, these are rendered "as-is" into the grid
+       template, whereas the more typical scenario involves rendering
+       each field "into" a span element, like:
+
+       .. code-block:: html
+
+          <span v-html="RENDERED-FIELD"></span>
+
+       So instead of injecting into a span, any "raw" fields defined
+       via this dict, will be injected as-is, like:
+
+       .. code-block:: html
+
+          RENDERED-FIELD
+
+       Note that each raw renderer is called only once, and *without*
+       any arguments.  Likely the only use case for this, is to inject
+       a Vue component into the field.  A basic example::
+
+          from webhelpers2.html import HTML
+
+          def myrender():
+              return HTML.tag('my-component', **{'v-model': 'props.row.myfield'})
+
+          grid = Grid(
+              # ..normal constructor args here..
+
+              raw_renderers={
+                  'myfield': myrender,
+              },
+          )
+
+    .. attribute row_uuid_getter::
+
+       Optional callable to obtain the "UUID" (sic) value for each
+       data row.  The default assumption as that each row object has a
+       ``uuid`` attribute, but when that isn't the case, *and* the
+       grid needs to support checkboxes, we must "pretend" by
+       injecting some custom value to the ``uuid`` of the row data.
+
+       If necssary, set this to a callable like so::
+
+          def fake_uuid(row):
+              return row.some_custom_key
+
+          grid.row_uuid_getter = fake_uuid
     """
 
-    def insert_before(self, field, newfield):
-        i = self.index(field)
-        self.insert(i, newfield)
+    def __init__(
+            self,
+            request,
+            key=None,
+            data=None,
+            width='auto',
+            model_title=None,
+            model_title_plural=None,
+            enums={},
+            assume_local_times=False,
+            invisible=[],
+            raw_renderers={},
+            extra_row_class=None,
+            url='#',
+            use_byte_string_filters=False,
+            checkboxes=False,
+            checked=None,
+            check_handler=None,
+            check_all_handler=None,
+            checkable=None,
+            row_uuid_getter=None,
+            clicking_row_checks_box=False,
+            click_handlers=None,
+            main_actions=[],
+            more_actions=[],
+            delete_speedbump=False,
+            ajax_data_url=None,
+            expose_direct_link=False,
+            **kwargs,
+    ):
+        if 'component' in kwargs:
+            warnings.warn("component param is deprecated for Grid(); "
+                          "please use vue_tagname param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('vue_tagname', kwargs.pop('component'))
 
-    def insert_after(self, field, newfield):
-        i = self.index(field)
-        self.insert(i + 1, newfield)
+        if 'default_sortkey' in kwargs:
+            warnings.warn("default_sortkey param is deprecated for Grid(); "
+                          "please use sort_defaults param instead",
+                          DeprecationWarning, stacklevel=2)
+        if 'default_sortdir' in kwargs:
+            warnings.warn("default_sortdir param is deprecated for Grid(); "
+                          "please use sort_defaults param instead",
+                          DeprecationWarning, stacklevel=2)
+        if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs:
+            sortkey = kwargs.pop('default_sortkey', None)
+            sortdir = kwargs.pop('default_sortdir', 'asc')
+            if sortkey:
+                kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
 
+        if 'pageable' in kwargs:
+            warnings.warn("pageable param is deprecated for Grid(); "
+                          "please use paginated param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('paginated', kwargs.pop('pageable'))
 
-class Grid(object):
-    """
-    Core grid class.  In sore need of documentation.
-    """
+        if 'default_pagesize' in kwargs:
+            warnings.warn("default_pagesize param is deprecated for Grid(); "
+                          "please use pagesize param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('pagesize', kwargs.pop('default_pagesize'))
 
-    def __init__(self, key, data, columns=None, width='auto', request=None, mobile=False,
-                 model_class=None, model_title=None, model_title_plural=None,
-                 enums={}, labels={}, renderers={}, extra_row_class=None, linked_columns=[], url='#',
-                 joiners={}, filterable=False, filters={}, use_byte_string_filters=False,
-                 sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
-                 pageable=False, default_pagesize=20, default_page=1,
-                 checkboxes=False, checked=None, check_handler=None, check_all_handler=None,
-                 main_actions=[], more_actions=[], delete_speedbump=False,
-                 ajax_data_url=None, component='tailbone-grid',
-                 **kwargs):
+        if 'default_page' in kwargs:
+            warnings.warn("default_page param is deprecated for Grid(); "
+                          "please use page param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('page', kwargs.pop('default_page'))
 
-        self.key = key
-        self.data = data
-        self.columns = FieldList(columns) if columns is not None else None
-        self.width = width
-        self.request = request
-        self.mobile = mobile
-        self.model_class = model_class
-        if self.model_class and self.columns is None:
-            self.columns = self.make_columns()
+        if 'searchable' in kwargs:
+            warnings.warn("searchable param is deprecated for Grid(); "
+                          "please use searchable_columns param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('searchable_columns', kwargs.pop('searchable'))
+
+        # TODO: this should not be needed once all templates correctly
+        # reference grid.vue_component etc.
+        kwargs.setdefault('vue_tagname', 'tailbone-grid')
+
+        # nb. these must be set before super init, as they are
+        # referenced when constructing filters
+        self.assume_local_times = assume_local_times
+        self.use_byte_string_filters = use_byte_string_filters
+
+        kwargs['key'] = key
+        kwargs['data'] = data
+        super().__init__(request, **kwargs)
 
         self.model_title = model_title
         if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'):
@@ -99,27 +281,13 @@ class Grid(object):
             if not self.model_title_plural:
                 self.model_title_plural = '{}s'.format(self.model_title)
 
+        self.width = width
         self.enums = enums or {}
-
-        self.labels = labels or {}
-        self.renderers = self.make_default_renderers(renderers or {})
+        self.renderers = self.make_default_renderers(self.renderers)
+        self.raw_renderers = raw_renderers or {}
+        self.invisible = invisible or []
         self.extra_row_class = extra_row_class
-        self.linked_columns = linked_columns or []
         self.url = url
-        self.joiners = joiners or {}
-
-        self.filterable = filterable
-        self.use_byte_string_filters = use_byte_string_filters
-        self.filters = self.make_filters(filters)
-
-        self.sortable = sortable
-        self.sorters = self.make_sorters(sorters)
-        self.default_sortkey = default_sortkey
-        self.default_sortdir = default_sortdir
-
-        self.pageable = pageable
-        self.default_pagesize = default_pagesize
-        self.default_page = default_page
 
         self.checkboxes = checkboxes
         self.checked = checked
@@ -127,46 +295,142 @@ class Grid(object):
             self.checked = lambda item: False
         self.check_handler = check_handler
         self.check_all_handler = check_all_handler
+        self.checkable = checkable
+        self.row_uuid_getter = row_uuid_getter
+        self.clicking_row_checks_box = clicking_row_checks_box
+
+        self.click_handlers = click_handlers or {}
 
-        self.main_actions = main_actions or []
-        self.more_actions = more_actions or []
         self.delete_speedbump = delete_speedbump
 
         if ajax_data_url:
             self.ajax_data_url = ajax_data_url
         elif self.request:
-            self.ajax_data_url = self.request.current_route_url()
+            self.ajax_data_url = self.request.path_url
         else:
             self.ajax_data_url = ''
 
-        self.component = component
+        self.main_actions = main_actions or []
+        if self.main_actions:
+            warnings.warn("main_actions param is deprecated for Grdi(); "
+                          "please use actions param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.actions.extend(self.main_actions)
+        self.more_actions = more_actions or []
+        if self.more_actions:
+            warnings.warn("more_actions param is deprecated for Grdi(); "
+                          "please use actions param instead",
+                          DeprecationWarning, stacklevel=2)
+            self.actions.extend(self.more_actions)
+
+        self.expose_direct_link = expose_direct_link
         self._whgrid_kwargs = kwargs
 
+    @property
+    def component(self):
+        """ """
+        warnings.warn("Grid.component is deprecated; "
+                      "please use vue_tagname instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_tagname
+
     @property
     def component_studly(self):
-        words = self.component.split('-')
-        return ''.join([word.capitalize() for word in words])
+        """ """
+        warnings.warn("Grid.component_studly is deprecated; "
+                      "please use vue_component instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.vue_component
 
-    def make_columns(self):
-        """
-        Return a default list of columns, based on :attr:`model_class`.
-        """
-        if not self.model_class:
-            raise ValueError("Must define model_class to use make_columns()")
+    def get_default_sortkey(self):
+        """ """
+        warnings.warn("Grid.default_sortkey is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            return self.sort_defaults[0].sortkey
 
-        mapper = orm.class_mapper(self.model_class)
-        return [prop.key for prop in mapper.iterate_properties]
+    def set_default_sortkey(self, value):
+        """ """
+        warnings.warn("Grid.default_sortkey is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            info = self.sort_defaults[0]
+            self.sort_defaults[0] = SortInfo(value, info.sortdir)
+        else:
+            self.sort_defaults = [SortInfo(value, 'asc')]
+
+    default_sortkey = property(get_default_sortkey, set_default_sortkey)
+
+    def get_default_sortdir(self):
+        """ """
+        warnings.warn("Grid.default_sortdir is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            return self.sort_defaults[0].sortdir
+
+    def set_default_sortdir(self, value):
+        """ """
+        warnings.warn("Grid.default_sortdir is deprecated; "
+                      "please use Grid.sort_defaults instead",
+                      DeprecationWarning, stacklevel=2)
+        if self.sort_defaults:
+            info = self.sort_defaults[0]
+            self.sort_defaults[0] = SortInfo(info.sortkey, value)
+        else:
+            raise ValueError("cannot set default_sortdir without default_sortkey")
+
+    default_sortdir = property(get_default_sortdir, set_default_sortdir)
+
+    def get_pageable(self):
+        """ """
+        warnings.warn("Grid.pageable is deprecated; "
+                      "please use Grid.paginated instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.paginated
+
+    def set_pageable(self, value):
+        """ """
+        warnings.warn("Grid.pageable is deprecated; "
+                      "please use Grid.paginated instead",
+                      DeprecationWarning, stacklevel=2)
+        self.paginated = value
+
+    pageable = property(get_pageable, set_pageable)
 
     def hide_column(self, key):
-        if key in self.columns:
-            self.columns.remove(key)
+        """
+        This *removes* a column from the grid, altogether.
+
+        This method is deprecated; use :meth:`remove()` instead.
+        """
+        warnings.warn("Grid.hide_column() is deprecated; please use "
+                      "Grid.remove() instead.",
+                      DeprecationWarning, stacklevel=2)
+        self.remove(key)
 
     def hide_columns(self, *keys):
-        for key in keys:
-            self.hide_column(key)
+        """
+        This *removes* columns from the grid, altogether.
 
-    def append(self, field):
-        self.columns.append(field)
+        This method is deprecated; use :meth:`remove()` instead.
+        """
+        self.remove(*keys)
+
+    def set_invisible(self, key, invisible=True):
+        """
+        Mark the given column as "invisible" (but do not remove it).
+
+        Use :meth:`remove()` if you actually want to remove it.
+        """
+        if invisible:
+            if key not in self.invisible:
+                self.invisible.append(key)
+        else:
+            if key in self.invisible:
+                self.invisible.remove(key)
 
     def insert_before(self, field, newfield):
         self.columns.insert_before(field, newfield)
@@ -176,53 +440,81 @@ class Grid(object):
 
     def replace(self, oldfield, newfield):
         self.insert_after(oldfield, newfield)
-        self.hide_column(oldfield)
+        self.remove(oldfield)
 
     def set_joiner(self, key, joiner):
+        """ """
         if joiner is None:
-            self.joiners.pop(key, None)
+            warnings.warn("specifying None is deprecated for Grid.set_joiner(); "
+                          "please use Grid.remove_joiner() instead",
+                          DeprecationWarning, stacklevel=2)
+            self.remove_joiner(key)
         else:
-            self.joiners[key] = joiner
+            super().set_joiner(key, joiner)
 
     def set_sorter(self, key, *args, **kwargs):
-        self.sorters[key] = self.make_sorter(*args, **kwargs)
+        """ """
 
-    def set_sort_defaults(self, sortkey, sortdir='asc'):
-        self.default_sortkey = sortkey
-        self.default_sortdir = sortdir
+        if len(args) == 1:
+            if kwargs:
+                warnings.warn("kwargs are ignored for Grid.set_sorter(); "
+                              "please refactor your code accordingly",
+                              DeprecationWarning, stacklevel=2)
+            if args[0] is None:
+                warnings.warn("specifying None is deprecated for Grid.set_sorter(); "
+                              "please use Grid.remove_sorter() instead",
+                              DeprecationWarning, stacklevel=2)
+                self.remove_sorter(key)
+            else:
+                super().set_sorter(key, args[0])
+
+        elif len(args) == 0:
+            super().set_sorter(key)
+
+        else:
+            warnings.warn("multiple args are deprecated for Grid.set_sorter(); "
+                          "please refactor your code accordingly",
+                          DeprecationWarning, stacklevel=2)
+            self.sorters[key] = self.make_sorter(*args, **kwargs)
 
     def set_filter(self, key, *args, **kwargs):
-        if len(args) == 1 and args[0] is None:
-            self.remove_filter(key)
+        """ """
+        if len(args) == 1:
+            if args[0] is None:
+                warnings.warn("specifying None is deprecated for Grid.set_filter(); "
+                              "please use Grid.remove_filter() instead",
+                              DeprecationWarning, stacklevel=2)
+                self.remove_filter(key)
+                return
+
+        # TODO: our make_filter() signature differs from upstream,
+        # so must call it explicitly instead of delegating to super
+        kwargs.setdefault('label', self.get_label(key))
+        self.filters[key] = self.make_filter(key, *args, **kwargs)
+
+    def set_click_handler(self, key, handler):
+        if handler:
+            self.click_handlers[key] = handler
         else:
-            if 'label' not in kwargs and key in self.labels:
-                kwargs['label'] = self.labels[key]
-            self.filters[key] = self.make_filter(key, *args, **kwargs)
+            self.click_handlers.pop(key, None)
 
-    def remove_filter(self, key):
-        self.filters.pop(key, None)
+    def has_click_handler(self, key):
+        return key in self.click_handlers
 
-    def set_label(self, key, label):
-        self.labels[key] = label
-        if key in self.filters:
-            self.filters[key].label = label
-
-    def get_label(self, key):
+    def set_raw_renderer(self, key, renderer):
         """
-        Returns the label text for given field key.
+        Set or remove the "raw" renderer for the given field.
+
+        See :attr:`raw_renderers` for more about these.
+
+        :param key: Field name.
+
+        :param renderer: Either a renderer callable, or ``None``.
         """
-        return self.labels.get(key, prettify(key))
-
-    def set_link(self, key, link=True):
-        if link:
-            if key not in self.linked_columns:
-                self.linked_columns.append(key)
-        else: # unlink
-            if self.linked_columns and key in self.linked_columns:
-                self.linked_columns.remove(key)
-
-    def set_renderer(self, key, renderer):
-        self.renderers[key] = renderer
+        if renderer:
+            self.raw_renderers[key] = renderer
+        else:
+            self.raw_renderers.pop(key, None)
 
     def set_type(self, key, type_):
         if type_ == 'boolean':
@@ -265,10 +557,29 @@ class Grid(object):
         return pretty_boolean(value)
 
     def obtain_value(self, obj, column_name):
+        """
+        Try to obtain and return the value from the given object, for
+        the given column name.
+
+        :returns: The value, or ``None`` if no value was found.
+        """
+        # TODO: this seems a little hacky, is there a better way?
+        # nb. this may only be relevant for import/export batch view?
+        if isinstance(obj, sa.engine.Row):
+            return obj._mapping[column_name]
+
+        if isinstance(obj, dict):
+            return obj[column_name]
+
+        try:
+            return getattr(obj, column_name)
+        except AttributeError:
+            pass
+
         try:
             return obj[column_name]
         except TypeError:
-            return getattr(obj, column_name)
+            pass
 
     def render_currency(self, obj, column_name):
         value = self.obtain_value(obj, column_name)
@@ -288,7 +599,8 @@ class Grid(object):
         value = self.obtain_value(obj, column_name)
         if value is None:
             return ""
-        value = localtime(self.request.rattail_config, value)
+        app = self.request.rattail_config.get_app()
+        value = app.localtime(value)
         return raw_datetime(self.request.rattail_config, value)
 
     def render_enum(self, obj, column_name):
@@ -297,8 +609,8 @@ class Grid(object):
             return ""
         enum = self.enums.get(column_name)
         if enum and value in enum:
-            return six.text_type(enum[value])
-        return six.text_type(value)
+            return str(enum[value])
+        return str(value)
 
     def render_gpc(self, obj, column_name):
         value = self.obtain_value(obj, column_name)
@@ -307,26 +619,28 @@ class Grid(object):
         return value.pretty()
 
     def render_percent(self, obj, column_name):
+        app = self.request.rattail_config.get_app()
         value = self.obtain_value(obj, column_name)
-        if value is None:
-            return ""
-        return "{:0.3f} %".format(value * 100)
+        return app.render_percent(value, places=3)
 
     def render_quantity(self, obj, column_name):
         value = self.obtain_value(obj, column_name)
-        return pretty_quantity(value)
+        app = self.request.rattail_config.get_app()
+        return app.render_quantity(value)
 
     def render_duration(self, obj, column_name):
-        value = self.obtain_value(obj, column_name)
-        if value is None:
+        seconds = self.obtain_value(obj, column_name)
+        if seconds is None:
             return ""
-        return pretty_hours(datetime.timedelta(seconds=value))
+        app = self.request.rattail_config.get_app()
+        return app.render_duration(seconds=seconds)
 
     def render_duration_hours(self, obj, field):
         value = self.obtain_value(obj, field)
         if value is None:
             return ""
-        return pretty_hours(hours=value)
+        app = self.request.rattail_config.get_app()
+        return app.render_duration(hours=value)
 
     def set_url(self, url):
         self.url = url
@@ -336,49 +650,6 @@ class Grid(object):
             return self.url(obj)
         return self.url
 
-    def make_webhelpers_grid(self):
-        kwargs = dict(self._whgrid_kwargs)
-        kwargs['request'] = self.request
-        kwargs['mobile'] = self.mobile
-        kwargs['url'] = self.make_url
-
-        columns = list(self.columns)
-        column_labels = kwargs.setdefault('column_labels', {})
-        column_formats = kwargs.setdefault('column_formats', {})
-
-        for key, value in self.labels.items():
-            column_labels.setdefault(key, value)
-
-        if self.checkboxes:
-            columns.insert(0, 'checkbox')
-            column_labels['checkbox'] = tags.checkbox('check-all')
-            column_formats['checkbox'] = self.checkbox_column_format
-
-        if self.renderers:
-            kwargs['renderers'] = self.renderers
-        if self.extra_row_class:
-            kwargs['extra_record_class'] = self.extra_row_class
-        if self.linked_columns:
-            kwargs['linked_columns'] = list(self.linked_columns)
-
-        if self.main_actions or self.more_actions:
-            columns.append('actions')
-            column_formats['actions'] = self.actions_column_format
-
-        # TODO: pretty sure this factory doesn't serve all use cases yet?
-        factory = CustomWebhelpersGrid
-        # factory = webhelpers2_grid.Grid
-        if self.sortable:
-            # factory = CustomWebhelpersGrid
-            kwargs['order_column'] = self.sortkey
-            kwargs['order_direction'] = 'dsc' if self.sortdir == 'desc' else 'asc'
-
-        grid = factory(self.make_visible_data(), columns, **kwargs)
-        if self.sortable:
-            grid.exclude_ordering = list([key for key in grid.exclude_ordering
-                                          if key not in self.sorters])
-        return grid
-
     def make_default_renderers(self, renderers):
         """
         Make the default set of column renderers for the grid.
@@ -407,7 +678,10 @@ class Grid(object):
             return self.render_boolean
 
         if isinstance(coltype, sa.DateTime):
-            return self.render_datetime
+            if self.assume_local_times:
+                return self.render_datetime_local
+            else:
+                return self.render_datetime
 
         if isinstance(coltype, GPCType):
             return self.render_gpc
@@ -420,18 +694,13 @@ class Grid(object):
     def actions_column_format(self, column_number, row_number, item):
         return HTML.td(self.render_actions(item, row_number), class_='actions')
 
-    def render_grid(self, template='/grids/grid.mako', **kwargs):
-        context = kwargs
-        context['grid'] = self
-        context['request'] = self.request
-        grid_class = ''
-        if self.width == 'full':
-            grid_class = 'full'
-        elif self.width == 'half':
-            grid_class = 'half'
-        context['grid_class'] = '{} {}'.format(grid_class, context.get('grid_class', ''))
-        context.setdefault('grid_attrs', {})
-        return render(template, context)
+    # TODO: upstream should handle this..
+    def make_backend_filters(self, filters=None):
+        """ """
+        final = self.get_default_filters()
+        if filters:
+            final.update(filters)
+        return final
 
     def get_default_filters(self):
         """
@@ -457,16 +726,6 @@ class Grid(object):
                 filters[prop.key] = self.make_filter(prop.key, column)
         return filters
 
-    def make_filters(self, filters=None):
-        """
-        Returns an initial set of filters which will be available to the grid.
-        The grid itself may or may not provide some default filters, and the
-        ``filters`` kwarg may contain additions and/or overrides.
-        """
-        if filters:
-            return filters
-        return self.get_default_filters()
-
     def make_filter(self, key, column, **kwargs):
         """
         Make a filter suitable for use with the given column.
@@ -478,6 +737,8 @@ class Grid(object):
                 factory = gridfilters.AlchemyStringFilter
             elif isinstance(column.type, sa.Numeric):
                 factory = gridfilters.AlchemyNumericFilter
+            elif isinstance(column.type, sa.BigInteger):
+                factory = gridfilters.AlchemyBigIntegerFilter
             elif isinstance(column.type, sa.Integer):
                 factory = gridfilters.AlchemyIntegerFilter
             elif isinstance(column.type, sa.Boolean):
@@ -486,17 +747,22 @@ class Grid(object):
             elif isinstance(column.type, sa.Date):
                 factory = gridfilters.AlchemyDateFilter
             elif isinstance(column.type, sa.DateTime):
-                factory = gridfilters.AlchemyDateTimeFilter
+                if self.assume_local_times:
+                    factory = gridfilters.AlchemyLocalDateTimeFilter
+                else:
+                    factory = gridfilters.AlchemyDateTimeFilter
             elif isinstance(column.type, GPCType):
                 factory = gridfilters.AlchemyGPCFilter
+        kwargs['column'] = column
+        kwargs.setdefault('config', self.request.rattail_config)
         kwargs.setdefault('encode_values', self.use_byte_string_filters)
-        return factory(key, column=column, config=self.request.rattail_config, **kwargs)
+        return factory(key, **kwargs)
 
     def iter_filters(self):
         """
         Iterate over all filters available to the grid.
         """
-        return six.itervalues(self.filters)
+        return self.filters.values()
 
     def iter_active_filters(self):
         """
@@ -507,66 +773,103 @@ class Grid(object):
             if filtr.active:
                 yield filtr
 
-    def make_sorters(self, sorters=None):
-        """
-        Returns an initial set of sorters which will be available to the grid.
-        The grid itself may or may not provide some default sorters, and the
-        ``sorters`` kwarg may contain additions and/or overrides.
-        """
-        sorters, updates = {}, sorters
-        if self.model_class:
-            mapper = orm.class_mapper(self.model_class)
-            for prop in mapper.iterate_properties:
-                if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
-                    sorters[prop.key] = self.make_sorter(prop)
-        if updates:
-            sorters.update(updates)
-        return sorters
-
-    def make_sorter(self, model_property):
-        """
-        Returns a function suitable for a sort map callable, with typical logic
-        built in for sorting applied to ``field``.
-        """
-        class_ = getattr(model_property, 'class_', self.model_class)
-        column = getattr(class_, model_property.key)
-        return lambda q, d: q.order_by(getattr(column, d)())
-
     def make_simple_sorter(self, key, foldcase=False):
-        """
-        Returns a function suitable for a sort map callable, with typical logic
-        built in for sorting a data set comprised of dicts, on the given key.
-        """
-        if foldcase:
-            keyfunc = lambda v: v[key].lower()
-        else:
-            keyfunc = lambda v: v[key]
-        return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
+        """ """
+        warnings.warn("Grid.make_simple_sorter() is deprecated; "
+                      "please use Grid.make_sorter() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.make_sorter(key, foldcase=foldcase)
 
-    def load_settings(self, store=True):
-        """
-        Load current/effective settings for the grid, from the request query
-        string and/or session storage.  If ``store`` is true, then once
-        settings have been fully read, they are stored in current session for
-        next time.  Finally, various instance attributes of the grid and its
-        filters are updated in-place to reflect the settings; this is so code
-        needn't access the settings dict directly, but the more Pythonic
-        instance attributes.
-        """
+    def get_pagesize_options(self, default=None):
+        """ """
+        # let upstream check config
+        options = super().get_pagesize_options(default=UNSPECIFIED)
+        if options is not UNSPECIFIED:
+            return options
+
+        # fallback to legacy config
+        options = self.config.get_list('tailbone.grid.pagesize_options')
+        if options:
+            warnings.warn("tailbone.grid.pagesize_options setting is deprecated; "
+                          "please set wuttaweb.grids.default_pagesize_options instead",
+                          DeprecationWarning)
+            options = [int(size) for size in options
+                       if size.isdigit()]
+            if options:
+                return options
+
+        if default:
+            return default
+
+        # use upstream default
+        return super().get_pagesize_options()
+
+    def get_pagesize(self, default=None):
+        """ """
+        # let upstream check config
+        pagesize = super().get_pagesize(default=UNSPECIFIED)
+        if pagesize is not UNSPECIFIED:
+            return pagesize
+
+        # fallback to legacy config
+        pagesize = self.config.get_int('tailbone.grid.default_pagesize')
+        if pagesize:
+            warnings.warn("tailbone.grid.default_pagesize setting is deprecated; "
+                          "please use wuttaweb.grids.default_pagesize instead",
+                          DeprecationWarning)
+            return pagesize
+
+        if default:
+            return default
+
+        # use upstream default
+        return super().get_pagesize()
+
+    def get_default_pagesize(self): # pragma: no cover
+        """ """
+        warnings.warn("Grid.get_default_pagesize() method is deprecated; "
+                      "please use Grid.get_pagesize() of Grid.page instead",
+                      DeprecationWarning, stacklevel=2)
+
+        if self.default_pagesize:
+            return self.default_pagesize
+
+        return self.get_pagesize()
+
+    def load_settings(self, **kwargs):
+        """ """
+        if 'store' in kwargs:
+            warnings.warn("the 'store' param is deprecated for load_settings(); "
+                          "please use the 'persist' param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('persist', kwargs.pop('store'))
+
+        persist = kwargs.get('persist', True)
 
         # initial default settings
         settings = {}
         if self.sortable:
-            settings['sortkey'] = self.default_sortkey
-            settings['sortdir'] = self.default_sortdir
-        if self.pageable:
-            settings['pagesize'] = self.default_pagesize
-            settings['page'] = self.default_page
+            if self.sort_defaults:
+                # nb. as of writing neither Buefy nor Oruga support a
+                # multi-column *default* sort; so just use first sorter
+                sortinfo = self.sort_defaults[0]
+                settings['sorters.length'] = 1
+                settings['sorters.1.key'] = sortinfo.sortkey
+                settings['sorters.1.dir'] = sortinfo.sortdir
+            else:
+                settings['sorters.length'] = 0
+        if self.paginated:
+            settings['pagesize'] = self.pagesize
+            settings['page'] = self.page
         if self.filterable:
             for filtr in self.iter_filters():
-                settings['filter.{}.active'.format(filtr.key)] = filtr.default_active
-                settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb
-                settings['filter.{}.value'.format(filtr.key)] = filtr.default_value
+                defaults = self.filter_defaults.get(filtr.key, {})
+                settings[f'filter.{filtr.key}.active'] = defaults.get('active',
+                                                                      filtr.default_active)
+                settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
+                                                                    filtr.default_verb)
+                settings[f'filter.{filtr.key}.value'] = defaults.get('value',
+                                                                     filtr.default_value)
 
         # If user has default settings on file, apply those first.
         if self.user_has_defaults():
@@ -574,25 +877,25 @@ class Grid(object):
 
         # If request contains instruction to reset to default filters, then we
         # can skip the rest of the request/session checks.
-        if self.request.GET.get('reset-to-default-filters') == 'true':
+        if self.request.GET.get('reset-view'):
             pass
 
         # If request has filter settings, grab those, then grab sort/pager
         # settings from request or session.
-        elif self.filterable and self.request_has_settings('filter'):
-            self.update_filter_settings(settings, 'request')
+        elif self.request_has_settings('filter'):
+            self.update_filter_settings(settings, src='request')
             if self.request_has_settings('sort'):
-                self.update_sort_settings(settings, 'request')
+                self.update_sort_settings(settings, src='request')
             else:
-                self.update_sort_settings(settings, 'session')
+                self.update_sort_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # If request has no filter settings but does have sort settings, grab
         # those, then grab filter settings from session, then grab pager
         # settings from request or session.
         elif self.request_has_settings('sort'):
-            self.update_sort_settings(settings, 'request')
-            self.update_filter_settings(settings, 'session')
+            self.update_sort_settings(settings, src='request')
+            self.update_filter_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # NOTE: These next two are functionally equivalent, but are kept
@@ -602,27 +905,27 @@ class Grid(object):
         # grab those, then grab filter/sort settings from session.
         elif self.request_has_settings('page'):
             self.update_page_settings(settings)
-            self.update_filter_settings(settings, 'session')
-            self.update_sort_settings(settings, 'session')
+            self.update_filter_settings(settings, src='session')
+            self.update_sort_settings(settings, src='session')
 
         # If request has no settings, grab all from session.
         elif self.session_has_settings():
-            self.update_filter_settings(settings, 'session')
-            self.update_sort_settings(settings, 'session')
+            self.update_filter_settings(settings, src='session')
+            self.update_sort_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # If no settings were found in request or session, don't store result.
         else:
-            store = False
+            persist = False
             
         # Maybe store settings for next time.
-        if store:
-            self.persist_settings(settings, 'session')
+        if persist:
+            self.persist_settings(settings, dest='session')
 
         # If request contained instruction to save current settings as defaults
         # for the current user, then do that.
         if self.request.GET.get('save-current-filters-as-defaults') == 'true':
-            self.persist_settings(settings, 'defaults')
+            self.persist_settings(settings, dest='defaults')
 
         # update ourself to reflect settings
         if self.filterable:
@@ -631,9 +934,14 @@ class Grid(object):
                 filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
                 filtr.value = settings['filter.{}.value'.format(filtr.key)]
         if self.sortable:
-            self.sortkey = settings['sortkey']
-            self.sortdir = settings['sortdir']
-        if self.pageable:
+            # and self.sort_on_backend:
+            self.active_sorters = []
+            for i in range(1, settings['sorters.length'] + 1):
+                self.active_sorters.append({
+                    'key': settings[f'sorters.{i}.key'],
+                    'dir': settings[f'sorters.{i}.dir'],
+                })
+        if self.paginated:
             self.pagesize = settings['pagesize']
             self.page = settings['page']
 
@@ -651,19 +959,36 @@ class Grid(object):
         # anything...
         session = Session()
         if user not in session:
-            user = session.merge(user)
+            # TODO: pretty sure there is no need to *merge* here..
+            # but we shall see if any breakage happens maybe
+            #user = session.merge(user)
+            user = session.get(user.__class__, user.uuid)
 
-        # User defaults should have all or nothing, so just check one key.
-        key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key)
-        return api.get_setting(session, key) is not None
+        app = self.request.rattail_config.get_app()
+
+        # user defaults should be all or nothing, so just check one key
+        key = f'tailbone.{user.uuid}.grid.{self.key}.sorters.length'
+        if app.get_setting(session, key) is not None:
+            return True
+
+        # TODO: this is deprecated but should work its way out of the
+        # system in a little while (?)..then can remove this entirely
+        key = f'tailbone.{user.uuid}.grid.{self.key}.sortkey'
+        if app.get_setting(session, key) is not None:
+            return True
+
+        return False
 
     def apply_user_defaults(self, settings):
         """
         Update the given settings dict with user defaults, if any exist.
         """
+        app = self.request.rattail_config.get_app()
+        session = Session()
+        prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
+
         def merge(key, normalize=lambda v: v):
-            skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
-            value = api.get_setting(Session(), skey)
+            value = app.get_setting(session, f'{prefix}.{key}')
             settings[key] = normalize(value)
 
         if self.filterable:
@@ -673,35 +998,70 @@ class Grid(object):
                 merge('filter.{}.value'.format(filtr.key))
 
         if self.sortable:
-            merge('sortkey')
-            merge('sortdir')
 
-        if self.pageable:
+            # first clear existing settings for *sorting* only
+            # nb. this is because number of sort settings will vary
+            for key in list(settings):
+                if key.startswith('sorters.'):
+                    del settings[key]
+
+            # check for *deprecated* settings, and use those if present
+            # TODO: obviously should stop this, but must wait until
+            # all old settings have been flushed out.  which in the
+            # case of user-persisted settings, could be a while...
+            sortkey = app.get_setting(session, f'{prefix}.sortkey')
+            if sortkey:
+                settings['sorters.length'] = 1
+                settings['sorters.1.key'] = sortkey
+                settings['sorters.1.dir'] = app.get_setting(session, f'{prefix}.sortdir')
+
+                # nb. re-persist these user settings per new
+                # convention, so deprecated settings go away and we
+                # can remove this logic after a while..
+                app = self.request.rattail_config.get_app()
+                model = app.model
+                prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
+                query = Session.query(model.Setting)\
+                               .filter(sa.or_(
+                                   model.Setting.name.like(f'{prefix}.sorters.%'),
+                                   model.Setting.name == f'{prefix}.sortkey',
+                                   model.Setting.name == f'{prefix}.sortdir'))
+                for setting in query.all():
+                    Session.delete(setting)
+                Session.flush()
+
+                def persist(key):
+                    app.save_setting(Session(),
+                                     f'tailbone.{self.request.user.uuid}.grid.{self.key}.{key}',
+                                     settings[key])
+
+                persist('sorters.length')
+                persist('sorters.1.key')
+                persist('sorters.1.dir')
+
+            else: # the future
+                merge('sorters.length', int)
+                for i in range(1, settings['sorters.length'] + 1):
+                    merge(f'sorters.{i}.key')
+                    merge(f'sorters.{i}.dir')
+
+        if self.paginated:
             merge('pagesize', int)
             merge('page', int)
 
     def request_has_settings(self, type_):
-        """
-        Determine if the current request (GET query string) contains any
-        filter/sort settings for the grid.
-        """
-        if type_ == 'filter':
-            for filtr in self.iter_filters():
-                if filtr.key in self.request.GET:
-                    return True
-            if 'filter' in self.request.GET: # user may be applying empty filters
-                return True
+        """ """
+        if super().request_has_settings(type_):
+            return True
 
-        elif type_ == 'sort':
+        if type_ == 'sort':
+
+            # TODO: remove this eventually, but some links in the wild
+            # may still include these params, so leave it for now
             for key in ['sortkey', 'sortdir']:
                 if key in self.request.GET:
                     return True
 
-        elif type_ == 'page':
-            for key in ['pagesize', 'page']:
-                if key in self.request.GET:
-                    return True
-
         return False
 
     def session_has_settings(self):
@@ -710,153 +1070,77 @@ class Grid(object):
         """
         # session should have all or nothing, so just check a few keys which
         # should be guaranteed present if anything has been stashed
-        for key in ['page', 'sortkey']:
-            if 'grid.{}.{}'.format(self.key, key) in self.request.session:
+        prefix = f'grid.{self.key}'
+        for key in ['page', 'sorters.length']:
+            if f'{prefix}.{key}' in self.request.session:
                 return True
-        return any([key.startswith('grid.{}.filter'.format(self.key)) for key in self.request.session])
+        return any([key.startswith(f'{prefix}.filter')
+                    for key in self.request.session])
 
-    def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
-        """
-        Get the effective value for a particular setting, preferring ``source``
-        but falling back to existing ``settings`` and finally the ``default``.
-        """
-        if source not in ('request', 'session'):
-            raise ValueError("Invalid source identifier: {}".format(source))
+    def persist_settings(self, settings, dest='session'):
+        """ """
+        if dest not in ('defaults', 'session'):
+            raise ValueError(f"invalid dest identifier: {dest}")
 
-        # If source is query string, try that first.
-        if source == 'request':
-            value = self.request.GET.get(key)
-            if value is not None:
-                try:
-                    value = normalize(value)
-                except ValueError:
-                    pass
-                else:
-                    return value
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
-        # Or, if source is session, try that first.
-        else:
-            value = self.request.session.get('grid.{}.{}'.format(self.key, key))
-            if value is not None:
-                return normalize(value)
-
-        # If source had nothing, try default/existing settings.
-        value = settings.get(key)
-        if value is not None:
-            try:
-                value = normalize(value)
-            except ValueError:
-                pass
-            else:
-                return value
-
-        # Okay then, default it is.
-        return default
-
-    def update_filter_settings(self, settings, source):
-        """
-        Updates a settings dictionary according to filter settings data found
-        in either the GET query string, or session storage.
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-
-        :param source: String identifying the source to consult for settings
-           data.  Must be one of: ``('request', 'session')``.
-        """
-        if not self.filterable:
-            return
-
-        for filtr in self.iter_filters():
-            prefix = 'filter.{}'.format(filtr.key)
-
-            if source == 'request':
-                # consider filter active if query string contains a value for it
-                settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
-                settings['{}.verb'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.verb'.format(filtr.key), default='')
-                settings['{}.value'.format(prefix)] = self.get_setting(
-                    source, settings, filtr.key, default='')
-
-            else: # source = session
-                settings['{}.active'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.active'.format(prefix),
-                    normalize=lambda v: six.text_type(v).lower() == 'true', default=False)
-                settings['{}.verb'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.verb'.format(prefix), default='')
-                settings['{}.value'.format(prefix)] = self.get_setting(
-                    source, settings, '{}.value'.format(prefix), default='')
-
-    def update_sort_settings(self, settings, source):
-        """
-        Updates a settings dictionary according to sort settings data found in
-        either the GET query string, or session storage.
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-
-        :param source: String identifying the source to consult for settings
-           data.  Must be one of: ``('request', 'session')``.
-        """
-        if not self.sortable:
-            return
-        settings['sortkey'] = self.get_setting(source, settings, 'sortkey')
-        settings['sortdir'] = self.get_setting(source, settings, 'sortdir')
-
-    def update_page_settings(self, settings):
-        """
-        Updates a settings dictionary according to pager settings data found in
-        either the GET query string, or session storage.
-
-        Note that due to how the actual pager functions, the effective settings
-        will often come from *both* the request and session.  This is so that
-        e.g. the page size will remain constant (coming from the session) while
-        the user jumps between pages (which only provides the single setting).
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-        """
-        if not self.pageable:
-            return
-
-        pagesize = self.request.GET.get('pagesize')
-        if pagesize is not None:
-            if pagesize.isdigit():
-                settings['pagesize'] = int(pagesize)
-        else:
-            pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key))
-            if pagesize is not None:
-                settings['pagesize'] = pagesize
-
-        page = self.request.GET.get('page')
-        if page is not None:
-            if page.isdigit():
-                settings['page'] = int(page)
-        else:
-            page = self.request.session.get('grid.{}.page'.format(self.key))
-            if page is not None:
-                settings['page'] = int(page)
-
-    def persist_settings(self, settings, to='session'):
-        """
-        Persist the given settings in some way, as defined by ``func``.
-        """
-        def persist(key, value=lambda k: settings[k]):
-            if to == 'defaults':
+        def persist(key, value=lambda k: settings.get(k)):
+            if dest == 'defaults':
                 skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
-                api.save_setting(Session(), skey, value(key))
-            else: # to == session
+                app.save_setting(Session(), skey, value(key))
+            else: # dest == session
                 skey = 'grid.{}.{}'.format(self.key, key)
                 self.request.session[skey] = value(key)
 
         if self.filterable:
             for filtr in self.iter_filters():
-                persist('filter.{}.active'.format(filtr.key), value=lambda k: six.text_type(settings[k]).lower())
+                persist('filter.{}.active'.format(filtr.key), value=lambda k: str(settings[k]).lower())
                 persist('filter.{}.verb'.format(filtr.key))
                 persist('filter.{}.value'.format(filtr.key))
 
         if self.sortable:
-            persist('sortkey')
-            persist('sortdir')
 
-        if self.pageable:
+            # first must clear all sort settings from dest. this is
+            # because number of sort settings will vary, so we delete
+            # all and then write all
+
+            if dest == 'defaults':
+                prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
+                query = Session.query(model.Setting)\
+                               .filter(sa.or_(
+                                   model.Setting.name.like(f'{prefix}.sorters.%'),
+                                   # TODO: remove these eventually,
+                                   # but probably should wait until
+                                   # all nodes have been upgraded for
+                                   # (quite) a while?
+                                   model.Setting.name == f'{prefix}.sortkey',
+                                   model.Setting.name == f'{prefix}.sortdir'))
+                for setting in query.all():
+                    Session.delete(setting)
+                Session.flush()
+
+            else: # session
+                # remove sort settings from user session
+                prefix = f'grid.{self.key}'
+                for key in list(self.request.session):
+                    if key.startswith(f'{prefix}.sorters.'):
+                        del self.request.session[key]
+                # TODO: definitely will remove these, but leave for
+                # now so they don't monkey with current user sessions
+                # when next upgrade happens.  so, remove after all are
+                # upgraded
+                self.request.session.pop(f'{prefix}.sortkey', None)
+                self.request.session.pop(f'{prefix}.sortdir', None)
+
+            # now save sort settings to dest
+            if 'sorters.length' in settings:
+                persist('sorters.length')
+                for i in range(1, settings['sorters.length'] + 1):
+                    persist(f'sorters.{i}.key')
+                    persist(f'sorters.{i}.dir')
+
+        if self.paginated:
             persist('pagesize')
             persist('page')
 
@@ -880,86 +1164,38 @@ class Grid(object):
 
         return data
 
-    def sort_data(self, data):
-        """
-        Sort the given query according to current settings, and return the result.
-        """
-        # Cannot sort unless we know which column to sort by.
-        if not self.sortkey:
-            return data
-
-        # Cannot sort unless we have a sort function.
-        sortfunc = self.sorters.get(self.sortkey)
-        if not sortfunc:
-            return data
-
-        # We can provide a default sort direction though.
-        sortdir = getattr(self, 'sortdir', 'asc')
-        if self.sortkey in self.joiners and self.sortkey not in self.joined:
-            data = self.joiners[self.sortkey](data)
-            self.joined.add(self.sortkey)
-        return sortfunc(data, sortdir)
-
-    def paginate_data(self, data):
-        """
-        Paginate the given data set according to current settings, and return
-        the result.
-        """
-        # we of course assume our current page is correct, at first
-        pager = self.make_pager(data)
-
-        # if pager has detected that our current page is outside the valid
-        # range, we must re-orient ourself around the "new" (valid) page
-        if pager.page != self.page:
-            self.page = pager.page
-            self.request.session['grid.{}.page'.format(self.key)] = self.page
-            pager = self.make_pager(data)
-
-        return pager
-
-    def make_pager(self, data):
-        return SqlalchemyOrmPage(data,
-                                 items_per_page=self.pagesize,
-                                 page=self.page,
-                                 url_maker=URLMaker(self.request))
-
     def make_visible_data(self):
-        """
-        Apply various settings to the raw data set, to produce a final data
-        set.  This will page / sort / filter as necessary, according to the
-        grid's defaults and the current request etc.
-        """
-        self.joined = set()
-        data = self.data
-        if self.filterable:
-            data = self.filter_data(data)
-        if self.sortable:
-            data = self.sort_data(data)
-        if self.pageable:
-            self.pager = self.paginate_data(data)
-            data = self.pager
-        return data
+        """ """
+        warnings.warn("grid.make_visible_data() method is deprecated; "
+                      "please use grid.get_visible_data() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.get_visible_data()
+
+    def render_vue_tag(self, master=None, **kwargs):
+        """ """
+        kwargs.setdefault('ref', 'grid')
+        kwargs.setdefault(':csrftoken', 'csrftoken')
+
+        if (master and master.deletable and master.has_perm('delete')
+            and master.delete_confirm == 'simple'):
+            kwargs.setdefault('@deleteActionClicked', 'deleteObject')
+
+        return HTML.tag(self.vue_tagname, **kwargs)
+
+    def render_vue_template(self, template='/grids/complete.mako', **context):
+        """ """
+        return self.render_complete(template=template, **context)
 
     def render_complete(self, template='/grids/complete.mako', **kwargs):
         """
-        Render the complete grid, including filters.
-        """
-        context = kwargs
-        context['grid'] = self
-        context['request'] = self.request
-        context.setdefault('allow_save_defaults', True)
-        return render(template, context)
-
-    def render_buefy(self, template='/grids/buefy.mako', **kwargs):
-        """
-        Render the Buefy grid, complete with filters.  Note that this also
+        Render the grid, complete with filters.  Note that this also
         includes the context menu items and grid tools.
         """
         if 'grid_columns' not in kwargs:
-            kwargs['grid_columns'] = self.get_buefy_columns()
+            kwargs['grid_columns'] = self.get_vue_columns()
 
         if 'grid_data' not in kwargs:
-            kwargs['grid_data'] = self.get_buefy_data()
+            kwargs['grid_data'] = self.get_table_data()
 
         if 'static_data' not in kwargs:
             kwargs['static_data'] = self.has_static_data()
@@ -970,45 +1206,55 @@ class Grid(object):
         if self.filterable and 'filters_sequence' not in kwargs:
             kwargs['filters_sequence'] = self.get_filters_sequence()
 
-        return self.render_complete(template=template, **kwargs)
+        context = kwargs
+        context['grid'] = self
+        context['request'] = self.request
+        context.setdefault('allow_save_defaults', True)
+        context.setdefault('view_click_handler', self.get_view_click_handler())
+        html = render(template, context)
+        return HTML.literal(html)
 
-    def render_buefy_table_element(self, template='/grids/b-table.mako',
-                                   data_prop='gridData', empty_labels=False,
-                                   **kwargs):
+    def render_buefy(self, **kwargs):
+        """ """
+        warnings.warn("Grid.render_buefy() is deprecated; "
+                      "please use Grid.render_complete() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.render_complete(**kwargs)
+
+    def render_table_element(self, template='/grids/b-table.mako',
+                             data_prop='gridData', empty_labels=False,
+                             literal=False,
+                             **kwargs):
         """
         This is intended for ad-hoc "small" grids with static data.  Renders
         just a ``<b-table>`` element instead of the typical "full" grid.
         """
         context = dict(kwargs)
         context['grid'] = self
+        context['request'] = self.request
         context['data_prop'] = data_prop
         context['empty_labels'] = empty_labels
         if 'grid_columns' not in context:
-            context['grid_columns'] = self.get_buefy_columns()
+            context['grid_columns'] = self.get_vue_columns()
         context.setdefault('paginated', False)
         if context['paginated']:
             context.setdefault('per_page', 20)
+        context['view_click_handler'] = self.get_view_click_handler()
+        result = render(template, context)
+        if literal:
+            result = HTML.literal(result)
+        return result
 
+    def get_view_click_handler(self):
+        """ """
         # locate the 'view' action
         # TODO: this should be easier, and/or moved elsewhere?
         view = None
-        for action in self.main_actions:
+        for action in self.actions:
             if action.key == 'view':
-                view = action
-                break
-        if not view:
-            for action in self.more_actions:
-                if action.key == 'view':
-                    view = action
-                    break
+                return getattr(action, 'click_handler', None)
 
-        context['view_click_handler'] = None
-        if view and view.click_handler:
-            context['view_click_handler'] = view.click_handler
-
-        return render(template, context)
-
-    def set_filters_sequence(self, filters):
+    def set_filters_sequence(self, filters, only=False):
         """
         Explicitly set the sequence for grid filters, using the sequence
         provided.  If the grid currently has more filters than are mentioned in
@@ -1016,12 +1262,21 @@ class Grid(object):
         tacked on at the end.
 
         :param filters: Sequence of filter keys, i.e. field names.
+
+        :param only: If true, then *only* those filters specified will
+           be kept, and all others discarded.  If false then any
+           filters not specified will still be tacked onto the end, in
+           alphabetical order.
         """
         new_filters = gridfilters.GridFilterSet()
         for field in filters:
-            new_filters[field] = self.filters.pop(field)
-        for field in self.filters:
-            new_filters[field] = self.filters[field]
+            if field in self.filters:
+                new_filters[field] = self.filters.pop(field)
+            else:
+                log.warning("field '%s' is not in current filter set", field)
+        if not only:
+            for field in sorted(self.filters):
+                new_filters[field] = self.filters[field]
         self.filters = new_filters
 
     def get_filters_sequence(self):
@@ -1033,7 +1288,7 @@ class Grid(object):
 
     def get_filters_data(self):
         """
-        Returns a dict of current filters data, for use with Buefy grid view.
+        Returns a dict of current filters data, for use with index view.
         """
         data = {}
         for filtr in self.filters.values():
@@ -1041,6 +1296,9 @@ class Grid(object):
             valueless = [v for v in filtr.valueless_verbs
                          if v in filtr.verbs]
 
+            multiple_values = [v for v in filtr.multiple_value_verbs
+                               if v in filtr.verbs]
+
             choices = []
             choice_labels = {}
             if filtr.choices:
@@ -1057,9 +1315,10 @@ class Grid(object):
                 'visible': filtr.active,
                 'verbs': filtr.verbs,
                 'valueless_verbs': valueless,
+                'multiple_value_verbs': multiple_values,
                 'verb_labels': filtr.verb_labels,
                 'verb': filtr.verb or filtr.default_verb or filtr.verbs[0],
-                'value': six.text_type(filtr.value) if filtr.value is not None else "",
+                'value': str(filtr.value) if filtr.value is not None else "",
                 'data_type': filtr.data_type,
                 'choices': choices,
                 'choice_labels': choice_labels,
@@ -1067,48 +1326,21 @@ class Grid(object):
 
         return data
 
-    def render_filters(self, template='/grids/filters.mako', **kwargs):
-        """
-        Render the filters to a Unicode string, using the specified template.
-        Additional kwargs are passed along as context to the template.
-        """
-        # Provide default data to filters form, so renderer can do some of the
-        # work for us.
-        data = {}
-        for filtr in self.iter_active_filters():
-            data['{}.active'.format(filtr.key)] = filtr.active
-            data['{}.verb'.format(filtr.key)] = filtr.verb
-            data[filtr.key] = filtr.value
+    def render_actions(self, row, i): # pragma: no cover
+        """ """
+        warnings.warn("grid.render_actions() is deprecated!",
+                      DeprecationWarning, stacklevel=2)
 
-        form = gridfilters.GridFiltersForm(self.filters,
-                                           request=self.request,
-                                           defaults=data)
+        actions = [self.render_action(a, row, i)
+                   for a in self.actions]
+        actions = [a for a in actions if a]
+        return HTML.literal('').join(actions)
 
-        kwargs['request'] = self.request
-        kwargs['grid'] = self
-        kwargs['form'] = form
-        return render(template, kwargs)
+    def render_action(self, action, row, i): # pragma: no cover
+        """ """
+        warnings.warn("grid.render_action() is deprecated!",
+                      DeprecationWarning, stacklevel=2)
 
-    def render_actions(self, row, i):
-        """
-        Returns the rendered contents of the 'actions' column for a given row.
-        """
-        main_actions = [self.render_action(a, row, i)
-                        for a in self.main_actions]
-        main_actions = [a for a in main_actions if a]
-        more_actions = [self.render_action(a, row, i)
-                        for a in self.more_actions]
-        more_actions = [a for a in more_actions if a]
-        if more_actions:
-            icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e')
-            link = tags.link_to("More" + icon, '#', class_='more')
-            main_actions.append(HTML.literal('&nbsp; ') + link + HTML.tag('div', class_='more', c=more_actions))
-        return HTML.literal('').join(main_actions)
-
-    def render_action(self, action, row, i):
-        """
-        Renders an action menu item (link) for the given row.
-        """
         url = action.get_url(row, i)
         if url:
             kwargs = {'class_': action.key, 'target': action.target}
@@ -1142,18 +1374,6 @@ class Grid(object):
         return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
                              checked=self.checked(item))
 
-    def get_pagesize_options(self):
-
-        # use values from config, if defined
-        options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options')
-        if options:
-            options = [int(size) for size in options
-                       if size.isdigit()]
-            if options:
-                return options
-
-        return [5, 10, 20, 50, 100, 200]
-
     def has_static_data(self):
         """
         Should return ``True`` if the grid data can be considered "static"
@@ -1165,34 +1385,76 @@ class Grid(object):
             return True
         return False
 
-    def get_buefy_columns(self):
-        """
-        Return a list of dicts representing all grid columns.  Meant for use
-        with Buefy table.
-        """
-        columns = []
-        for name in self.columns:
-            columns.append({
-                'field': name,
-                'label': self.get_label(name),
-                'sortable': self.sortable and name in self.sorters,
-            })
+    def get_vue_columns(self):
+        """ """
+        columns = super().get_vue_columns()
+
+        for column in columns:
+            column['visible'] = column['field'] not in self.invisible
+
         return columns
 
-    def get_buefy_data(self):
+    def get_table_columns(self):
+        """ """
+        warnings.warn("grid.get_table_columns() method is deprecated; "
+                      "please use grid.get_vue_columns() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.get_vue_columns()
+
+    def get_uuid_for_row(self, rowobj):
+
+        # use custom getter if set
+        if self.row_uuid_getter:
+            return self.row_uuid_getter(rowobj)
+
+        # otherwise fallback to normal uuid, if present
+        if hasattr(rowobj, 'uuid'):
+            return rowobj.uuid
+
+    def get_vue_context(self):
+        """ """
+        return self.get_table_data()
+
+    def get_vue_data(self):
+        """ """
+        table_data = self.get_table_data()
+        return table_data['data']
+
+    def get_table_data(self):
         """
-        Returns a list of data rows for the grid, for use with Buefy table.
+        Returns a list of data rows for the grid, for use with
+        client-side JS table.
         """
+        if hasattr(self, '_table_data'):
+            return self._table_data
+
         # filter / sort / paginate to get "visible" data
-        raw_data = self.make_visible_data()
+        raw_data = self.get_visible_data()
         data = []
         status_map = {}
         checked = []
 
+        # we check for 'all' method and if so, assume we have a Query;
+        # otherwise we assume it's something we can use len() with, which could
+        # be a list or a Paginator
+        if hasattr(raw_data, 'all'):
+            count = raw_data.count()
+        else:
+            count = len(raw_data)
+
         # iterate over data rows
-        for i in range(len(raw_data)):
+        checkable = self.checkboxes and self.checkable and callable(self.checkable)
+        for i in range(count):
             rowobj = raw_data[i]
-            row = {}
+
+            # nb. cache 0-based index on the row, in case client-side
+            # logic finds it useful
+            row = {'_index': i}
+
+            # if grid allows checkboxes, and we have logic to see if
+            # any given row is checkable, add data for that here
+            if checkable:
+                row['_checkable'] = self.checkable(rowobj)
 
             # sometimes we need to include some "raw" data columns in our
             # result set, even though the column is not displayed as part of
@@ -1200,26 +1462,43 @@ class Grid(object):
             # instance, when the "display" version is different than raw data.
             # here is the hack we use for that.
             columns = list(self.columns)
-            if hasattr(self, 'buefy_data_columns'):
-                columns.extend(self.buefy_data_columns)
+            if hasattr(self, 'raw_data_columns'):
+                columns.extend(self.raw_data_columns)
 
             # iterate over data fields
             for name in columns:
 
                 # leverage configured rendering logic where applicable;
                 # otherwise use "raw" data value as string
+                value = self.obtain_value(rowobj, name)
                 if self.renderers and name in self.renderers:
-                    value = self.renderers[name](rowobj, name)
-                else:
-                    value = self.obtain_value(rowobj, name)
+                    renderer = self.renderers[name]
+
+                    # TODO: legacy renderer callables require 2 args,
+                    # but wuttaweb callables require 3 args
+                    sig = inspect.signature(renderer)
+                    required = [param for param in sig.parameters.values()
+                                if param.default == param.empty]
+
+                    if len(required) == 2:
+                        # TODO: legacy renderer
+                        value = renderer(rowobj, name)
+                    else: # the future
+                        value = renderer(rowobj, name, value)
+
                 if value is None:
                     value = ""
-                row[name] = six.text_type(value)
+
+                # this value will ultimately be inserted into table
+                # cell a la <td v-html="..."> so we must escape it
+                # here to be safe
+                row[name] = HTML.literal.escape(value)
 
             # maybe add UUID for convenience
             if 'uuid' not in self.columns:
-                if hasattr(rowobj, 'uuid'):
-                    row['uuid'] = rowobj.uuid
+                uuid = self.get_uuid_for_row(rowobj)
+                if uuid:
+                    row['uuid'] = uuid
 
             # set action URL(s) for row, as needed
             self.set_action_urls(row, rowobj, i)
@@ -1239,6 +1518,8 @@ class Grid(object):
 
         results = {
             'data': data,
+            'row_classes': status_map,
+            # TODO: deprecate / remove this
             'row_status_map': status_map,
         }
 
@@ -1246,141 +1527,84 @@ class Grid(object):
             results['checked_rows'] = checked
             # TODO: this seems a bit hacky, but is required for now to
             # initialize things on the client side...
-            var = '{}CurrentData'.format(self.component_studly)
+            var = '{}CurrentData'.format(self.vue_component)
             results['checked_rows_code'] = '[{}]'.format(
                 ', '.join(['{}[{}]'.format(var, i) for i in checked]))
 
-        if self.pageable and self.pager is not None:
+        if self.paginated and self.paginate_on_backend:
+            results['pager_stats'] = self.get_vue_pager_stats()
+
+        # TODO: is this actually needed now that we have pager_stats?
+        if self.paginated and self.pager is not None:
             results['total_items'] = self.pager.item_count
             results['per_page'] = self.pager.items_per_page
             results['page'] = self.pager.page
             results['pages'] = self.pager.page_count
             results['first_item'] = self.pager.first_item
             results['last_item'] = self.pager.last_item
+        else:
+            results['total_items'] = count
 
-        return results
+        self._table_data = results
+        return self._table_data
+
+    # TODO: remove this when we use upstream GridAction
+    def add_action(self, key, **kwargs):
+        """ """
+        self.actions.append(GridAction(self.request, key, **kwargs))
 
     def set_action_urls(self, row, rowobj, i):
         """
         Pre-generate all action URLs for the given data row.  Meant for use
-        with Buefy table, since we can't generate URLs from JS.
+        with client-side table, since we can't generate URLs from JS.
         """
-        for action in (self.main_actions + self.more_actions):
+        for action in self.actions:
             url = action.get_url(rowobj, i)
             row['_action_url_{}'.format(action.key)] = url
 
-    def is_linked(self, name):
-        """
-        Should return ``True`` if the given column name is configured to be
-        "linked" (i.e. table cell should contain a link to "view object"),
-        otherwise ``False``.
-        """
-        if self.linked_columns:
-            if name in self.linked_columns:
-                return True
-        return False
 
-
-class CustomWebhelpersGrid(webhelpers2_grid.Grid):
+class GridAction(WuttaGridAction):
     """
-    Implement column sorting links etc. for webhelpers2_grid
+    Represents a "row action" hyperlink within a grid context.
+
+    This is a subclass of
+    :class:`wuttaweb:wuttaweb.grids.base.GridAction`.
+
+    .. warning::
+
+       This class remains for now, to retain compatibility with
+       existing code.  But at some point the WuttaWeb class will
+       supersede this one entirely.
+
+    :param target: HTML "target" attribute for the ``<a>`` tag.
+
+    :param click_handler: Optional JS click handler for the action.
+       This value will be rendered as-is within the final grid
+       template, hence the JS string must be callable code.  Note
+       that ``props.row`` will be available in the calling context,
+       so a couple of examples:
+
+       * ``deleteThisThing(props.row)``
+       * ``$emit('do-something', props.row)``
     """
 
-    def __init__(self, itemlist, columns, **kwargs):
-        self.mobile = kwargs.pop('mobile', False)
-        self.renderers = kwargs.pop('renderers', {})
-        self.linked_columns = kwargs.pop('linked_columns', [])
-        self.extra_record_class = kwargs.pop('extra_record_class', None)
-        super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs)
+    def __init__(
+            self,
+            request,
+            key,
+            target=None,
+            click_handler=None,
+            **kwargs,
+    ):
+        # TODO: previously url default was '#' - but i don't think we
+        # need that anymore?  guess we'll see..
+        #kwargs.setdefault('url', '#')
 
-    def default_header_record_format(self, headers):
-        if self.mobile:
-            return HTML('')
-        return super(CustomWebhelpersGrid, self).default_header_record_format(headers)
+        super().__init__(request, key, **kwargs)
 
-    def generate_header_link(self, column_number, column, label_text):
-
-        # display column header as simple no-op link; client-side JS takes care
-        # of the rest for us
-        label_text = tags.link_to(label_text, '#', data_sortkey=column)
-
-        # Is the current column the one we're ordering on?
-        if (column == self.order_column):
-            return self.default_header_ordered_column_format(column_number,
-                                                             column,
-                                                             label_text)
-        else:
-            return self.default_header_column_format(column_number, column,
-                                                     label_text)            
-
-    def default_record_format(self, i, record, columns):
-        if self.mobile:
-            return columns
-        kwargs = {
-            'class_': self.get_record_class(i, record, columns),
-        }
-        if hasattr(record, 'uuid'):
-            kwargs['data_uuid'] = record.uuid
-        return HTML.tag('tr', columns, **kwargs)
-
-    def get_record_class(self, i, record, columns):
-        if i % 2 == 0:
-            cls = 'even r{}'.format(i)
-        else:
-            cls = 'odd r{}'.format(i)
-        if self.extra_record_class:
-            extra = self.extra_record_class(record, i)
-            if extra:
-                cls = '{} {}'.format(cls, extra)
-        return cls
-
-    def get_column_value(self, column_number, i, record, column_name):
-        if self.renderers and column_name in self.renderers:
-            return self.renderers[column_name](record, column_name)
-        try:
-            return record[column_name]
-        except TypeError:
-            return getattr(record, column_name)
-
-    def default_column_format(self, column_number, i, record, column_name):
-        value = self.get_column_value(column_number, i, record, column_name)
-        if self.mobile:
-            url = self.url_generator(record, i)
-            attrs = {}
-            if hasattr(record, 'uuid'):
-                attrs['data_uuid'] = record.uuid
-            return HTML.tag('li', tags.link_to(value, url), **attrs)
-        if self.linked_columns and column_name in self.linked_columns and (
-                value is not None and value != ''):
-            url = self.url_generator(record, i)
-            value = tags.link_to(value, url)
-        class_name = 'c{} {}'.format(column_number, column_name)
-        return HTML.tag('td', value, class_=class_name)
-
-
-class GridAction(object):
-    """
-    Represents an action available to a grid.  This is used to construct the
-    'actions' column when rendering the grid.
-    """
-
-    def __init__(self, key, label=None, url='#', icon=None, target=None,
-                 click_handler=None):
-        self.key = key
-        self.label = label or prettify(key)
-        self.icon = icon
-        self.url = url
         self.target = target
         self.click_handler = click_handler
 
-    def get_url(self, row, i):
-        """
-        Returns an action URL for the given row.
-        """
-        if callable(self.url):
-            return self.url(row, i)
-        return self.url
-
 
 class URLMaker(object):
     """
@@ -1395,5 +1619,5 @@ class URLMaker(object):
         params = self.request.GET.copy()
         params["page"] = page
         params["partial"] = "1"
-        qs = urllib.parse.urlencode(params, True)
+        qs = urlencode(params, True)
         return '{}?{}'.format(self.request.path, qs)
diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py
index 54643c94..7e52bb8d 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,17 +24,15 @@
 Grid Filters
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import re
 import datetime
+import decimal
 import logging
+from collections import OrderedDict
 
-import six
 import sqlalchemy as sa
 
 from rattail.gpc import GPC
-from rattail.util import OrderedDict
 from rattail.core import UNSPECIFIED
 from rattail.time import localtime, make_utc
 from rattail.util import prettify
@@ -76,8 +74,7 @@ class NumericValueRenderer(FilterValueRenderer):
     """
     Input renderer for numeric values.
     """
-    # TODO
-    # data_type = 'number'
+    data_type = 'number'
 
     def render(self, value=None, **kwargs):
         kwargs.setdefault('step', '0.001')
@@ -118,7 +115,7 @@ class EnumValueRenderer(ChoiceValueRenderer):
             sorted_keys = list(enum.keys())
         else:
             sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
-        self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys]
+        self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys]
 
 
 class GridFilter(object):
@@ -130,40 +127,69 @@ class GridFilter(object):
         'is_any':               "is any",
         'equal':                "equal to",
         'not_equal':            "not equal to",
+        'equal_any_of':         "equal to any of",
         'greater_than':         "greater than",
         'greater_equal':        "greater than or equal to",
         'less_than':            "less than",
         'less_equal':           "less than or equal to",
+        'is_empty':             "is empty",
+        'is_not_empty':         "is not empty",
+        'between':              "between",
         'is_null':              "is null",
         'is_not_null':          "is not null",
         'is_true':              "is true",
         'is_false':             "is false",
         'is_false_null':        "is false or null",
+        'is_empty_or_null':     "is either empty or null",
         'contains':             "contains",
         'does_not_contain':     "does not contain",
+        'contains_any_of':      "contains any of",
         'is_me':                "is me",
         'is_not_me':            "is not me",
     }
 
-    valueless_verbs = ['is_any', 'is_null', 'is_not_null', 'is_true', 'is_false',
-                       'is_false_null', 'is_me', 'is_not_me']
+    valueless_verbs = [
+        'is_any',
+        'is_empty',
+        'is_not_empty',
+        'is_null',
+        'is_not_null',
+        'is_true',
+        'is_false',
+        'is_false_null',
+        'is_empty_or_null',
+        'is_me',
+        'is_not_me',
+    ]
+
+    multiple_value_verbs = [
+        'equal_any_of',
+        'contains_any_of',
+    ]
 
     value_renderer_factory = DefaultValueRenderer
     data_type = 'string'        # default, but will be set from value renderer
     choices = {}
 
-    def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
+    def __init__(self, key, config=None, label=None, verbs=None,
+                 value_enum=None, value_renderer=None,
                  default_active=False, default_verb=None, default_value=None,
                  encode_values=False, value_encoding='utf-8', **kwargs):
         self.key = key
+        self.config = config
         self.label = label or prettify(key)
-        self.verbs = verbs or self.get_default_verbs()
+
         if value_renderer:
             self.set_value_renderer(value_renderer)
         elif value_enum:
-            self.set_value_renderer(EnumValueRenderer(value_enum))
+            self.set_choices(value_enum)
         else:
             self.set_value_renderer(self.value_renderer_factory)
+
+        # nb. do this after setting choices, if applicable, since that
+        # could change default verbs
+        self.verbs = verbs or self.get_default_verbs()
+
         self.default_active = default_active
         self.default_verb = default_verb
         self.default_value = default_value
@@ -189,13 +215,13 @@ class GridFilter(object):
             return verbs
         return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
 
-    def set_choices(self, choices):
+    def normalize_choices(self, choices):
         """
-        Set the value choices for the filter, post-construction.  Note that
-        this also will set the value renderer to one which supports choices.
+        Normalize a set of "choices" to a format suitable for use with the
+        filter.
 
-        :param choices: A collection of "choices" for the filter.  This must be
-           in one of the following formats:
+        :param choices: A collection of "choices" in one of the following
+           formats:
 
            * simple list, each value of which should be a string, which is
              assumed to be able to serve as both key and value (ordering of
@@ -205,20 +231,30 @@ class GridFilter(object):
            * OrderedDict, keys and values of which will define the choices
              (ordering of choices will be preserved)
         """
-        # first must normalize choices
         if isinstance(choices, OrderedDict):
             normalized = choices
+
         elif isinstance(choices, dict):
             normalized = OrderedDict([
                 (key, choices[key])
                 for key in sorted(choices)])
+
         elif isinstance(choices, list):
             normalized = OrderedDict([
                 (key, key)
                 for key in choices])
 
-        # store normalized choices, and set renderer
-        self.choices = normalized
+        return normalized
+
+    def set_choices(self, choices):
+        """
+        Set the value choices for the filter.  Note that this also will set the
+        value renderer to one which supports choices.
+
+        :param choices: A collection of "choices" which will be normalized by
+           way of :meth:`normalize_choices()`.
+        """
+        self.choices = self.normalize_choices(choices)
         self.set_value_renderer(ChoiceValueRenderer(self.choices))
 
     def set_value_renderer(self, renderer):
@@ -241,14 +277,15 @@ class GridFilter(object):
         value = self.get_value(value)
         filtr = getattr(self, 'filter_{0}'.format(verb), None)
         if not filtr:
-            raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
+            log.warning("unknown filter verb: %s", verb)
+            return data
         return filtr(data, value)
 
     def get_value(self, value=UNSPECIFIED):
         return value if value is not UNSPECIFIED else self.value
 
     def encode_value(self, value):
-        if self.encode_values and isinstance(value, six.string_types):
+        if self.encode_values and isinstance(value, str):
             return value.encode('utf-8')
         return value
 
@@ -270,18 +307,6 @@ class GridFilter(object):
         return self.value_renderer.render(value=value, **kwargs)
 
 
-class MobileFilter(GridFilter):
-    """
-    Base class for mobile grid filters.
-    """
-    default_verbs = ['equal']
-
-    def __init__(self, key, **kwargs):
-        kwargs.setdefault('default_active', True)
-        kwargs.setdefault('default_verb', 'equal')
-        super(MobileFilter, self).__init__(key, **kwargs)
-
-
 class AlchemyGridFilter(GridFilter):
     """
     Base class for SQLAlchemy grid filters.
@@ -289,7 +314,7 @@ class AlchemyGridFilter(GridFilter):
 
     def __init__(self, *args, **kwargs):
         self.column = kwargs.pop('column')
-        super(AlchemyGridFilter, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
     def filter_equal(self, query, value):
         """
@@ -313,6 +338,38 @@ class AlchemyGridFilter(GridFilter):
             self.column != self.encode_value(value),
         ))
 
+    def filter_equal_any_of(self, query, value):
+        """
+        This filter expects "multiple values" separated by newline
+        character, and will add an "OR" condition with each value
+        being checked separately.  For instance if the user submits a
+        "value" like this:
+
+        .. code-block:: none
+
+           foo bar
+           baz
+
+        This will result in SQL condition like this:
+
+        .. code-block:: sql
+
+           name = 'foo bar' OR name = 'baz'
+        """
+        if not value:
+            return query
+
+        values = value.split('\n')
+        values = [value for value in values if value]
+        if not values:
+            return query
+
+        conditions = []
+        for value in values:
+            conditions.append(self.column == self.encode_value(value))
+
+        return query.filter(sa.or_(*conditions))
+
     def filter_is_null(self, query, value):
         """
         Filter data with an 'IS NULL' query.  Note that this filter does not
@@ -359,6 +416,47 @@ class AlchemyGridFilter(GridFilter):
             return query
         return query.filter(self.column <= self.encode_value(value))
 
+    def filter_between(self, query, value):
+        """
+        Filter data with a "between" query.  Really this uses ">=" and
+        "<=" (inclusive) logic instead of SQL "between" keyword.
+        """
+        if value is None or value == '':
+            return query
+
+        if '|' not in value:
+            return query
+
+        values = value.split('|')
+        if len(values) != 2:
+            return query
+
+        start_value, end_value = values
+
+        # we'll only filter if we have start and/or end value
+        if not start_value and not end_value:
+            return query
+
+        return self.filter_for_range(query, start_value, end_value)
+
+    def filter_for_range(self, query, start_value, end_value):
+        """
+        This method should actually apply filter(s) to the query,
+        according to the given value range.  Subclasses may override
+        this logic.
+        """
+        if start_value:
+            if self.value_invalid(start_value):
+                return query
+            query = query.filter(self.column >= self.encode_value(start_value))
+
+        if end_value:
+            if self.value_invalid(end_value):
+                return query
+            query = query.filter(self.column <= self.encode_value(end_value))
+
+        return query
+
 
 class AlchemyStringFilter(AlchemyGridFilter):
     """
@@ -369,8 +467,17 @@ class AlchemyStringFilter(AlchemyGridFilter):
         """
         Expose contains / does-not-contain verbs in addition to core.
         """
+
+        if self.choices:
+            return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
+
         return ['contains', 'does_not_contain',
-                'equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
+                'contains_any_of',
+                'equal', 'not_equal', 'equal_any_of',
+                'is_empty', 'is_not_empty',
+                'is_null', 'is_not_null',
+                'is_empty_or_null',
+                'is_any']
 
     def filter_contains(self, query, value):
         """
@@ -378,9 +485,13 @@ class AlchemyStringFilter(AlchemyGridFilter):
         """
         if value is None or value == '':
             return query
-        return query.filter(sa.and_(
-            *[self.column.ilike(self.encode_value('%{}%'.format(v)))
-              for v in value.split()]))
+
+        criteria = []
+        for val in value.split():
+            val = val.replace('_', r'\_')
+            val = self.encode_value(f'%{val}%')
+            criteria.append(self.column.ilike(val))
+        return query.filter(sa.and_(*criteria))
 
     def filter_does_not_contain(self, query, value):
         """
@@ -389,14 +500,65 @@ class AlchemyStringFilter(AlchemyGridFilter):
         if value is None or value == '':
             return query
 
+        criteria = []
+        for val in value.split():
+            val = val.replace('_', r'\_')
+            val = self.encode_value(f'%{val}%')
+            criteria.append(~self.column.ilike(val))
+
         # When saying something is 'not like' something else, we must also
         # include things which are nothing at all, in our result set.
         return query.filter(sa.or_(
             self.column == None,
-            sa.and_(
-                *[~self.column.ilike(self.encode_value('%{}%'.format(v)))
-                  for v in value.split()]),
-        ))
+            sa.and_(*criteria)))
+
+    def filter_contains_any_of(self, query, value):
+        """
+        This filter expects "multiple values" separated by newline character,
+        and will add an "OR" condition with each value being checked via
+        "ILIKE".  For instance if the user submits a "value" like this:
+
+        .. code-block:: none
+
+           foo bar
+           baz
+
+        This will result in SQL condition like this:
+
+        .. code-block:: sql
+
+           (name ILIKE '%foo%' AND name ILIKE '%bar%') OR name ILIKE '%baz%'
+        """
+        if not value:
+            return query
+
+        values = value.split('\n')
+        values = [value for value in values if value]
+        if not values:
+            return query
+
+        conditions = []
+        for value in values:
+            criteria = []
+            for val in value.split():
+                val = val.replace('_', r'\_')
+                val = self.encode_value(f'%{val}%')
+                criteria.append(self.column.ilike(val))
+            conditions.append(sa.and_(*criteria))
+
+        return query.filter(sa.or_(*conditions))
+
+    def filter_is_empty(self, query, value):
+        return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))
+
+    def filter_is_not_empty(self, query, value):
+        return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))
+
+    def filter_is_empty_or_null(self, query, value):
+        return query.filter(
+            sa.or_(
+                sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
+                self.column == None))
 
 
 class AlchemyEmptyStringFilter(AlchemyStringFilter):
@@ -408,13 +570,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
         return query.filter(
             sa.or_(
                 self.column == None,
-                sa.func.trim(self.column) == self.encode_value('')))
+                sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')))
 
     def filter_is_not_null(self, query, value):
         return query.filter(
             sa.and_(
                 self.column != None,
-                sa.func.trim(self.column) != self.encode_value('')))
+                sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')))
 
 
 class AlchemyByteStringFilter(AlchemyStringFilter):
@@ -426,8 +588,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
     value_encoding = 'utf-8'
 
     def get_value(self, value=UNSPECIFIED):
-        value = super(AlchemyByteStringFilter, self).get_value(value)
-        if isinstance(value, six.text_type):
+        value = super().get_value(value)
+        if isinstance(value, str):
             value = value.encode(self.value_encoding)
         return value
 
@@ -437,8 +599,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
         """
         if value is None or value == '':
             return query
-        return query.filter(sa.and_(
-            *[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
+
+        criteria = []
+        for val in value.split():
+            val = val.replace('_', r'\_')
+            val = b'%{}%'.format(val)
+            criteria.append(self.column.ilike(val))
+        return query.filters(sa.and_(*criteria))
 
     def filter_does_not_contain(self, query, value):
         """
@@ -447,13 +614,16 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
         if value is None or value == '':
             return query
 
+        for val in value.split():
+            val = val.replace('_', '\_')
+            val = b'%{}%'.format(val)
+            criteria.append(~self.column.ilike(val))
+
         # When saying something is 'not like' something else, we must also
         # include things which are nothing at all, in our result set.
         return query.filter(sa.or_(
             self.column == None,
-            sa.and_(
-                *[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
-        ))
+            sa.and_(*criteria)))
 
 
 class AlchemyNumericFilter(AlchemyGridFilter):
@@ -462,9 +632,11 @@ class AlchemyNumericFilter(AlchemyGridFilter):
     """
     value_renderer_factory = NumericValueRenderer
 
-    # expose greater-than / less-than verbs in addition to core
-    default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
-                     'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any']
+    def default_verbs(self):
+        # expose greater-than / less-than verbs in addition to core
+        return ['equal', 'not_equal', 'greater_than', 'greater_equal',
+                'less_than', 'less_equal', 'between',
+                'is_null', 'is_not_null', 'is_any']
 
     # TODO: what follows "works" in that it prevents an error...but from the
     # user's perspective it still fails silently...need to improve on front-end
@@ -473,43 +645,69 @@ class AlchemyNumericFilter(AlchemyGridFilter):
     # term for integer field...
 
     def value_invalid(self, value):
-        return bool(value and len(six.text_type(value)) > 8)
+
+        # first just make sure it's somewhat numeric
+        try:
+            self.parse_decimal(value)
+        except decimal.InvalidOperation:
+            return True
+
+        return bool(value and len(str(value)) > 8)
+
+    def parse_decimal(self, value):
+        if value:
+            value = value.replace(',', '')
+            return decimal.Decimal(value)
+
+    def encode_value(self, value):
+        if value:
+            value = str(self.parse_decimal(value))
+        return super().encode_value(value)
 
     def filter_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_equal(query, value)
+        return super().filter_equal(query, value)
 
     def filter_not_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
+        return super().filter_not_equal(query, value)
 
     def filter_greater_than(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
+        return super().filter_greater_than(query, value)
 
     def filter_greater_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
+        return super().filter_greater_equal(query, value)
 
     def filter_less_than(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_less_than(query, value)
+        return super().filter_less_than(query, value)
 
     def filter_less_equal(self, query, value):
         if self.value_invalid(value):
             return query
-        return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
+        return super().filter_less_equal(query, value)
 
 
 class AlchemyIntegerFilter(AlchemyNumericFilter):
     """
     Integer filter for SQLAlchemy.
     """
+    bigint = False
+
+    def default_verbs(self):
+
+        # limited verbs if choices are defined
+        if self.choices:
+            return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
+
+        return super().default_verbs()
 
     def value_invalid(self, value):
         if value:
@@ -517,12 +715,25 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
                 return True
             if not value.isdigit():
                 return True
-            # TODO: this one is to avoid DataError from PG, but perhaps that
-            # isn't a good enough reason to make this global logic?
-            if int(value) > 2147483647:
+            # normal Integer columns have a max value, beyond which PG
+            # will throw an error if we try to query for larger values
+            # TODO: this seems hacky, how to better handle it?
+            if not self.bigint and int(value) > 2147483647:
                 return True
         return False
 
+    def encode_value(self, value):
+        # ensure we pass integer value to sqlalchemy, so it does not try to
+        # encode it as a string etc.
+        return int(value)
+
+
+class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
+    """
+    BigInteger filter for SQLAlchemy.
+    """
+    bigint = True
+
 
 class AlchemyBooleanFilter(AlchemyGridFilter):
     """
@@ -602,6 +813,9 @@ class AlchemyDateFilter(AlchemyGridFilter):
         Convert user input to a proper ``datetime.date`` object.
         """
         if value:
+            if isinstance(value, datetime.date):
+                return value
+
             try:
                 dt = datetime.datetime.strptime(value, '%Y-%m-%d')
             except ValueError:
@@ -609,6 +823,48 @@ class AlchemyDateFilter(AlchemyGridFilter):
             else:
                 return dt.date()
 
+    def filter_equal(self, query, value):
+        date = self.make_date(value)
+        if not date:
+            return query
+
+        return query.filter(self.column == self.encode_value(date))
+
+    def filter_not_equal(self, query, value):
+        date = self.make_date(value)
+        if not date:
+            return query
+
+        return query.filter(sa.or_(
+            self.column == None,
+            self.column != self.encode_value(date),
+        ))
+
+    def filter_greater_than(self, query, value):
+        date = self.make_date(value)
+        if not date:
+            return query
+        return query.filter(self.column > self.encode_value(date))
+
+    def filter_greater_equal(self, query, value):
+        date = self.make_date(value)
+        if not date:
+            return query
+        return query.filter(self.column >= self.encode_value(date))
+
+    def filter_less_than(self, query, value):
+        date = self.make_date(value)
+        if not date:
+            return query
+        return query.filter(self.column < self.encode_value(date))
+
+    def filter_less_equal(self, query, value):
+        date = self.make_date(value)
+        if not date:
+            return query
+        return query.filter(self.column <= self.encode_value(date))
+
+    # TODO: this should be merged into parent class
     def filter_between(self, query, value):
         """
         Filter data with a "between" query.  Really this uses ">=" and "<="
@@ -636,6 +892,7 @@ class AlchemyDateFilter(AlchemyGridFilter):
 
         return self.filter_date_range(query, start_date, end_date)
 
+    # TODO: this should be merged into parent class
     def filter_date_range(self, query, start_date, end_date):
         """
         This method should actually apply filter(s) to the query, according to
@@ -853,7 +1110,8 @@ class AlchemyGPCFilter(AlchemyGridFilter):
     """
     GPC filter for SQLAlchemy.
     """
-    default_verbs = ['equal', 'not_equal']
+    default_verbs = ['equal', 'not_equal', 'equal_any_of',
+                     'is_null', 'is_not_null']
 
     def filter_equal(self, query, value):
         """
@@ -895,6 +1153,47 @@ class AlchemyGPCFilter(AlchemyGridFilter):
         except ValueError:
             return query
 
+    def filter_equal_any_of(self, query, value):
+        """
+        This filter expects "multiple values" separated by newline character,
+        and will add an "OR" condition with each value being checked via
+        "ILIKE".  For instance if the user submits a "value" like this:
+
+        .. code-block:: none
+
+           07430500132
+           07430500116
+
+        This will result in SQL condition like this:
+
+        .. code-block:: sql
+
+           (upc IN (7430500132, 74305001321)) OR (upc IN (7430500116, 74305001161))
+        """
+        if not value:
+            return query
+
+        values = value.split('\n')
+        values = [value for value in values if value]
+        if not values:
+            return query
+
+        conditions = []
+        for value in values:
+            try:
+                clause = self.column.in_((
+                    GPC(value),
+                    GPC(value, calc_check_digit='upc')))
+            except ValueError:
+                pass
+            else:
+                conditions.append(clause)
+
+        if not conditions:
+            return query
+
+        return query.filter(sa.or_(*conditions))
+
 
 class AlchemyPhoneNumberFilter(AlchemyStringFilter):
     """
@@ -924,7 +1223,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
         'ILIKE' query with those parts.
         """
         value = self.parse_value(value)
-        return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value)
+        return super().filter_contains(query, value)
 
     def filter_does_not_contain(self, query, value):
         """
@@ -932,7 +1231,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
         'NOT ILIKE' query with those parts.
         """
         value = self.parse_value(value)
-        return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value)
+        return super().filter_does_not_contain(query, value)
 
 
 class GridFilterSet(OrderedDict):
@@ -976,7 +1275,7 @@ class GridFiltersForm(forms.Form):
                 node = colander.SchemaNode(colander.String(), name=key)
                 schema.add(node)
             kwargs['schema'] = schema
-        super(GridFiltersForm, self).__init__(**kwargs)
+        super().__init__(**kwargs)
 
     def iter_filters(self):
         return self.filters.values()
diff --git a/tailbone/handler.py b/tailbone/handler.py
new file mode 100644
index 00000000..00f41bc9
--- /dev/null
+++ b/tailbone/handler.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Tailbone Handler
+"""
+
+import warnings
+
+from mako.lookup import TemplateLookup
+
+from rattail.app import GenericHandler
+from rattail.files import resource_path
+
+from tailbone.providers import get_all_providers
+
+
+class TailboneHandler(GenericHandler):
+    """
+    Base class and default implementation for Tailbone handler.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # TODO: make templates dir configurable?
+        templates = [resource_path('rattail:templates/web')]
+        self.templates = TemplateLookup(directories=templates)
+
+    def get_menu_handler(self, **kwargs):
+        """
+        DEPRECATED; use
+        :meth:`wuttaweb.handler.WebHandler.get_menu_handler()`
+        instead.
+        """
+        warnings.warn("TailboneHandler.get_menu_handler() is deprecated; "
+                      "please use WebHandler.get_menu_handler() instead",
+                      DeprecationWarning, stacklevel=2)
+
+        if not hasattr(self, 'menu_handler'):
+            spec = self.config.get('tailbone.menus', 'handler',
+                                   default='tailbone.menus:MenuHandler')
+            Handler = self.app.load_object(spec)
+            self.menu_handler = Handler(self.config)
+            self.menu_handler.tb = self
+        return self.menu_handler
+
+    def iter_providers(self):
+        """
+        Returns an iterator over all registered Tailbone providers.
+        """
+        providers = get_all_providers(self.config)
+        return providers.values()
+
+    def write_model_view(self, data, path, **kwargs):
+        """
+        Write code for a new model view, based on the given data dict,
+        to the given path.
+        """
+        template = self.templates.get_template('/new-model-view.mako')
+        content = template.render(**data)
+        with open(path, 'wt') as f:
+            f.write(content)
diff --git a/tailbone/helpers.py b/tailbone/helpers.py
index 46a30dec..50b38c30 100644
--- a/tailbone/helpers.py
+++ b/tailbone/helpers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,20 +24,21 @@
 Template Context Helpers
 """
 
-from __future__ import unicode_literals, absolute_import
+# start off with all from wuttaweb
+from wuttaweb.helpers import *
 
+import os
 import datetime
 from decimal import Decimal
+from collections import OrderedDict
 
 from rattail.time import localtime, make_utc
-from rattail.util import (pretty_quantity, pretty_hours, hours_as_decimal,
-                          OrderedDict)
+from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal
 from rattail.db.util import maxlen
 
-from webhelpers2.html import *
-from webhelpers2.html.tags import *
-
-from tailbone.util import csrf_token, get_csrf_token, pretty_datetime, raw_datetime
+from tailbone.util import (pretty_datetime, raw_datetime,
+                           render_markdown,
+                           route_exists)
 
 
 def pretty_date(date):
diff --git a/tailbone/menus.py b/tailbone/menus.py
index f28574bf..09d6f3f0 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,92 +24,749 @@
 App Menus
 """
 
-from __future__ import unicode_literals, absolute_import
+import logging
+import warnings
 
-from rattail.core import Object
-from rattail.util import import_module_path
+from rattail.util import prettify, simple_error
+
+from webhelpers2.html import tags, HTML
+
+from wuttaweb.menus import MenuHandler as WuttaMenuHandler
+
+from tailbone.db import Session
 
 
-class MenuGroup(Object):
-    title = None
-    items = None
-    is_link = False
+log = logging.getLogger(__name__)
 
 
-class MenuItem(Object):
-    title = None
-    url = None
-    target = None
-    is_link = True
-    is_sep = False
-
-
-class MenuSeparator(object):
-    is_sep = True
-
-
-def make_simple_menus(request):
+class TailboneMenuHandler(WuttaMenuHandler):
     """
-    Build the main menu list for the app.
+    Base class and default implementation for menu handler.
     """
-    menus_module = import_module_path(
-        request.rattail_config.require('tailbone', 'menus'))
 
-    if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus):
-        raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module))
+    ##############################
+    # internal methods
+    ##############################
 
-    # collect "simple" menus definition, but must refine that somewhat to
-    # produce our final menus
-    raw_menus = menus_module.simple_menus(request)
-    final_menus = []
-    for topitem in raw_menus:
+    def _is_allowed(self, request, item):
+        """
+        TODO: must override this until wuttaweb has proper user auth checks
+        """
+        perm = item.get('perm')
+        if perm:
+            return request.has_perm(perm)
+        return True
 
-        if topitem.get('type') == 'link':
-            final_menus.append(
-                MenuItem(title=topitem['title'],
-                         url=topitem['url'],
-                         target=topitem.get('target')))
+    def _make_raw_menus(self, request, **kwargs):
+        """
+        We are overriding this to allow for making dynamic menus from
+        config/settings.  Which may or may not be a good idea..
+        """
+        # first try to make menus from config, but this is highly
+        # susceptible to failure, so try to warn user of problems
+        try:
+            menus = self._make_menus_from_config(request)
+            if menus:
+                return menus
+        except Exception as error:
 
-        else: # assuming 'menu' type
+            # TODO: these messages show up multiple times on some pages?!
+            # that must mean the BeforeRender event is firing multiple
+            # times..but why??  seems like there is only 1 request...
+            log.warning("failed to make menus from config", exc_info=True)
+            request.session.flash(simple_error(error), 'error')
+            request.session.flash("Menu config is invalid! Reverting to menus "
+                                  "defined in code!", 'warning')
+            msg = HTML.literal('Please edit your {} ASAP.'.format(
+                tags.link_to("Menu Config", request.route_url('configure_menus'))))
+            request.session.flash(msg, 'warning')
 
-            # figure out which ones the user has permission to access
-            allowed = []
-            for item in topitem['items']:
+        # okay, no config, so menus will be built from code
+        return self.make_menus(request, **kwargs)
 
-                if item.get('type') == 'sep':
-                    allowed.append(item)
+    def _make_menus_from_config(self, request, **kwargs):
+        """
+        Try to build a complete menu set from config/settings.
+
+        This will look in the DB settings table, or config file, for
+        menu data.  If found, it constructs menus from that data.
+        """
+        # bail unless config defines top-level menu keys
+        main_keys = self.config.getlist('tailbone.menu', 'menus')
+        if not main_keys:
+            return
+
+        model = self.app.model
+        menus = []
+
+        # menu definition can come either from config file or db
+        # settings, but if the latter then we want to optimize with
+        # one big query
+        if self.config.getbool('tailbone.menu', 'from_settings',
+                               default=False):
+
+            # fetch all menu-related settings at once
+            query = Session().query(model.Setting)\
+                             .filter(model.Setting.name.like('tailbone.menu.%'))
+            settings = self.app.cache_model(Session(), model.Setting,
+                                            query=query, key='name',
+                                            normalizer=lambda s: s.value)
+            for key in main_keys:
+                menus.append(self._make_single_menu_from_settings(request, key, settings))
+
+        else: # read from config file only
+            for key in main_keys:
+                menus.append(self._make_single_menu_from_config(request, key))
+
+        return menus
+
+    def _make_single_menu_from_config(self, request, key, **kwargs):
+        """
+        Makes a single top-level menu dict from config file.  Note
+        that this will read from config file(s) *only* and avoids
+        querying the database, for efficiency.
+        """
+        menu = {
+            'key': key,
+            'type': 'menu',
+            'items': [],
+        }
+
+        # title
+        title = self.config.get('tailbone.menu',
+                                'menu.{}.label'.format(key),
+                                usedb=False)
+        menu['title'] = title or prettify(key)
+
+        # items
+        item_keys = self.config.getlist('tailbone.menu',
+                                        'menu.{}.items'.format(key),
+                                        usedb=False)
+        for item_key in item_keys:
+            item = {}
+
+            if item_key == 'SEP':
+                item['type'] = 'sep'
+
+            else:
+                item['type'] = 'item'
+                item['key'] = item_key
+
+                # title
+                title = self.config.get('tailbone.menu',
+                                        'menu.{}.item.{}.label'.format(key, item_key),
+                                        usedb=False)
+                item['title'] = title or prettify(item_key)
+
+                # route
+                route = self.config.get('tailbone.menu',
+                                        'menu.{}.item.{}.route'.format(key, item_key),
+                                        usedb=False)
+                if route:
+                    item['route'] = route
+                    item['url'] = request.route_url(route)
 
-                if item.get('perm'):
-                    if request.has_perm(item['perm']):
-                        allowed.append(item)
                 else:
-                    allowed.append(item)
 
-            if allowed:
+                    # url
+                    url = self.config.get('tailbone.menu',
+                                          'menu.{}.item.{}.url'.format(key, item_key),
+                                          usedb=False)
+                    if not url:
+                        url = request.route_url(item_key)
+                    elif url.startswith('route:'):
+                        url = request.route_url(url[6:])
+                    item['url'] = url
 
-                # user must have access to something; construct items for the menu
-                menu_items = []
-                for item in allowed:
+                # perm
+                perm = self.config.get('tailbone.menu',
+                                       'menu.{}.item.{}.perm'.format(key, item_key),
+                                       usedb=False)
+                item['perm'] = perm or '{}.list'.format(item_key)
 
-                    # separator
-                    if item.get('type') == 'sep':
-                        if menu_items and not menu_items[-1].is_sep:
-                            menu_items.append(MenuSeparator())
+            menu['items'].append(item)
 
-                    # menu item
-                    else:
-                        menu_items.append(
-                            MenuItem(title=item['title'],
-                                     url=item['url'],
-                                     target=item.get('target')))
+        return menu
 
-                # remove final separator if present
-                if menu_items and menu_items[-1].is_sep:
-                    menu_items.pop()
+    def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
+        """
+        Makes a single top-level menu dict from DB settings.
+        """
+        menu = {
+            'key': key,
+            'type': 'menu',
+            'items': [],
+        }
 
-                # only add if we wound up with something
-                if menu_items:
-                    final_menus.append(
-                        MenuGroup(title=topitem['title'], items=menu_items))
+        # title
+        title = settings.get('tailbone.menu.menu.{}.label'.format(key))
+        menu['title'] = title or prettify(key)
 
-    return final_menus
+        # items
+        item_keys = self.config.parse_list(
+            settings.get('tailbone.menu.menu.{}.items'.format(key)))
+        for item_key in item_keys:
+            item = {}
+
+            if item_key == 'SEP':
+                item['type'] = 'sep'
+
+            else:
+                item['type'] = 'item'
+                item['key'] = item_key
+
+                # title
+                title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format(
+                    key, item_key))
+                item['title'] = title or prettify(item_key)
+
+                # route
+                route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format(
+                    key, item_key))
+                if route:
+                    item['route'] = route
+                    item['url'] = request.route_url(route)
+
+                else:
+
+                    # url
+                    url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format(
+                        key, item_key))
+                    if not url:
+                        url = request.route_url(item_key)
+                    if url.startswith('route:'):
+                        url = request.route_url(url[6:])
+                    item['url'] = url
+
+                # perm
+                perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format(
+                    key, item_key))
+                item['perm'] = perm or '{}.list'.format(item_key)
+
+            menu['items'].append(item)
+
+        return menu
+
+    ##############################
+    # menu defaults
+    ##############################
+
+    def make_menus(self, request, **kwargs):
+        """
+        Make the full set of menus for the app.
+
+        This method provides a semi-sane menu set by default, but it
+        is expected for most apps to override it.
+        """
+        menus = [
+            self.make_custorders_menu(request),
+            self.make_people_menu(request),
+            self.make_products_menu(request),
+            self.make_vendors_menu(request),
+        ]
+
+        integration_menus = self.make_integration_menus(request)
+        if integration_menus:
+            menus.extend(integration_menus)
+
+        menus.extend([
+            self.make_reports_menu(request, include_trainwreck=True),
+            self.make_batches_menu(request),
+            self.make_admin_menu(request, include_stores=True),
+        ])
+
+        return menus
+
+    def make_integration_menus(self, request, **kwargs):
+        """
+        Make a set of menus for all registered system integrations.
+        """
+        tb = self.app.get_tailbone_handler()
+        menus = []
+        for provider in tb.iter_providers():
+            menu = provider.make_integration_menu(request)
+            if menu:
+                menus.append(menu)
+        menus.sort(key=lambda menu: menu['title'].lower())
+        return menus
+
+    def make_custorders_menu(self, request, **kwargs):
+        """
+        Generate a typical Customer Orders menu
+        """
+        return {
+            'title': "Orders",
+            'type': 'menu',
+            'items': [
+                {
+                    'title': "New Customer Order",
+                    'route': 'custorders.create',
+                    'perm': 'custorders.create',
+                },
+                {
+                    'title': "All New Orders",
+                    'route': 'new_custorders',
+                    'perm': 'new_custorders.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "All Customer Orders",
+                    'route': 'custorders',
+                    'perm': 'custorders.list',
+                },
+                {
+                    'title': "All Order Items",
+                    'route': 'custorders.items',
+                    'perm': 'custorders.items.list',
+                },
+            ],
+        }
+
+    def make_people_menu(self, request, **kwargs):
+        """
+        Generate a typical People menu
+        """
+        return {
+            'title': "People",
+            'type': 'menu',
+            'items': [
+                {
+                    'title': "Members",
+                    'route': 'members',
+                    'perm': 'members.list',
+                },
+                {
+                    'title': "Member Equity Payments",
+                    'route': 'member_equity_payments',
+                    'perm': 'member_equity_payments.list',
+                },
+                {
+                    'title': "Membership Types",
+                    'route': 'membership_types',
+                    'perm': 'membership_types.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Customers",
+                    'route': 'customers',
+                    'perm': 'customers.list',
+                },
+                {
+                    'title': "Customer Shoppers",
+                    'route': 'customer_shoppers',
+                    'perm': 'customer_shoppers.list',
+                },
+                {
+                    'title': "Customer Groups",
+                    'route': 'customergroups',
+                    'perm': 'customergroups.list',
+                },
+                {
+                    'title': "Pending Customers",
+                    'route': 'pending_customers',
+                    'perm': 'pending_customers.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Employees",
+                    'route': 'employees',
+                    'perm': 'employees.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "All People",
+                    'route': 'people',
+                    'perm': 'people.list',
+                },
+            ],
+        }
+
+    def make_products_menu(self, request, **kwargs):
+        """
+        Generate a typical Products menu
+        """
+        return {
+            'title': "Products",
+            'type': 'menu',
+            'items': [
+                {
+                    'title': "Products",
+                    'route': 'products',
+                    'perm': 'products.list',
+                },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
+                {
+                    'title': "Departments",
+                    'route': 'departments',
+                    'perm': 'departments.list',
+                },
+                {
+                    'title': "Subdepartments",
+                    'route': 'subdepartments',
+                    'perm': 'subdepartments.list',
+                },
+                {
+                    'title': "Brands",
+                    'route': 'brands',
+                    'perm': 'brands.list',
+                },
+                {
+                    'title': "Categories",
+                    'route': 'categories',
+                    'perm': 'categories.list',
+                },
+                {
+                    'title': "Families",
+                    'route': 'families',
+                    'perm': 'families.list',
+                },
+                {
+                    'title': "Report Codes",
+                    'route': 'reportcodes',
+                    'perm': 'reportcodes.list',
+                },
+                {
+                    'title': "Units of Measure",
+                    'route': 'uoms',
+                    'perm': 'uoms.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Pending Products",
+                    'route': 'pending_products',
+                    'perm': 'pending_products.list',
+                },
+            ],
+        }
+
+    def make_vendors_menu(self, request, **kwargs):
+        """
+        Generate a typical Vendors menu
+        """
+        return {
+            'title': "Vendors",
+            'type': 'menu',
+            'items': [
+                {
+                    'title': "Vendors",
+                    'route': 'vendors',
+                    'perm': 'vendors.list',
+                },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Ordering",
+                    'route': 'ordering',
+                    'perm': 'ordering.list',
+                },
+                {
+                    'title': "Receiving",
+                    'route': 'receiving',
+                    'perm': 'receiving.list',
+                },
+                {
+                    'title': "Invoice Costing",
+                    'route': 'invoice_costing',
+                    'perm': 'invoice_costing.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Purchases",
+                    'route': 'purchases',
+                    'perm': 'purchases.list',
+                },
+                {
+                    'title': "Credits",
+                    'route': 'purchases.credits',
+                    'perm': 'purchases.credits.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Catalog Batches",
+                    'route': 'vendorcatalogs',
+                    'perm': 'vendorcatalogs.list',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Sample Files",
+                    'route': 'vendorsamplefiles',
+                    'perm': 'vendorsamplefiles.list',
+                },
+            ],
+        }
+
+    def make_batches_menu(self, request, **kwargs):
+        """
+        Generate a typical Batches menu
+        """
+        return {
+            'title': "Batches",
+            'type': 'menu',
+            'items': [
+                {
+                    'title': "Handheld",
+                    'route': 'batch.handheld',
+                    'perm': 'batch.handheld.list',
+                },
+                {
+                    'title': "Inventory",
+                    'route': 'batch.inventory',
+                    'perm': 'batch.inventory.list',
+                },
+                {
+                    'title': "Import / Export",
+                    'route': 'batch.importer',
+                    'perm': 'batch.importer.list',
+                },
+                {
+                    'title': "POS",
+                    'route': 'batch.pos',
+                    'perm': 'batch.pos.list',
+                },
+            ],
+        }
+
+    def make_reports_menu(self, request, **kwargs):
+        """
+        Generate a typical Reports menu
+        """
+        items = [
+            {
+                'title': "New Report",
+                'route': 'report_output.create',
+                'perm': 'report_output.create',
+            },
+            {
+                'title': "Generated Reports",
+                'route': 'report_output',
+                'perm': 'report_output.list',
+            },
+            {
+                'title': "Problem Reports",
+                'route': 'problem_reports',
+                'perm': 'problem_reports.list',
+            },
+        ]
+
+        if kwargs.get('include_poser', False):
+            items.extend([
+                {'type': 'sep'},
+                {
+                    'title': "Poser Reports",
+                    'route': 'poser_reports',
+                    'perm': 'poser_reports.list',
+                },
+            ])
+
+        if kwargs.get('include_worksheets', False):
+            items.extend([
+                {'type': 'sep'},
+                {
+                    'title': "Ordering Worksheet",
+                    'route': 'reports.ordering',
+                },
+                {
+                    'title': "Inventory Worksheet",
+                    'route': 'reports.inventory',
+                },
+            ])
+
+        if kwargs.get('include_trainwreck', False):
+            items.extend([
+                {'type': 'sep'},
+                {
+                    'title': "Trainwreck",
+                    'route': 'trainwreck.transactions',
+                    'perm': 'trainwreck.transactions.list',
+                },
+            ])
+
+        return {
+            'title': "Reports",
+            'type': 'menu',
+            'items': items,
+        }
+
+    def make_tempmon_menu(self, request, **kwargs):
+        """
+        Generate a typical TempMon menu
+        """
+        return {
+            'title': "TempMon",
+            'type': 'menu',
+            'items': [
+                {
+                    'title': "Dashboard",
+                    'route': 'tempmon.dashboard',
+                    'perm': 'tempmon.appliances.dashboard',
+                },
+                {'type': 'sep'},
+                {
+                    'title': "Appliances",
+                    'route': 'tempmon.appliances',
+                    'perm': 'tempmon.appliances.list',
+                },
+                {
+                    'title': "Clients",
+                    'route': 'tempmon.clients',
+                    'perm': 'tempmon.clients.list',
+                },
+                {
+                    'title': "Probes",
+                    'route': 'tempmon.probes',
+                    'perm': 'tempmon.probes.list',
+                },
+                {
+                    'title': "Readings",
+                    'route': 'tempmon.readings',
+                    'perm': 'tempmon.readings.list',
+                },
+            ],
+        }
+
+    def make_admin_menu(self, request, **kwargs):
+        """
+        Generate a typical Admin menu
+        """
+        items = []
+
+        include_stores = kwargs.get('include_stores', True)
+        include_tenders = kwargs.get('include_tenders', True)
+
+        if include_stores or include_tenders:
+
+            if include_stores:
+                items.extend([
+                    {
+                        'title': "Stores",
+                        'route': 'stores',
+                        'perm': 'stores.list',
+                    },
+                ])
+
+            if include_tenders:
+                items.extend([
+                    {
+                        'title': "Tenders",
+                        'route': 'tenders',
+                        'perm': 'tenders.list',
+                    },
+                ])
+
+            items.append({'type': 'sep'})
+
+        items.extend([
+            {
+                'title': "Users",
+                'route': 'users',
+                'perm': 'users.list',
+            },
+            {
+                'title': "Roles",
+                'route': 'roles',
+                'perm': 'roles.list',
+            },
+            {
+                'title': "Raw Permissions",
+                'route': 'permissions',
+                'perm': 'permissions.list',
+            },
+            {'type': 'sep'},
+            {
+                'title': "Email Settings",
+                'route': 'emailprofiles',
+                'perm': 'emailprofiles.list',
+            },
+            {
+                'title': "Email Attempts",
+                'route': 'email_attempts',
+                'perm': 'email_attempts.list',
+            },
+            {'type': 'sep'},
+            {
+                'title': "DataSync Status",
+                'route': 'datasync.status',
+                'perm': 'datasync.status',
+            },
+            {
+                'title': "DataSync Changes",
+                'route': 'datasyncchanges',
+                'perm': 'datasync_changes.list',
+            },
+            {
+                'title': "Importing / Exporting",
+                'route': 'importing',
+                'perm': 'importing.list',
+            },
+            {
+                'title': "Luigi Tasks",
+                'route': 'luigi',
+                'perm': 'luigi.list',
+            },
+            {'type': 'sep'},
+            {
+                'title': "App Info",
+                'route': 'appinfo',
+                'perm': 'appinfo.list',
+            },
+        ])
+
+        if kwargs.get('include_label_settings', False):
+            items.extend([
+                {
+                    'title': "Label Settings",
+                    'route': 'labelprofiles',
+                    'perm': 'labelprofiles.list',
+                },
+            ])
+
+        items.extend([
+            {
+                'title': "Raw Settings",
+                'route': 'settings',
+                'perm': 'settings.list',
+            },
+            {
+                'title': "Upgrades",
+                'route': 'upgrades',
+                'perm': 'upgrades.list',
+            },
+        ])
+
+        return {
+            'title': "Admin",
+            'type': 'menu',
+            'items': items,
+        }
+
+
+class MenuHandler(TailboneMenuHandler):
+
+    def __init__(self, *args, **kwargs):
+        warnings.warn("tailbone.menus.MenuHandler is deprecated; "
+                      "please use tailbone.menus.TailboneMenuHandler instead",
+                      DeprecationWarning, stacklevel=2)
+        super().__init__(*args, **kwargs)
+
+
+class NullMenuHandler(WuttaMenuHandler):
+    """
+    Null menu handler which uses an empty menu set.
+
+    .. note:
+
+       This class shouldn't even exist, but for the moment, it is
+       useful to configure non-traditional (e.g. API) web apps to use
+       this, in order to avoid most of the overhead.
+    """
+
+    def make_menus(self, request, **kwargs):
+        return []
diff --git a/tailbone/progress.py b/tailbone/progress.py
index 90fa21be..5c45f390 100644
--- a/tailbone/progress.py
+++ b/tailbone/progress.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,22 +27,33 @@ Progress Indicator
 from __future__ import unicode_literals, absolute_import
 
 import os
+import warnings
 
 from rattail.progress import ProgressBase
 
 from beaker.session import Session
 
 
+def get_basic_session(config, request={}, **kwargs):
+    """
+    Create/get a "basic" Beaker session object.
+    """
+    kwargs['use_cookies'] = False
+    session = Session(request, **kwargs)
+    return session
+
+
 def get_progress_session(request, key, **kwargs):
     """
     Create/get a Beaker session object, to be used for progress.
     """
-    id = '{}.progress.{}'.format(request.session.id, key)
-    kwargs['use_cookies'] = False
+    kwargs['id'] = '{}.progress.{}'.format(request.session.id, key)
     if kwargs.get('type') == 'file':
+        warnings.warn("Passing a 'type' kwarg to get_progress_session() "
+                      "is deprecated...i think",
+                      DeprecationWarning, stacklevel=2)
         kwargs['data_dir'] = os.path.join(request.rattail_config.appdir(), 'sessions')
-    session = Session(request, id, **kwargs)
-    return session
+    return get_basic_session(request.rattail_config, request, **kwargs)
 
 
 class SessionProgress(ProgressBase):
@@ -52,11 +63,20 @@ class SessionProgress(ProgressBase):
     This class is only responsible for keeping the progress *data* current.  It
     is the responsibility of some client-side AJAX (etc.) to consume the data
     for display to the user.
+
+    :param ws: If true, then websockets are assumed, and the progress will
+       behave accordingly.  The default is false, "traditional" behavior.
     """
 
-    def __init__(self, request, key, session_type=None):
+    def __init__(self, request, key, session_type=None, ws=False):
         self.key = key
-        self.session = get_progress_session(request, key, type=session_type)
+        self.ws = ws
+
+        if self.ws:
+            self.session = get_basic_session(request.rattail_config, id=key)
+        else:
+            self.session = get_progress_session(request, key, type=session_type)
+
         self.canceled = False
         self.clear()
 
diff --git a/tailbone/providers.py b/tailbone/providers.py
new file mode 100644
index 00000000..a538fa73
--- /dev/null
+++ b/tailbone/providers.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Providers for Tailbone features
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from rattail.util import load_entry_points
+
+
+class TailboneProvider(object):
+    """
+    Base class for Tailbone providers.  These are responsible for
+    declaring which things a given project makes available to the app.
+    (Or at least the things which should be easily configurable.)
+    """
+
+    def __init__(self, config):
+        self.config = config
+
+    def configure_db_sessions(self, rattail_config, pyramid_config):
+        pass
+
+    def get_static_includes(self):
+        pass
+
+    def get_provided_views(self):
+        return {}
+
+    def make_integration_menu(self, request, **kwargs):
+        pass
+
+
+def get_all_providers(config):
+    """
+    Returns a dict of all registered providers.
+    """
+    providers = load_entry_points('tailbone.providers')
+    for key in list(providers):
+        providers[key] = providers[key](config)
+    return providers
diff --git a/tailbone/reports/ordering_worksheet.mako b/tailbone/reports/ordering_worksheet.mako
index f6a97dc6..fe3f53e8 100644
--- a/tailbone/reports/ordering_worksheet.mako
+++ b/tailbone/reports/ordering_worksheet.mako
@@ -111,7 +111,7 @@
                     <td class="brand">${cost.product.brand or ''}</td>
                     <td class="desc">${cost.product.description}</td>
                     <td class="size">${cost.product.size or ''}</td>
-                    <td class="case-qty">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td>
+                    <td class="case-qty">${app.render_quantity(cost.case_size)} ${"LB" if cost.product.weighed else "EA"}</td>
                     <td class="code">${cost.code or ''}</td>
                     <td class="preferred">${'X' if cost.preference == 1 else ''}</td>
                     % for i in range(14):
diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py
index 2ad5161a..57700b80 100644
--- a/tailbone/static/__init__.py
+++ b/tailbone/static/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,8 @@
 Static Assets
 """
 
-from __future__ import unicode_literals, absolute_import
-
 
 def includeme(config):
+    config.include('wuttaweb.static')
     config.add_static_view('tailbone', 'tailbone:static')
     config.add_static_view('deform', 'deform:static')
diff --git a/tailbone/static/css/base.css b/tailbone/static/css/base.css
index 689fb000..0fa02dbb 100644
--- a/tailbone/static/css/base.css
+++ b/tailbone/static/css/base.css
@@ -1,122 +1,14 @@
 
-/******************************
- * General
- ******************************/
-
-* {
-    margin: 0px;
-}
-
-body {
-    font-family: Verdana, Arial, sans-serif;
-    font-size: 11pt;
-}
-
-a {
-    color: #0972a5;
-    text-decoration: none;
-}
-
-a:hover {
-    text-decoration: underline;
-}
-
-h1 {
-    margin-bottom: 15px;
-}
-
-h2 {
-    font-size: 12pt;
-    margin: 20px auto 10px auto;
-}
-
-li {
-    line-height: 2em;
-}
-
-p {
-    margin-bottom: 5px;
-}
-
-.left {
-    float: left;
-    text-align: left;
-}
-
-.right {
-    text-align: right;
-}
-
-.wrapper {
-    overflow: auto;
-}
-
-div.buttons {
-    clear: both;
-    margin-top: 10px;
-}
-
-div.dialog {
-    display: none;
-}
-
-div.flash-message {
-    background-color: #dddddd;
-    margin-bottom: 8px;
-    padding: 3px;
-}
-
-div.flash-messages div.ui-state-highlight {
-    padding: .3em;
-    margin-bottom: 8px;
-}
-
-div.error-messages div.ui-state-error {
-    padding: .3em;
-    margin-bottom: 8px;
-}
-
-.flash-messages,
-.error-messages {
-    margin: 0.5em 0 0 0;
-}
-
-ul.error {
-    color: #dd6666;
-    font-weight: bold;
-    padding: 0px;
-}
-
-ul.error li {
-    list-style-type: none;
-}
-
-pre.is-family-sans-serif {
-    background-color: white;
-    font-family: Verdana, Arial, sans-serif;
-    font-size: 11pt;
-    padding: 1em;
-}
-
-/******************************
- * jQuery UI tweaks
- ******************************/
-
-ul.ui-menu {
-    max-height: 30em;
-}
-
 /******************************
  * tweaks for root user
  ******************************/
 
-.menubar .root-user .ui-button-text,
-.menubar .root-user.ui-menu-item a {
+.navbar .navbar-end .navbar-link.root-user,
+.navbar .navbar-end .navbar-link.root-user:hover,
+.navbar .navbar-end .navbar-link.root-user.is_active,
+.navbar .navbar-end .navbar-item.root-user,
+.navbar .navbar-end .navbar-item.root-user:hover,
+.navbar .navbar-end .navbar-item.root-user.is_active {
     background-color: red;
-    color: black;
     font-weight: bold;
 }
-
-.menubar .root-user.ui-menu-item a {
-    padding-left: 1em;
-}
diff --git a/tailbone/static/css/codehilite.css b/tailbone/static/css/codehilite.css
new file mode 100644
index 00000000..a0992759
--- /dev/null
+++ b/tailbone/static/css/codehilite.css
@@ -0,0 +1,74 @@
+pre { line-height: 125%; }
+td.linenos pre { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
+span.linenos { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
+td.linenos pre.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
+span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
+.codehilite .hll { background-color: #ffffcc }
+.codehilite { background: #f8f8f8; }
+.codehilite .c { color: #408080; font-style: italic } /* Comment */
+.codehilite .err { border: 1px solid #FF0000 } /* Error */
+.codehilite .k { color: #008000; font-weight: bold } /* Keyword */
+.codehilite .o { color: #666666 } /* Operator */
+.codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */
+.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */
+.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */
+.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */
+.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */
+.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */
+.codehilite .gd { color: #A00000 } /* Generic.Deleted */
+.codehilite .ge { font-style: italic } /* Generic.Emph */
+.codehilite .gr { color: #FF0000 } /* Generic.Error */
+.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */
+.codehilite .gi { color: #00A000 } /* Generic.Inserted */
+.codehilite .go { color: #888888 } /* Generic.Output */
+.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
+.codehilite .gs { font-weight: bold } /* Generic.Strong */
+.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
+.codehilite .gt { color: #0044DD } /* Generic.Traceback */
+.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
+.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
+.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
+.codehilite .kp { color: #008000 } /* Keyword.Pseudo */
+.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
+.codehilite .kt { color: #B00040 } /* Keyword.Type */
+.codehilite .m { color: #666666 } /* Literal.Number */
+.codehilite .s { color: #BA2121 } /* Literal.String */
+.codehilite .na { color: #7D9029 } /* Name.Attribute */
+.codehilite .nb { color: #008000 } /* Name.Builtin */
+.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */
+.codehilite .no { color: #880000 } /* Name.Constant */
+.codehilite .nd { color: #AA22FF } /* Name.Decorator */
+.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */
+.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
+.codehilite .nf { color: #0000FF } /* Name.Function */
+.codehilite .nl { color: #A0A000 } /* Name.Label */
+.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
+.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */
+.codehilite .nv { color: #19177C } /* Name.Variable */
+.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
+.codehilite .w { color: #bbbbbb } /* Text.Whitespace */
+.codehilite .mb { color: #666666 } /* Literal.Number.Bin */
+.codehilite .mf { color: #666666 } /* Literal.Number.Float */
+.codehilite .mh { color: #666666 } /* Literal.Number.Hex */
+.codehilite .mi { color: #666666 } /* Literal.Number.Integer */
+.codehilite .mo { color: #666666 } /* Literal.Number.Oct */
+.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */
+.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */
+.codehilite .sc { color: #BA2121 } /* Literal.String.Char */
+.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */
+.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
+.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */
+.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
+.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */
+.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
+.codehilite .sx { color: #008000 } /* Literal.String.Other */
+.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */
+.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */
+.codehilite .ss { color: #19177C } /* Literal.String.Symbol */
+.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */
+.codehilite .fm { color: #0000FF } /* Name.Function.Magic */
+.codehilite .vc { color: #19177C } /* Name.Variable.Class */
+.codehilite .vg { color: #19177C } /* Name.Variable.Global */
+.codehilite .vi { color: #19177C } /* Name.Variable.Instance */
+.codehilite .vm { color: #19177C } /* Name.Variable.Magic */
+.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */
diff --git a/tailbone/static/css/filters.css b/tailbone/static/css/filters.css
index c4d59025..72506a06 100644
--- a/tailbone/static/css/filters.css
+++ b/tailbone/static/css/filters.css
@@ -1,28 +1,18 @@
 
 /******************************
- * Filters
+ * Grid Filters
  ******************************/
 
-div.filters form {
-    margin-bottom: 10px;
+.filters .filter-fieldname .field,
+.filters .filter-fieldname .field label {
+    width: 100%;
 }
 
-div.filters div.filter {
-    margin-bottom: 10px;
+.filters .filter-fieldname .field label {
+    justify-content: left;
 }
 
-div.filters div.filter label {
-    margin-right: 8px;
-}
-
-div.filters div.filter select.filter-type {
-    margin-right: 8px;
-}
-
-div.filters div.filter div.value {
-    display: inline;
-}
-
-div.filters div.buttons * {
-    margin-right: 8px;
+.filters .filter-verb .select,
+.filters .filter-verb .select select {
+    width: 100%;
 }
diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css
index 42364b14..de4b1ebe 100644
--- a/tailbone/static/css/forms.css
+++ b/tailbone/static/css/forms.css
@@ -1,34 +1,37 @@
 
 /******************************
- * Form Wrapper
+ * forms
  ******************************/
 
-div.form-wrapper {
-    overflow: auto;
-}
-
-
-/******************************
- * Forms
- ******************************/
-
-div.form,
-div.fieldset-form,
-div.fieldset {
-    clear: left;
-    float: left;
-    margin-top: 10px;
-}
-
+/* note that this should only apply to "normal" primary forms */
+/* TODO: replace this with bulma equivalent */
 .form {
     padding-left: 5em;
 }
 
+/* note that this should only apply to "normal" primary forms */
+.form-wrapper .form .field.is-horizontal .field-label .label {
+    text-align: left;
+    white-space: nowrap;
+    width: 18em;
+}
+
+/* note that this should only apply to "normal" primary forms */
+.form-wrapper .form .field.is-horizontal .field-body {
+    min-width: 30em;
+}
+
+/* note that this should only apply to "normal" primary forms */
+.form-wrapper .form .field.is-horizontal .field-body .select,
+.form-wrapper .form .field.is-horizontal .field-body .select select {
+    width: 100%;
+}
 
 /******************************
- * Fieldsets
+ * field-wrappers
  ******************************/
 
+/* TODO: replace this with bulma equivalent */
 .field-wrapper {
     clear: both;
     min-height: 30px;
@@ -36,16 +39,12 @@ div.fieldset {
     margin: 15px;
 }
 
-.field-wrapper.with-error {
-    background-color: #ddcccc;
-    border: 2px solid #dd6666;
-    padding-bottom: 1em;
-}
-
+/* TODO: replace this with bulma equivalent */
 .field-wrapper .field-row {
     display: table-row;
 }
 
+/* TODO: replace this with bulma equivalent */
 .field-wrapper label {
     display: table-cell;
     vertical-align: top;
@@ -55,47 +54,8 @@ div.fieldset {
     white-space: nowrap;
 }
 
-.field-wrapper.with-error label {
-    padding-left: 1em;
-}
-
-.field-wrapper .field-error {
-    padding: 1em 0 0.5em 1em;
-}
-
-.field-wrapper .field-error .error-msg {
-    color: #dd6666;
-    font-weight: bold;
-}
-
+/* TODO: replace this with bulma equivalent */
 .field-wrapper .field {
     display: table-cell;
     line-height: 25px;
 }
-
-.field-wrapper .field input[type=text],
-.field-wrapper .field input[type=password],
-.field-wrapper .field select,
-.field-wrapper .field textarea {
-    width: 320px;
-}
-
-label input[type="checkbox"],
-label input[type="radio"] {
-    margin-right: 0.5em;
-}
-
-.field ul {
-    margin: 0px;
-    padding-left: 15px;
-}
-
-
-/******************************
- * Buttons
- ******************************/
-
-div.buttons {
-    clear: both;
-    margin: 10px 0px;
-}
diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css
index 3725c8e3..42da832c 100644
--- a/tailbone/static/css/grids.css
+++ b/tailbone/static/css/grids.css
@@ -25,6 +25,11 @@
     margin: 0;
 }
 
+.grid-tools {
+    display: flex;
+    gap: 0.5rem;
+}
+
 .grid-wrapper .grid-header td.tools {
     margin: 0;
     padding: 0;
@@ -261,6 +266,10 @@
  * main actions
  ******************************/
 
+a.grid-action {
+    white-space: nowrap;
+}
+
 .grid .actions {
     width: 1px;
 }
diff --git a/tailbone/static/themes/falafel/css/grids.rowstatus.css b/tailbone/static/css/grids.rowstatus.css
similarity index 95%
rename from tailbone/static/themes/falafel/css/grids.rowstatus.css
rename to tailbone/static/css/grids.rowstatus.css
index 9335b827..bfd73404 100644
--- a/tailbone/static/themes/falafel/css/grids.rowstatus.css
+++ b/tailbone/static/css/grids.rowstatus.css
@@ -2,7 +2,7 @@
 /********************************************************************************
  * grids.rowstatus.css
  *
- * Add "row status" styles for Buefy grid tables.
+ * Add "row status" styles for grid tables.
  ********************************************************************************/
 
 /**************************************************
diff --git a/tailbone/static/css/jquery.loadmask.css b/tailbone/static/css/jquery.loadmask.css
deleted file mode 100644
index 6aa1caa1..00000000
--- a/tailbone/static/css/jquery.loadmask.css
+++ /dev/null
@@ -1,40 +0,0 @@
-.loadmask {
-    z-index: 100;
-    position: absolute;
-    top:0;
-    left:0;
-    -moz-opacity: 0.5;
-    opacity: .50;
-    filter: alpha(opacity=50);
-    background-color: #CCC;
-    width: 100%;
-    height: 100%;
-    zoom: 1;
-}
-.loadmask-msg {
-    z-index: 20001;
-    position: absolute;
-    top: 0;
-    left: 0;
-    border:1px solid #6593cf;
-    background: #c3daf9;
-    padding:2px;
-}
-.loadmask-msg div {
-    padding:5px 10px 5px 25px;
-    background: #fbfbfb url('../img/loading.gif') no-repeat 5px 5px;
-    line-height: 16px;
-	border:1px solid #a3bad9;
-    color:#222;
-    font:normal 11px tahoma, arial, helvetica, sans-serif;
-    cursor:wait;
-}
-.masked {
-    overflow: hidden !important;
-}
-.masked-relative {
-    position: relative !important;
-}
-.masked-hidden {
-    visibility: hidden !important;
-}
\ No newline at end of file
diff --git a/tailbone/static/css/jquery.tagit.css b/tailbone/static/css/jquery.tagit.css
deleted file mode 100644
index f18650d9..00000000
--- a/tailbone/static/css/jquery.tagit.css
+++ /dev/null
@@ -1,69 +0,0 @@
-ul.tagit {
-    padding: 1px 5px;
-    overflow: auto;
-    margin-left: inherit; /* usually we don't want the regular ul margins. */
-    margin-right: inherit;
-}
-ul.tagit li {
-    display: block;
-    float: left;
-    margin: 2px 5px 2px 0;
-}
-ul.tagit li.tagit-choice {    
-    position: relative;
-    line-height: inherit;
-}
-input.tagit-hidden-field {
-    display: none;
-}
-ul.tagit li.tagit-choice-read-only { 
-    padding: .2em .5em .2em .5em; 
-} 
-
-ul.tagit li.tagit-choice-editable { 
-    padding: .2em 18px .2em .5em; 
-} 
-
-ul.tagit li.tagit-new {
-    padding: .25em 4px .25em 0;
-}
-
-ul.tagit li.tagit-choice a.tagit-label {
-    cursor: pointer;
-    text-decoration: none;
-}
-ul.tagit li.tagit-choice .tagit-close {
-    cursor: pointer;
-    position: absolute;
-    right: .1em;
-    top: 50%;
-    margin-top: -8px;
-    line-height: 17px;
-}
-
-/* used for some custom themes that don't need image icons */
-ul.tagit li.tagit-choice .tagit-close .text-icon {
-    display: none;
-}
-
-ul.tagit li.tagit-choice input {
-    display: block;
-    float: left;
-    margin: 2px 5px 2px 0;
-}
-ul.tagit input[type="text"] {
-    -moz-box-sizing:    border-box;
-    -webkit-box-sizing: border-box;
-    box-sizing:         border-box;
-
-    -moz-box-shadow: none;
-    -webkit-box-shadow: none;
-    box-shadow: none;
-
-    border: none;
-    margin: 0;
-    padding: 0;
-    width: inherit;
-    background-color: inherit;
-    outline: none;
-}
diff --git a/tailbone/static/css/jquery.ui.menubar.css b/tailbone/static/css/jquery.ui.menubar.css
deleted file mode 100644
index 8b175f28..00000000
--- a/tailbone/static/css/jquery.ui.menubar.css
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * jQuery UI Menubar @VERSION
- *
- * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
- * Dual licensed under the MIT or GPL Version 2 licenses.
- * http://jquery.org/license
- */
-.ui-menubar { list-style: none; margin: 0; padding-left: 0; }
-
-.ui-menubar-item { float: left; }
-
-.ui-menubar .ui-button { float: left; font-weight: normal; border-top-width: 0 !important; border-bottom-width: 0 !important; margin: 0; outline: none; }
-.ui-menubar .ui-menubar-link { border-right: 1px dashed transparent; border-left: 1px dashed transparent; }
-
-.ui-menubar .ui-menu { width: 200px; position: absolute; z-index: 9999; font-weight: normal; }
diff --git a/tailbone/static/css/jquery.ui.tailbone.css b/tailbone/static/css/jquery.ui.tailbone.css
deleted file mode 100644
index b6ce1023..00000000
--- a/tailbone/static/css/jquery.ui.tailbone.css
+++ /dev/null
@@ -1,14 +0,0 @@
-
-/**********************************************************************
- * jquery.ui.tailbone.css
- *
- * jQuery UI tweaks for Tailbone
- **********************************************************************/
-
-.ui-widget {
-    font-size: 1em;
-}
-
-.ui-menu-item a {
-    display: block;
-}
diff --git a/tailbone/static/css/jquery.ui.timepicker.css b/tailbone/static/css/jquery.ui.timepicker.css
deleted file mode 100644
index b5930fb7..00000000
--- a/tailbone/static/css/jquery.ui.timepicker.css
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Timepicker stylesheet
- * Highly inspired from datepicker
- * FG - Nov 2010 - Web3R 
- *
- * version 0.0.3 : Fixed some settings, more dynamic
- * version 0.0.4 : Removed width:100% on tables
- * version 0.1.1 : set width 0 on tables to fix an ie6 bug
- */
-
-.ui-timepicker-inline { display: inline; }
-
-#ui-timepicker-div { padding: 0.2em; }
-.ui-timepicker-table { display: inline-table; width: 0; }
-.ui-timepicker-table table { margin:0.15em 0 0 0; border-collapse: collapse; }
-
-.ui-timepicker-hours, .ui-timepicker-minutes { padding: 0.2em;  }
-
-.ui-timepicker-table .ui-timepicker-title { line-height: 1.8em; text-align: center; }
-.ui-timepicker-table td { padding: 0.1em; width: 2.2em; }
-.ui-timepicker-table th.periods { padding: 0.1em; width: 2.2em; }
-
-/* span for disabled cells */
-.ui-timepicker-table td span {
-	display:block;
-    padding:0.2em 0.3em 0.2em 0.5em;
-    width: 1.2em;
-
-    text-align:right;
-    text-decoration:none;
-}
-/* anchors for clickable cells */
-.ui-timepicker-table td a {
-    display:block;
-    padding:0.2em 0.3em 0.2em 0.5em;
-    width: 1.2em;
-    cursor: pointer;
-    text-align:right;
-    text-decoration:none;
-}
-
-
-/* buttons and button pane styling */
-.ui-timepicker .ui-timepicker-buttonpane {
-    background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0;
-}
-.ui-timepicker .ui-timepicker-buttonpane button { margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
-/* The close button */
-.ui-timepicker .ui-timepicker-close { float: right }
-
-/* the now button */
-.ui-timepicker .ui-timepicker-now { float: left; }
-
-/* the deselect button */
-.ui-timepicker .ui-timepicker-deselect { float: left; }
-
-
diff --git a/tailbone/static/css/layout.css b/tailbone/static/css/layout.css
index 6c1b926f..ef5c5352 100644
--- a/tailbone/static/css/layout.css
+++ b/tailbone/static/css/layout.css
@@ -1,152 +1,87 @@
 
 /******************************
- * Main Layout
+ * main layout
  ******************************/
 
-html, body, #body-wrapper {
-    height: 100%;
-}
-
-body > #body-wrapper {
-    height: auto;
-    min-height: 100%;
-}
-
-#body-wrapper {
-    margin: 0 1em;
-    width: auto;
-}
-
-#header {
-    height: 50px;
-    line-height: 50px;
-}
-
-#body {
-    padding-top: 10px;
-    padding-bottom: 5em;
-}
-
-#footer {
-    clear: both;
-    margin-top: -4em;
-    text-align: center;
-}
-
-
-/******************************
- * Header
- ******************************/
-
-#header h1 {
-    float: left;
-    font-size: 25px;
-    margin: 0px;
-}
-
-#header div.login {
-    float: right;
-}
-
-/* new stuff from 'better' theme begins here */
-
-header .global {
-    background-color: #eaeaea;
-    height: 60px;
-}
-
-header .global a.home,
-header .global a.global,
-header .global span.global {
-    display: block;
-    float: left;
-    font-size: 2em;
-    font-weight: bold;
-    line-height: 60px;
-    margin-left: 10px;
-}
-
-header .global a.home img {
-    display: block;
-    float: left;
-    padding: 5px 5px 5px 30px;
-}
-
-header .global .grid-nav {
-    display: inline-block;
-    font-size: 16px;
-    font-weight: bold;
-    line-height: 60px;
-    margin-left: 5em;
-}
-
-header .global .grid-nav .ui-button,
-header .global .grid-nav span.viewing {
-    margin-left: 1em;
-}
-
-header .global .feedback {
-    float: right;
-    line-height: 60px;
-    margin-right: 1em;
-}
-
-header .global .after-feedback {
-    float: right;
-    line-height: 60px;
-    margin-right: 1em;
-}
-
-header .page {
-    border-bottom: 1px solid lightgrey;
-    padding: 0.5em;
-}
-
-header .page h1 {
-    margin: 0;
-    padding: 0 0 0 0.5em;
-}
-
-/******************************
- * Logo
- ******************************/
-
-#logo {
-    display: block;
-    margin: 40px auto;
-}
-
-
-/****************************************
- * content
- ****************************************/
-
-body > #body-wrapper {
-    margin: 0px;
-    position: relative;
+body {
+    display: flex;
+    flex-direction: column;
+    min-height: 100vh;
 }
 
 .content-wrapper {
-    height: 100%;
-    padding-bottom: 30px;
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    justify-content: space-between;
 }
 
-#scrollpane {
-    height: 100%;
+
+/******************************
+ * header
+ ******************************/
+
+/* this is the one in the very top left of screen, next to logo and linked to
+the home page */
+#global-header-title {
+    margin-left: 0.3rem;
 }
 
-#scrollpane .inner-content {
-    padding: 0 0.5em 0.5em 0.5em;
+header .level {
+    /* TODO: not sure what this 60px was supposed to do? but it broke the */
+    /* styles for the feedback dialog, so disabled it is.
+    /* height: 60px; */
+    /* line-height: 60px; */
+    padding-left: 0.5em;
+    padding-right: 0.5em;
 }
 
+header .level #header-logo {
+    display: inline-block;
+}
+
+header .level .global-title,
+header .level-left .global-title {
+    font-size: 2em;
+    font-weight: bold;
+}
+
+/* indent nested menu items a bit */
+header .navbar-item.nested {
+    padding-left: 2.5rem;
+}
+
+header span.header-text {
+    font-size: 2em;
+    font-weight: bold;
+    margin-right: 10px;
+}
+
+#content-title h1 {
+    margin-bottom: 0;
+    margin-right: 1rem;
+    max-width: 50%;
+    overflow: hidden;
+    padding: 0 0.3rem;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+/******************************
+ * content
+ ******************************/
+
+#page-body {
+    padding: 0.4em;
+}
 
 /******************************
  * context menu
  ******************************/
 
 #context-menu {
-    list-style-type: none;
-    margin: 0.5em;
+    margin-bottom: 1em;
+    margin-left: 1em;
     text-align: right;
     white-space: nowrap;
 }
@@ -155,11 +90,24 @@ body > #body-wrapper {
  * "object helper" panel
  ******************************/
 
+.object-helpers .panel {
+    margin: 1rem;
+    margin-bottom: 1.5rem;
+}
+
+.object-helpers .panel-heading {
+    white-space: nowrap;
+}
+
+.object-helpers a {
+    white-space: nowrap;
+}
+
 .object-helper {
     border: 1px solid black;
     margin: 1em;
     padding: 1em;
-    min-width: 20em;
+    width: 20em;
 }
 
 .object-helper-content {
@@ -167,87 +115,44 @@ body > #body-wrapper {
 }
 
 /******************************
- * Panels
+ * markdown
  ******************************/
 
-.panel,
-.panel-grid {
-    border-left: 1px solid Black;
-    margin-bottom: 1em;
+.rendered-markdown p,
+.rendered-markdown ul {
+    margin-bottom: 1rem;
 }
 
-.panel {
-    border-bottom: 1px solid Black;
-    border-right: 1px solid Black;
-    padding: 0px;
+.rendered-markdown .codehilite {
+    margin-bottom: 2rem;
 }
 
-.panel h2,
-.panel-grid h2 {
-    border-bottom: 1px solid Black;
-    border-top: 1px solid Black;
-    padding: 5px;
-    margin: 0px;
+/******************************
+ * fix datepicker within modals
+ * TODO: someday this may not be necessary? cf.
+ * https://github.com/buefy/buefy/issues/292#issuecomment-347365637
+ ******************************/
+
+.modal .animation-content .modal-card {
+    overflow: visible !important;
 }
 
-.panel-grid h2 {
-    border-right: 1px solid Black;
+.modal-card-body {
+    overflow: visible !important;
 }
 
-.panel-body {
-    overflow: auto;
-    padding: 5px;
-}
+/* TODO: a simpler option we might try sometime instead?  */
+/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
 
-/****************************************
- * footer
- ****************************************/
-
-#footer {
-    border-top: 1px solid lightgray;
-    bottom: 0;
-    font-size: 9pt;
-    height: 20px;
-    left: 0;
-    line-height: 20px;
-    margin: 0;
-    position: absolute;
-    width: 100%;
-}
+/* .dropdown-content{ */
+/*     position: fixed; */
+/* } */
 
 /******************************
  * feedback
  ******************************/
 
-#feedback-dialog {
-    display: none;
-}
-
-#feedback-dialog p {
-    margin-top: 1em;
-}
-
-#feedback-dialog .red {
+.feedback-dialog .red {
     color: red;
     font-weight: bold;
 }
-
-#feedback-dialog .field-wrapper {
-    margin-top: 1em;
-    padding: 0;
-}
-
-#feedback-dialog .field {
-    margin-bottom: 0;
-    margin-top: 0.5em;
-}
-
-#feedback-dialog .referrer .field {
-    clear: both;
-    float: none;
-    margin-top: 1em;
-}
-
-#feedback-dialog textarea {
-    width: auto;
-}
diff --git a/tailbone/static/css/login.css b/tailbone/static/css/login.css
deleted file mode 100644
index 448f9f70..00000000
--- a/tailbone/static/css/login.css
+++ /dev/null
@@ -1,48 +0,0 @@
-
-/******************************
- * login.css
- ******************************/
-
-.logo img,
-#logo {
-    display: block;
-    margin: 40px auto;
-    max-height: 350px;
-    max-width: 800px;
-}
-
-div.form {
-    margin: auto;
-    float: none;
-    text-align: center;
-}
-
-div.field-wrapper {
-    margin: 10px auto;
-    width: 300px;
-}
-
-div.field-wrapper label {
-    text-align: right;
-    width: auto;
-}
-
-div.field-wrapper div.field input[type="text"],
-div.field-wrapper div.field input[type="password"] {
-    margin-left: 1em;
-    width: 150px;
-}
-
-div.buttons {
-    display: block;
-}
-
-div.buttons input {
-    margin: auto 5px;
-}
-
-/* this is for "login as chuck" tip in demo mode */
-.tips {
-    margin-top: 2em;
-    text-align: center;
-}
diff --git a/tailbone/static/css/mobile.css b/tailbone/static/css/mobile.css
deleted file mode 100644
index 9ebfbc8b..00000000
--- a/tailbone/static/css/mobile.css
+++ /dev/null
@@ -1,57 +0,0 @@
-
-/****************************************
- * Global styles for mobile templates
- ****************************************/
-
-/* main user menu button when root */
-[data-role="header"] a.root-user,
-[data-role="header"] a.root-user:hover {
-    background-color: red;
-}
-
-/* become/stop root menu links */
-#usermenu .root-user a {
-    background-color: red;
-}
-
-/* normal flash messages */
-.flash {
-    color: green;
-    margin-bottom: 1em;
-}
-
-/* error flash messages */
-.error,
-.error-messages {
-    color: red;
-    margin-bottom: 1em;
-}
-
-/* receiving warning flash messages */
-.receiving-warning {
-    color: red;
-}
-
-.replacement-header {
-    display: none;
-}
-
-.field-wrapper.with-error {
-    background-color: #ddcccc;
-    border: 2px solid #dd6666;
-    margin-bottom: 1em;
-}
-
-.field-wrapper label {
-    font-weight: bold;
-    margin-top: 1em;
-}
-
-.field-error .error-msg {
-    color: Red;
-}
-
-/* make sure space comes between simple filter and "grid" list */
-.simple-filter {
-    margin-bottom: 1.5em;
-}
diff --git a/tailbone/static/files/newproduct_template.xlsx b/tailbone/static/files/newproduct_template.xlsx
new file mode 100644
index 00000000..82ce5ff1
Binary files /dev/null and b/tailbone/static/files/newproduct_template.xlsx differ
diff --git a/tailbone/static/files/vendor_catalog_template.xlsx b/tailbone/static/files/vendor_catalog_template.xlsx
new file mode 100644
index 00000000..f68be31d
Binary files /dev/null and b/tailbone/static/files/vendor_catalog_template.xlsx differ
diff --git a/tailbone/static/img/testing.png b/tailbone/static/img/testing.png
index 281ec8cf..7228b334 100644
Binary files a/tailbone/static/img/testing.png and b/tailbone/static/img/testing.png differ
diff --git a/tailbone/static/js/debounce.js b/tailbone/static/js/debounce.js
new file mode 100644
index 00000000..8fea0eda
--- /dev/null
+++ b/tailbone/static/js/debounce.js
@@ -0,0 +1,36 @@
+
+// this code was politely stolen from
+// https://vanillajstoolkit.com/helpers/debounce/
+
+// its purpose is to help with Buefy autocomplete performance
+// https://buefy.org/documentation/autocomplete/
+
+/**
+ * Debounce functions for better performance
+ * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com
+ * @param  {Function} fn The function to debounce
+ */
+function debounce (fn) {
+
+    // Setup a timer
+    let timeout;
+
+    // Return a function to run debounced
+    return function () {
+
+	// Setup the arguments
+	let context = this;
+	let args = arguments;
+
+	// If there's a timer, cancel it
+	if (timeout) {
+	    window.cancelAnimationFrame(timeout);
+	}
+
+	// Setup the new requestAnimationFrame()
+	timeout = window.requestAnimationFrame(function () {
+	    fn.apply(context, args);
+	});
+
+    };
+}
diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js
deleted file mode 100644
index 06904e59..00000000
--- a/tailbone/static/js/jquery.ui.tailbone.js
+++ /dev/null
@@ -1,452 +0,0 @@
-
-/**********************************************************************
- * jQuery UI plugins for Tailbone
- **********************************************************************/
-
-/**********************************************************************
- * gridcore plugin
- **********************************************************************/
-
-(function($) {
-
-    $.widget('tailbone.gridcore', {
-
-        _create: function() {
-
-            var that = this;
-
-            // Add hover highlight effect to grid rows during mouse-over.
-            // this.element.on('mouseenter', 'tbody tr:not(.header)', function() {
-            this.element.on('mouseenter', 'tr:not(.header)', function() {
-                $(this).addClass('hovering');
-            });
-            // this.element.on('mouseleave', 'tbody tr:not(.header)', function() {
-            this.element.on('mouseleave', 'tr:not(.header)', function() {
-                $(this).removeClass('hovering');
-            });
-
-            // do some extra stuff for grids with checkboxes
-
-            // mark rows selected on page load, as needed
-            this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
-                $(this).parents('tr:first').addClass('selected');
-            });
-
-            // (un-)check all rows when clicking check-all box in header
-            if (this.element.find('tr.header td.checkbox :checkbox').length) {
-                this.element.on('click', 'tr.header td.checkbox :checkbox', function() {
-                    var checked = $(this).prop('checked');
-                    var rows = that.element.find('tr:not(.header)');
-                    rows.find('td.checkbox :checkbox').prop('checked', checked);
-                    if (checked) {
-                        rows.addClass('selected');
-                    } else {
-                        rows.removeClass('selected');
-                    }
-                    that.element.trigger('gridchecked', that.count_selected());
-                });
-            }
-
-            // when row with checkbox is clicked, toggle selected status,
-            // unless clicking checkbox (since that already toggles it) or a
-            // link (since that does something completely different)
-            this.element.on('click', 'tr:not(.header)', function(event) {
-                var el = $(event.target);
-                if (!el.is('a') && !el.is(':checkbox')) {
-                    $(this).find('td.checkbox :checkbox').click();
-                }
-            });
-
-            this.element.on('change', 'tr:not(.header) td.checkbox :checkbox', function() {
-                if (this.checked) {
-                    $(this).parents('tr:first').addClass('selected');
-                } else {
-                    $(this).parents('tr:first').removeClass('selected');
-                }
-                that.element.trigger('gridchecked', that.count_selected());
-            });
-
-            // Show 'more' actions when user hovers over 'more' link.
-            this.element.on('mouseenter', '.actions a.more', function() {
-                that.element.find('.actions div.more').hide();
-                $(this).siblings('div.more')
-                    .show()
-                    .position({my: 'left-5 top-4', at: 'left top', of: $(this)});
-            });
-            this.element.on('mouseleave', '.actions div.more', function() {
-                $(this).hide();
-            });
-
-            // Add speed bump for "Delete Row" action, if grid is so configured.
-            if (this.element.data('delete-speedbump')) {
-                this.element.on('click', 'tr:not(.header) .actions a.delete', function() {
-                    return confirm("Are you sure you wish to delete this object?");
-                });
-            }
-        },
-
-        count_selected: function() {
-            return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').length;
-        },
-
-        // TODO: deprecate / remove this?
-        count_checked: function() {
-            return this.count_selected();
-        },
-
-        selected_rows: function() {
-            return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').parents('tr:first');
-        },
-
-        all_uuids: function() {
-            var uuids = [];
-            this.element.find('tr:not(.header)').each(function() {
-                uuids.push($(this).data('uuid'));
-            });
-            return uuids;
-        },
-
-        selected_uuids: function() {
-            var uuids = [];
-            this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
-                uuids.push($(this).parents('tr:first').data('uuid'));
-            });
-            return uuids;
-        }
-
-    });
-
-})( jQuery );
-
-
-/**********************************************************************
- * gridwrapper plugin
- **********************************************************************/
-
-(function($) {
-    
-    $.widget('tailbone.gridwrapper', {
-
-        _create: function() {
-
-            var that = this;
-
-            // Snag some element references.
-            this.filters = this.element.find('.newfilters');
-            this.filters_form = this.filters.find('form');
-            this.add_filter = this.filters.find('#add-filter');
-            this.apply_filters = this.filters.find('#apply-filters');
-            this.default_filters = this.filters.find('#default-filters');
-            this.clear_filters = this.filters.find('#clear-filters');
-            this.save_defaults = this.filters.find('#save-defaults');
-            this.grid = this.element.find('.grid');
-
-            // add standard grid behavior
-            this.grid.gridcore();
-
-            // Enhance filters etc.
-            this.filters.find('.filter').gridfilter();
-            this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'});
-            this.default_filters.button('option', 'icons', {primary: 'ui-icon-home'});
-            this.clear_filters.button('option', 'icons', {primary: 'ui-icon-trash'});
-            this.save_defaults.button('option', 'icons', {primary: 'ui-icon-disk'});
-            if (! this.filters.find('.active:checked').length) {
-                this.apply_filters.button('disable');
-            }
-            this.add_filter.selectmenu({
-                width: '15em',
-
-                // Initially disabled if contains no enabled filter options.
-                disabled: this.add_filter.find('option:enabled').length == 1,
-
-                // When add-filter choice is made, show/focus new filter value input,
-                // and maybe hide the add-filter selection or show the apply button.
-                change: function (event, ui) {
-                    var filter = that.filters.find('#filter-' + ui.item.value);
-                    var select = $(this);
-                    var option = ui.item.element;
-                    filter.gridfilter('active', true);
-                    filter.gridfilter('focus');
-                    select.val('');
-                    option.attr('disabled', 'disabled');
-                    select.selectmenu('refresh');
-                    if (select.find('option:enabled').length == 1) { // prompt is always enabled
-                        select.selectmenu('disable');
-                    }
-                    that.apply_filters.button('enable');
-                }
-            });
-
-            this.add_filter.on('selectmenuopen', function(event, ui) {
-                show_all_options($(this));
-            });
-
-            // Intercept filters form submittal, and submit via AJAX instead.
-            this.filters_form.on('submit', function() {
-                var settings = {filter: true, partial: true};
-                if (that.filters_form.find('input[name="save-current-filters-as-defaults"]').val() == 'true') {
-                    settings['save-current-filters-as-defaults'] = true;
-                }
-                that.filters.find('.filter').each(function() {
-
-                    // currently active filters will be included in form data
-                    if ($(this).gridfilter('active')) {
-                        settings[$(this).data('key')] = $(this).gridfilter('value');
-                        settings[$(this).data('key') + '.verb'] = $(this).gridfilter('verb');
-
-                    // others will be hidden from view
-                    } else {
-                        $(this).gridfilter('hide');
-                    }
-                });
-
-                // if no filters are visible, disable submit button
-                if (! that.filters.find('.filter:visible').length) {
-                    that.apply_filters.button('disable');
-                }
-
-                // okay, submit filters to server and refresh grid
-                that.refresh(settings);
-                return false;
-            });
-
-            // When user clicks Default Filters button, refresh page with
-            // instructions for the server to reset filters to default settings.
-            this.default_filters.click(function() {
-                that.filters_form.off('submit');
-                that.filters_form.find('input[name="reset-to-default-filters"]').val('true');
-                that.element.mask("Refreshing data...");
-                that.filters_form.get(0).submit();
-            });
-
-            // When user clicks Save Defaults button, refresh the grid as with
-            // Apply Filters, but add an instruction for the server to save
-            // current settings as defaults for the user.
-            this.save_defaults.click(function() {
-                that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('true');
-                that.filters_form.submit();
-                that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('false');
-            });
-
-            // When user clicks Clear Filters button, deactivate all filters
-            // and refresh the grid.
-            this.clear_filters.click(function() {
-                that.filters.find('.filter').each(function() {
-                    if ($(this).gridfilter('active')) {
-                        $(this).gridfilter('active', false);
-                    }
-                });
-                that.filters_form.submit();
-            });
-
-            // Refresh data when user clicks a sortable column header.
-            this.element.on('click', 'tr.header a', function() {
-                var td = $(this).parent();
-                var data = {
-                    sortkey: $(this).data('sortkey'),
-                    sortdir: (td.hasClass('asc')) ? 'desc' : 'asc',
-                    page: 1,
-                    partial: true
-                };
-                that.refresh(data);
-                return false;
-            });
-
-            // Refresh data when user chooses a new page size setting.
-            this.element.on('change', '.pager #pagesize', function() {
-                var settings = {
-                    partial: true,
-                    pagesize: $(this).val()
-                };
-                that.refresh(settings);
-            });
-
-            // Refresh data when user clicks a pager link.
-            this.element.on('click', '.pager a', function() {
-                that.refresh(this.search.substring(1)); // remove leading '?'
-                return false;
-            });
-        },
-
-        // Refreshes the visible data within the grid, according to the given settings.
-        refresh: function(settings) {
-            var that = this;
-            this.element.mask("Refreshing data...");
-            $.get(this.grid.data('url'), settings, function(data) {
-                that.grid.replaceWith(data);
-                that.grid = that.element.find('.grid');
-                that.grid.gridcore();
-                that.element.unmask();
-            });
-        },
-
-        results_count: function(as_text) {
-            var count = null;
-            var match = /showing \d+ thru \d+ of (\S+)/.exec(this.element.find('.pager .showing').text());
-            if (match) {
-                count = match[1];
-                if (!as_text) {
-                    count = parseInt(count, 10);
-                }
-            }
-            return count;
-        },
-
-        all_uuids: function() {
-            return this.grid.gridcore('all_uuids');
-        },
-
-        selected_uuids: function() {
-            return this.grid.gridcore('selected_uuids');
-        }
-
-    });
-    
-})( jQuery );
-
-
-/**********************************************************************
- * gridfilter plugin
- **********************************************************************/
-
-(function($) {
-    
-    $.widget('tailbone.gridfilter', {
-
-        _create: function() {
-
-            var that = this;
-
-            // Track down some important elements.
-            this.checkbox = this.element.find('input[name$="-active"]');
-            this.label = this.element.find('label');
-            this.inputs = this.element.find('.inputs');
-            this.add_filter = this.element.parents('.grid-wrapper').find('#add-filter');
-
-            // Hide the checkbox and label, and add button for toggling active status.
-            this.checkbox.addClass('ui-helper-hidden-accessible');
-            this.label.hide();
-            this.activebutton = $('<button type="button" class="toggle" />')
-                .insertAfter(this.label)
-                .text(this.label.text())
-                .button({
-                    icons: {primary: 'ui-icon-blank'}
-                });
-
-            // Enhance verb dropdown as selectmenu.
-            this.verb_select = this.inputs.find('.verb');
-            this.valueless_verbs = {};
-            $.each(this.verb_select.data('hide-value-for').split(' '), function(index, value) {
-                that.valueless_verbs[value] = true;
-            });
-            this.verb_select.selectmenu({
-                width: '15em',
-                change: function(event, ui) {
-                    if (ui.item.value in that.valueless_verbs) {
-                        that.inputs.find('.value').hide();
-                    } else {
-                        that.inputs.find('.value').show();
-                        that.focus();
-                        that.select();
-                    }
-                }
-            });
-
-            this.verb_select.on('selectmenuopen', function(event, ui) {
-                show_all_options($(this));
-            });
-
-            // Enhance any date values with datepicker widget.
-            this.inputs.find('.value input[data-datepicker="true"]').datepicker({
-                dateFormat: 'yy-mm-dd',
-                changeYear: true,
-                changeMonth: true
-            });
-
-            // Enhance any choice/dropdown values with selectmenu.
-            this.inputs.find('.value select').selectmenu({
-                // provide sane width for value dropdown
-                width: '15em'
-            });
-
-            this.inputs.find('.value select').on('selectmenuopen', function(event, ui) {
-                show_all_options($(this));
-            });
-
-            // Listen for button click, to keep checkbox in sync.
-            this._on(this.activebutton, {
-                click: function(e) {
-                    var checked = !this.checkbox.is(':checked');
-                    this.checkbox.prop('checked', checked);
-                    this.refresh();
-                    if (checked) {
-                        this.focus();
-                    }
-                }
-            });
-
-            // Update the initial state of the button according to checkbox.
-            this.refresh();
-        },
-
-        refresh: function() {
-            if (this.checkbox.is(':checked')) {
-                this.activebutton.button('option', 'icons', {primary: 'ui-icon-check'});
-                if (this.verb() in this.valueless_verbs) {
-                    this.inputs.find('.value').hide();
-                } else {
-                    this.inputs.find('.value').show();
-                }
-                this.inputs.show();
-            } else {
-                this.activebutton.button('option', 'icons', {primary: 'ui-icon-blank'});
-                this.inputs.hide();
-            }
-        },
-
-        active: function(value) {
-            if (value === undefined) {
-                return this.checkbox.is(':checked');
-            }
-            if (value) {
-                if (!this.checkbox.is(':checked')) {
-                    this.checkbox.prop('checked', true);
-                    this.refresh();
-                    this.element.show();
-                }
-            } else if (this.checkbox.is(':checked')) {
-                this.checkbox.prop('checked', false);
-                this.refresh();
-            }
-        },
-
-        hide: function() {
-            this.active(false);
-            this.element.hide();
-            var option = this.add_filter.find('option[value="' + this.element.data('key') + '"]');
-            option.attr('disabled', false);
-            if (this.add_filter.selectmenu('option', 'disabled')) {
-                this.add_filter.selectmenu('enable');
-            }
-            this.add_filter.selectmenu('refresh');
-        },
-
-        focus: function() {
-            this.inputs.find('.value input').focus();
-        },
-
-        select: function() {
-            this.inputs.find('.value input').select();
-        },
-
-        value: function() {
-            return this.inputs.find('.value input, .value select').val();
-        },
-
-        verb: function() {
-            return this.inputs.find('.verb').val();
-        }
-
-    });
-    
-})( jQuery );
diff --git a/tailbone/static/js/jquery.ui.tailbone.mobile.js b/tailbone/static/js/jquery.ui.tailbone.mobile.js
deleted file mode 100644
index 79eecb9a..00000000
--- a/tailbone/static/js/jquery.ui.tailbone.mobile.js
+++ /dev/null
@@ -1,81 +0,0 @@
-
-/******************************************
- * jQuery Mobile plugins for Tailbone
- *****************************************/
-
-/******************************************
- * mobile autocomplete
- *****************************************/
-
-(function($) {
-    
-    $.widget('tailbone.mobileautocomplete', {
-
-        _create: function() {
-            var that = this;
-
-            // snag some element references
-            this.search = this.element.find('.ui-input-search');
-            this.hidden_field = this.element.find('input[type="hidden"]');
-            this.text_field = this.element.find('input[type="text"]');
-            this.ul = this.element.find('ul');
-            this.button = this.element.find('button');
-
-            // establish our autocomplete URL
-            this.url = this.options.url || this.element.data('url');
-
-            // NOTE: much of this code was copied from the jquery mobile demo site
-            // https://demos.jquerymobile.com/1.4.5/listview-autocomplete-remote/
-            this.ul.on('filterablebeforefilter', function(e, data) {
-
-                var $input = $( data.input ),
-                    value = $input.val(),
-                    html = "";
-                that.ul.html( "" );
-                if ( value && value.length > 2 ) {
-                    that.ul.html( "<li><div class='ui-loader'><span class='ui-icon ui-icon-loading'></span></div></li>" );
-                    that.ul.listview( "refresh" );
-                    $.ajax({
-                        url: that.url,
-                        data: {
-                            term: $input.val()
-                        }
-                    })
-                        .then( function ( response ) {
-                            $.each( response, function ( i, val ) {
-                                html += '<li data-uuid="' + val.value + '">' + val.label + "</li>";
-                            });
-                            that.ul.html( html );
-                            that.ul.listview( "refresh" );
-                            that.ul.trigger( "updatelayout");
-                        });
-                }
-
-            });
-
-            // when user clicks autocomplete result, hide search etc.
-            this.ul.on('click', 'li', function() {
-                var $li = $(this);
-                var uuid = $li.data('uuid');
-                that.search.hide();
-                that.hidden_field.val(uuid);
-                that.button.text($li.text()).show();
-                that.ul.hide();
-                that.element.trigger('autocompleteitemselected', uuid);
-            });
-
-            // when user clicks "change" button, show search etc.
-            this.button.click(function() {
-                that.button.hide();
-                that.ul.empty().show();
-                that.hidden_field.val('');
-                that.search.show();
-                that.text_field.focus();
-                that.element.trigger('autocompleteitemcleared');
-            });
-
-        }
-
-    });
-    
-})( jQuery );
diff --git a/tailbone/static/js/lib/jquery.loadmask.min.js b/tailbone/static/js/lib/jquery.loadmask.min.js
deleted file mode 100644
index d77373c8..00000000
--- a/tailbone/static/js/lib/jquery.loadmask.min.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Copyright (c) 2009 Sergiy Kovalchuk (serg472@gmail.com)
- * 
- * Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
- * and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
- *  
- * Following code is based on Element.mask() implementation from ExtJS framework (http://extjs.com/)
- *
- */
-(function(a){a.fn.mask=function(c,b){a(this).each(function(){if(b!==undefined&&b>0){var d=a(this);d.data("_mask_timeout",setTimeout(function(){a.maskElement(d,c)},b))}else{a.maskElement(a(this),c)}})};a.fn.unmask=function(){a(this).each(function(){a.unmaskElement(a(this))})};a.fn.isMasked=function(){return this.hasClass("masked")};a.maskElement=function(d,c){if(d.data("_mask_timeout")!==undefined){clearTimeout(d.data("_mask_timeout"));d.removeData("_mask_timeout")}if(d.isMasked()){a.unmaskElement(d)}if(d.css("position")=="static"){d.addClass("masked-relative")}d.addClass("masked");var e=a('<div class="loadmask"></div>');if(navigator.userAgent.toLowerCase().indexOf("msie")>-1){e.height(d.height()+parseInt(d.css("padding-top"))+parseInt(d.css("padding-bottom")));e.width(d.width()+parseInt(d.css("padding-left"))+parseInt(d.css("padding-right")))}if(navigator.userAgent.toLowerCase().indexOf("msie 6")>-1){d.find("select").addClass("masked-hidden")}d.append(e);if(c!==undefined){var b=a('<div class="loadmask-msg" style="display:none;"></div>');b.append("<div>"+c+"</div>");d.append(b);b.css("top",Math.round(d.height()/2-(b.height()-parseInt(b.css("padding-top"))-parseInt(b.css("padding-bottom")))/2)+"px");b.css("left",Math.round(d.width()/2-(b.width()-parseInt(b.css("padding-left"))-parseInt(b.css("padding-right")))/2)+"px");b.show()}};a.unmaskElement=function(b){if(b.data("_mask_timeout")!==undefined){clearTimeout(b.data("_mask_timeout"));b.removeData("_mask_timeout")}b.find(".loadmask-msg,.loadmask").remove();b.removeClass("masked");b.removeClass("masked-relative");b.find("select").removeClass("masked-hidden")}})(jQuery);
\ No newline at end of file
diff --git a/tailbone/static/js/lib/jquery.ui.menubar.js b/tailbone/static/js/lib/jquery.ui.menubar.js
deleted file mode 100644
index a1559091..00000000
--- a/tailbone/static/js/lib/jquery.ui.menubar.js
+++ /dev/null
@@ -1,331 +0,0 @@
-/*
- * jQuery UI Menubar @VERSION
- *
- * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
- * Dual licensed under the MIT or GPL Version 2 licenses.
- * http://jquery.org/license
- *
- * http://docs.jquery.com/UI/Menubar
- *
- * Depends:
- *    jquery.ui.core.js
- *    jquery.ui.widget.js
- *    jquery.ui.position.js
- *    jquery.ui.menu.js
- */
-(function( $ ) {
-
-    // TODO when mixing clicking menus and keyboard navigation, focus handling is broken
-    // there has to be just one item that has tabindex
-    $.widget( "ui.menubar", {
-        version: "@VERSION",
-        options: {
-            autoExpand: false,
-            buttons: false,
-            items: "li",
-            menuElement: "ul",
-            menuIcon: false,
-            position: {
-                my: "left top",
-                at: "left bottom"
-            }
-        },
-        _create: function() {
-            var that = this;
-            this.menuItems = this.element.children( this.options.items );
-            this.items = this.menuItems.children( "button, a" );
-
-            this.menuItems
-                .addClass( "ui-menubar-item" )
-                .attr( "role", "presentation" );
-            // let only the first item receive focus
-            this.items.slice(1).attr( "tabIndex", -1 );
-
-            this.element
-                .addClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
-                .attr( "role", "menubar" );
-            this._focusable( this.items );
-            this._hoverable( this.items );
-            this.items.siblings( this.options.menuElement )
-                .menu({
-                    position: {
-                        within: this.options.position.within
-                    },
-                    select: function( event, ui ) {
-                        ui.item.parents( "ul.ui-menu:last" ).hide();
-                        that._close();
-                        // TODO what is this targetting? there's probably a better way to access it
-                        $(event.target).prev().focus();
-                        that._trigger( "select", event, ui );
-                    },
-                    menus: that.options.menuElement
-                })
-                .hide()
-                .attr({
-                    "aria-hidden": "true",
-                    "aria-expanded": "false"
-                })
-            // TODO use _on
-                .bind( "keydown.menubar", function( event ) {
-                    var menu = $( this );
-                    if ( menu.is( ":hidden" ) ) {
-                        return;
-                    }
-                    switch ( event.keyCode ) {
-                    case $.ui.keyCode.LEFT:
-                        that.previous( event );
-                        event.preventDefault();
-                        break;
-                    case $.ui.keyCode.RIGHT:
-                        that.next( event );
-                        event.preventDefault();
-                        break;
-                    }
-                });
-            this.items.each(function() {
-                var input = $(this),
-                // TODO menu var is only used on two places, doesn't quite justify the .each
-                menu = input.next( that.options.menuElement );
-
-                // might be a non-menu button
-                if ( menu.length ) {
-                    // TODO use _on
-                    input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) {
-                        // ignore triggered focus event
-                        if ( event.type === "focus" && !event.originalEvent ) {
-                            return;
-                        }
-                        event.preventDefault();
-                        // TODO can we simplify or extractthis check? especially the last two expressions
-                        // there's a similar active[0] == menu[0] check in _open
-                        if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) {
-                            that._close();
-                            return;
-                        }
-                        if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) {
-                            if( that.options.autoExpand ) {
-                                clearTimeout( that.closeTimer );
-                            }
-
-                            that._open( event, menu );
-                        }
-                    })
-                    // TODO use _on
-                        .bind( "keydown", function( event ) {
-                            switch ( event.keyCode ) {
-                            case $.ui.keyCode.SPACE:
-                            case $.ui.keyCode.UP:
-                            case $.ui.keyCode.DOWN:
-                                that._open( event, $( this ).next() );
-                                event.preventDefault();
-                                break;
-                            case $.ui.keyCode.LEFT:
-                                that.previous( event );
-                                event.preventDefault();
-                                break;
-                            case $.ui.keyCode.RIGHT:
-                                that.next( event );
-                                event.preventDefault();
-                                break;
-                            }
-                        })
-                        .attr( "aria-haspopup", "true" );
-
-                    // TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged
-                    if ( that.options.menuIcon ) {
-                        input.addClass( "ui-state-default" ).append( "<span class='ui-button-icon-secondary ui-icon ui-icon-triangle-1-s'></span>" );
-                        input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" );
-                    }
-                } else {
-                    // TODO use _on
-                    input.bind( "click.menubar mouseenter.menubar", function( event ) {
-                        if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) {
-                            that._close();
-                        }
-                    });
-                }
-
-                input
-                    .addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" )
-                    .attr( "role", "menuitem" )
-                    .wrapInner( "<span class='ui-button-text'></span>" );
-
-                if ( that.options.buttons ) {
-                    input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" );
-                }
-            });
-            that._on( {
-                keydown: function( event ) {
-                    if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) {
-                        var active = that.active;
-                        that.active.blur();
-                        that._close( event );
-                        active.prev().focus();
-                    }
-                },
-                focusin: function( event ) {
-                    clearTimeout( that.closeTimer );
-                },
-                focusout: function( event ) {
-                    that.closeTimer = setTimeout( function() {
-                        that._close( event );
-                    }, 150);
-                },
-                "mouseleave .ui-menubar-item": function( event ) {
-                    if ( that.options.autoExpand ) {
-                        that.closeTimer = setTimeout( function() {
-                            that._close( event );
-                        }, 150);
-                    }
-                },
-                "mouseenter .ui-menubar-item": function( event ) {
-                    clearTimeout( that.closeTimer );
-                }
-            });
-
-            // Keep track of open submenus
-            this.openSubmenus = 0;
-        },
-
-        _destroy : function() {
-            this.menuItems
-                .removeClass( "ui-menubar-item" )
-                .removeAttr( "role" );
-
-            this.element
-                .removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
-                .removeAttr( "role" )
-                .unbind( ".menubar" );
-
-            this.items
-                .unbind( ".menubar" )
-                .removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" )
-                .removeAttr( "role" )
-                .removeAttr( "aria-haspopup" )
-            // TODO unwrap?
-                .children( "span.ui-button-text" ).each(function( i, e ) {
-                    var item = $( this );
-                    item.parent().html( item.html() );
-                })
-                    .end()
-                .children( ".ui-icon" ).remove();
-
-            this.element.find( ":ui-menu" )
-                .menu( "destroy" )
-                .show()
-                .removeAttr( "aria-hidden" )
-                .removeAttr( "aria-expanded" )
-                .removeAttr( "tabindex" )
-                .unbind( ".menubar" );
-        },
-
-        _close: function() {
-            if ( !this.active || !this.active.length ) {
-                return;
-            }
-            this.active
-                .menu( "collapseAll" )
-                .hide()
-                .attr({
-                    "aria-hidden": "true",
-                    "aria-expanded": "false"
-                });
-            this.active
-                .prev()
-                .removeClass( "ui-state-active" )
-                .removeAttr( "tabIndex" );
-            this.active = null;
-            this.open = false;
-            this.openSubmenus = 0;
-        },
-
-        _open: function( event, menu ) {
-            // on a single-button menubar, ignore reopening the same menu
-            if ( this.active && this.active[0] === menu[0] ) {
-                return;
-            }
-            // TODO refactor, almost the same as _close above, but don't remove tabIndex
-            if ( this.active ) {
-                this.active
-                    .menu( "collapseAll" )
-                    .hide()
-                    .attr({
-                        "aria-hidden": "true",
-                        "aria-expanded": "false"
-                    });
-                this.active
-                    .prev()
-                    .removeClass( "ui-state-active" );
-            }
-            // set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
-            var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 );
-            this.active = menu
-                .show()
-                .position( $.extend({
-                    of: button
-                }, this.options.position ) )
-                .removeAttr( "aria-hidden" )
-                .attr( "aria-expanded", "true" )
-                .menu("focus", event, menu.children( ".ui-menu-item" ).first() )
-                // TODO need a comment here why both events are triggered
-                // TODO: heh well given the above comment i'm not sure what the
-                // implications might be for disabling the focus() call..but it
-                // messes with text input focus in undesirable ways..so disable it
-                // we will..until we know why we shouldn't
-                // .focus()
-                .focusin();
-            this.open = true;
-        },
-
-        next: function( event ) {
-            if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) {
-                // Track number of open submenus and prevent moving to next menubar item
-                this.openSubmenus++;
-                return;
-            }
-            this.openSubmenus = 0;
-            this._move( "next", "first", event );
-        },
-
-        previous: function( event ) {
-            if ( this.open && this.openSubmenus ) {
-                // Track number of open submenus and prevent moving to previous menubar item
-                this.openSubmenus--;
-                return;
-            }
-            this.openSubmenus = 0;
-            this._move( "prev", "last", event );
-        },
-
-        _move: function( direction, filter, event ) {
-            var next,
-            wrapItem;
-            if ( this.open ) {
-                next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 );
-                wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 );
-            } else {
-                if ( event ) {
-                    next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 );
-                    wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 );
-                } else {
-                    next = wrapItem = this.menuItems.children( "a" ).eq( 0 );
-                }
-            }
-
-            if ( next.length ) {
-                if ( this.open ) {
-                    this._open( event, next );
-                } else {
-                    next.removeAttr( "tabIndex")[0].focus();
-                }
-            } else {
-                if ( this.open ) {
-                    this._open( event, wrapItem );
-                } else {
-                    wrapItem.removeAttr( "tabIndex")[0].focus();
-                }
-            }
-        }
-    });
-
-}( jQuery ));
diff --git a/tailbone/static/js/lib/jquery.ui.timepicker.js b/tailbone/static/js/lib/jquery.ui.timepicker.js
deleted file mode 100644
index d8a0cfb7..00000000
--- a/tailbone/static/js/lib/jquery.ui.timepicker.js
+++ /dev/null
@@ -1,1496 +0,0 @@
-/*
- * jQuery UI Timepicker
- *
- * Copyright 2010-2013, Francois Gelinas
- * Dual licensed under the MIT or GPL Version 2 licenses.
- * http://jquery.org/license
- *
- * http://fgelinas.com/code/timepicker
- *
- * Depends:
- *	jquery.ui.core.js
- *  jquery.ui.position.js (only if position settings are used)
- *
- * Change version 0.1.0 - moved the t-rex up here
- *
-                                                  ____
-       ___                                      .-~. /_"-._
-      `-._~-.                                  / /_ "~o\  :Y
-          \  \                                / : \~x.  ` ')
-           ]  Y                              /  |  Y< ~-.__j
-          /   !                        _.--~T : l  l<  /.-~
-         /   /                 ____.--~ .   ` l /~\ \<|Y
-        /   /             .-~~"        /| .    ',-~\ \L|
-       /   /             /     .^   \ Y~Y \.^>/l_   "--'
-      /   Y           .-"(  .  l__  j_j l_/ /~_.-~    .
-     Y    l          /    \  )    ~~~." / `/"~ / \.__/l_
-     |     \     _.-"      ~-{__     l  :  l._Z~-.___.--~
-     |      ~---~           /   ~~"---\_  ' __[>
-     l  .                _.^   ___     _>-y~
-      \  \     .      .-~   .-~   ~>--"  /
-       \  ~---"            /     ./  _.-'
-        "-.,_____.,_  _.--~\     _.-~
-                    ~~     (   _}       -Row
-                           `. ~(
-                             )  \
-                            /,`--'~\--'~\
-                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-                             ->T-Rex<-
-*/
-
-(function ($) {
-
-    $.extend($.ui, { timepicker: { version: "0.3.3"} });
-
-    var PROP_NAME = 'timepicker',
-        tpuuid = new Date().getTime();
-
-    /* Time picker manager.
-    Use the singleton instance of this class, $.timepicker, to interact with the time picker.
-    Settings for (groups of) time pickers are maintained in an instance object,
-    allowing multiple different settings on the same page. */
-
-    function Timepicker() {
-        this.debug = true; // Change this to true to start debugging
-        this._curInst = null; // The current instance in use
-        this._disabledInputs = []; // List of time picker inputs that have been disabled
-        this._timepickerShowing = false; // True if the popup picker is showing , false if not
-        this._inDialog = false; // True if showing within a "dialog", false if not
-        this._dialogClass = 'ui-timepicker-dialog'; // The name of the dialog marker class
-        this._mainDivId = 'ui-timepicker-div'; // The ID of the main timepicker division
-        this._inlineClass = 'ui-timepicker-inline'; // The name of the inline marker class
-        this._currentClass = 'ui-timepicker-current'; // The name of the current hour / minutes marker class
-        this._dayOverClass = 'ui-timepicker-days-cell-over'; // The name of the day hover marker class
-
-        this.regional = []; // Available regional settings, indexed by language code
-        this.regional[''] = { // Default regional settings
-            hourText: 'Hour',           // Display text for hours section
-            minuteText: 'Minute',       // Display text for minutes link
-            amPmText: ['AM', 'PM'],     // Display text for AM PM
-            closeButtonText: 'Done',        // Text for the confirmation button (ok button)
-            nowButtonText: 'Now',           // Text for the now button
-            deselectButtonText: 'Deselect'  // Text for the deselect button
-        };
-        this._defaults = { // Global defaults for all the time picker instances
-            showOn: 'focus',    // 'focus' for popup on focus,
-                                // 'button' for trigger button, or 'both' for either (not yet implemented)
-            button: null,                   // 'button' element that will trigger the timepicker
-            showAnim: 'fadeIn',             // Name of jQuery animation for popup
-            showOptions: {},                // Options for enhanced animations
-            appendText: '',                 // Display text following the input box, e.g. showing the format
-
-            beforeShow: null,               // Define a callback function executed before the timepicker is shown
-            onSelect: null,                 // Define a callback function when a hour / minutes is selected
-            onClose: null,                  // Define a callback function when the timepicker is closed
-
-            timeSeparator: ':',             // The character to use to separate hours and minutes.
-            periodSeparator: ' ',           // The character to use to separate the time from the time period.
-            showPeriod: false,              // Define whether or not to show AM/PM with selected time
-            showPeriodLabels: true,         // Show the AM/PM labels on the left of the time picker
-            showLeadingZero: true,          // Define whether or not to show a leading zero for hours < 10. [true/false]
-            showMinutesLeadingZero: true,   // Define whether or not to show a leading zero for minutes < 10.
-            altField: '',                   // Selector for an alternate field to store selected time into
-            defaultTime: 'now',             // Used as default time when input field is empty or for inline timePicker
-                                            // (set to 'now' for the current time, '' for no highlighted time)
-            myPosition: 'left top',         // Position of the dialog relative to the input.
-                                            // see the position utility for more info : http://jqueryui.com/demos/position/
-            atPosition: 'left bottom',      // Position of the input element to match
-                                            // Note : if the position utility is not loaded, the timepicker will attach left top to left bottom
-            //NEW: 2011-02-03
-            onHourShow: null,			    // callback for enabling / disabling on selectable hours  ex : function(hour) { return true; }
-            onMinuteShow: null,             // callback for enabling / disabling on time selection  ex : function(hour,minute) { return true; }
-
-            hours: {
-                starts: 0,                  // first displayed hour
-                ends: 23                    // last displayed hour
-            },
-            minutes: {
-                starts: 0,                  // first displayed minute
-                ends: 55,                   // last displayed minute
-                interval: 5,                // interval of displayed minutes
-                manual: []                  // optional extra manual entries for minutes
-            },
-            rows: 4,                        // number of rows for the input tables, minimum 2, makes more sense if you use multiple of 2
-            // 2011-08-05 0.2.4
-            showHours: true,                // display the hours section of the dialog
-            showMinutes: true,              // display the minute section of the dialog
-            optionalMinutes: false,         // optionally parse inputs of whole hours with minutes omitted
-
-            // buttons
-            showCloseButton: false,         // shows an OK button to confirm the edit
-            showNowButton: false,           // Shows the 'now' button
-            showDeselectButton: false,       // Shows the deselect time button
-            
-            maxTime: {
-                hour: null,
-                minute: null
-            },
-            minTime: {
-                hour: null,
-                minute: null
-            }
-			
-        };
-        $.extend(this._defaults, this.regional['']);
-
-        this.tpDiv = $('<div id="' + this._mainDivId + '" class="ui-timepicker ui-widget ui-helper-clearfix ui-corner-all " style="display: none"></div>');
-    }
-
-    $.extend(Timepicker.prototype, {
-        /* Class name added to elements to indicate already configured with a time picker. */
-        markerClassName: 'hasTimepicker',
-
-        /* Debug logging (if enabled). */
-        log: function () {
-            if (this.debug)
-                console.log.apply('', arguments);
-        },
-
-        _widgetTimepicker: function () {
-            return this.tpDiv;
-        },
-
-        /* Override the default settings for all instances of the time picker.
-        @param  settings  object - the new settings to use as defaults (anonymous object)
-        @return the manager object */
-        setDefaults: function (settings) {
-            extendRemove(this._defaults, settings || {});
-            return this;
-        },
-
-        /* Attach the time picker to a jQuery selection.
-        @param  target    element - the target input field or division or span
-        @param  settings  object - the new settings to use for this time picker instance (anonymous) */
-        _attachTimepicker: function (target, settings) {
-            // check for settings on the control itself - in namespace 'time:'
-            var inlineSettings = null;
-            for (var attrName in this._defaults) {
-                var attrValue = target.getAttribute('time:' + attrName);
-                if (attrValue) {
-                    inlineSettings = inlineSettings || {};
-                    try {
-                        inlineSettings[attrName] = eval(attrValue);
-                    } catch (err) {
-                        inlineSettings[attrName] = attrValue;
-                    }
-                }
-            }
-            var nodeName = target.nodeName.toLowerCase();
-            var inline = (nodeName == 'div' || nodeName == 'span');
-
-            if (!target.id) {
-                this.uuid += 1;
-                target.id = 'tp' + this.uuid;
-            }
-            var inst = this._newInst($(target), inline);
-            inst.settings = $.extend({}, settings || {}, inlineSettings || {});
-            if (nodeName == 'input') {
-                this._connectTimepicker(target, inst);
-                // init inst.hours and inst.minutes from the input value
-                this._setTimeFromField(inst);
-            } else if (inline) {
-                this._inlineTimepicker(target, inst);
-            }
-
-
-        },
-
-        /* Create a new instance object. */
-        _newInst: function (target, inline) {
-            var id = target[0].id.replace(/([^A-Za-z0-9_-])/g, '\\\\$1'); // escape jQuery meta chars
-            return {
-                id: id, input: target, // associated target
-                inline: inline, // is timepicker inline or not :
-                tpDiv: (!inline ? this.tpDiv : // presentation div
-                    $('<div class="' + this._inlineClass + ' ui-timepicker ui-widget  ui-helper-clearfix"></div>'))
-            };
-        },
-
-        /* Attach the time picker to an input field. */
-        _connectTimepicker: function (target, inst) {
-            var input = $(target);
-            inst.append = $([]);
-            inst.trigger = $([]);
-            if (input.hasClass(this.markerClassName)) { return; }
-            this._attachments(input, inst);
-            input.addClass(this.markerClassName).
-                keydown(this._doKeyDown).
-                keyup(this._doKeyUp).
-                bind("setData.timepicker", function (event, key, value) {
-                    inst.settings[key] = value;
-                }).
-                bind("getData.timepicker", function (event, key) {
-                    return this._get(inst, key);
-                });
-            $.data(target, PROP_NAME, inst);
-        },
-
-        /* Handle keystrokes. */
-        _doKeyDown: function (event) {
-            var inst = $.timepicker._getInst(event.target);
-            var handled = true;
-            inst._keyEvent = true;
-            if ($.timepicker._timepickerShowing) {
-                switch (event.keyCode) {
-                    case 9: $.timepicker._hideTimepicker();
-                        handled = false;
-                        break; // hide on tab out
-                    case 13:
-                        $.timepicker._updateSelectedValue(inst);
-                        $.timepicker._hideTimepicker();
-
-						return false; // don't submit the form
-						break; // select the value on enter
-                    case 27: $.timepicker._hideTimepicker();
-                        break; // hide on escape
-                    default: handled = false;
-                }
-            }
-            else if (event.keyCode == 36 && event.ctrlKey) { // display the time picker on ctrl+home
-                $.timepicker._showTimepicker(this);
-            }
-            else {
-                handled = false;
-            }
-            if (handled) {
-                event.preventDefault();
-                event.stopPropagation();
-            }
-        },
-
-        /* Update selected time on keyUp */
-        /* Added verion 0.0.5 */
-        _doKeyUp: function (event) {
-            var inst = $.timepicker._getInst(event.target);
-            $.timepicker._setTimeFromField(inst);
-            $.timepicker._updateTimepicker(inst);
-        },
-
-        /* Make attachments based on settings. */
-        _attachments: function (input, inst) {
-            var appendText = this._get(inst, 'appendText');
-            var isRTL = this._get(inst, 'isRTL');
-            if (inst.append) { inst.append.remove(); }
-            if (appendText) {
-                inst.append = $('<span class="' + this._appendClass + '">' + appendText + '</span>');
-                input[isRTL ? 'before' : 'after'](inst.append);
-            }
-            input.unbind('focus.timepicker', this._showTimepicker);
-            input.unbind('click.timepicker', this._adjustZIndex);
-
-            if (inst.trigger) { inst.trigger.remove(); }
-
-            var showOn = this._get(inst, 'showOn');
-            if (showOn == 'focus' || showOn == 'both') { // pop-up time picker when in the marked field
-                input.bind("focus.timepicker", this._showTimepicker);
-                input.bind("click.timepicker", this._adjustZIndex);
-            }
-            if (showOn == 'button' || showOn == 'both') { // pop-up time picker when 'button' element is clicked
-                var button = this._get(inst, 'button');
-
-                // Add button if button element is not set
-                if(button == null) {
-                    button = $('<button class="ui-timepicker-trigger" type="button">...</button>');
-                    input.after(button);
-                }
-
-                $(button).bind("click.timepicker", function () {
-                    if ($.timepicker._timepickerShowing && $.timepicker._lastInput == input[0]) {
-                        $.timepicker._hideTimepicker();
-                    } else if (!inst.input.is(':disabled')) {
-                        $.timepicker._showTimepicker(input[0]);
-                    }
-                    return false;
-                });
-
-            }
-        },
-
-
-        /* Attach an inline time picker to a div. */
-        _inlineTimepicker: function(target, inst) {
-            var divSpan = $(target);
-            if (divSpan.hasClass(this.markerClassName))
-                return;
-            divSpan.addClass(this.markerClassName).append(inst.tpDiv).
-                bind("setData.timepicker", function(event, key, value){
-                    inst.settings[key] = value;
-                }).bind("getData.timepicker", function(event, key){
-                    return this._get(inst, key);
-                });
-            $.data(target, PROP_NAME, inst);
-
-            this._setTimeFromField(inst);
-            this._updateTimepicker(inst);
-            inst.tpDiv.show();
-        },
-
-        _adjustZIndex: function(input) {
-            input = input.target || input;
-            var inst = $.timepicker._getInst(input);
-            inst.tpDiv.css('zIndex', $.timepicker._getZIndex(input) +1);
-        },
-
-        /* Pop-up the time picker for a given input field.
-        @param  input  element - the input field attached to the time picker or
-        event - if triggered by focus */
-        _showTimepicker: function (input) {
-            input = input.target || input;
-            if (input.nodeName.toLowerCase() != 'input') { input = $('input', input.parentNode)[0]; } // find from button/image trigger
-
-            if ($.timepicker._isDisabledTimepicker(input) || $.timepicker._lastInput == input) { return; } // already here
-
-            // fix v 0.0.8 - close current timepicker before showing another one
-            $.timepicker._hideTimepicker();
-
-            var inst = $.timepicker._getInst(input);
-            if ($.timepicker._curInst && $.timepicker._curInst != inst) {
-                $.timepicker._curInst.tpDiv.stop(true, true);
-            }
-            var beforeShow = $.timepicker._get(inst, 'beforeShow');
-            extendRemove(inst.settings, (beforeShow ? beforeShow.apply(input, [input, inst]) : {}));
-            inst.lastVal = null;
-            $.timepicker._lastInput = input;
-
-            $.timepicker._setTimeFromField(inst);
-
-            // calculate default position
-            if ($.timepicker._inDialog) { input.value = ''; } // hide cursor
-            if (!$.timepicker._pos) { // position below input
-                $.timepicker._pos = $.timepicker._findPos(input);
-                $.timepicker._pos[1] += input.offsetHeight; // add the height
-            }
-            var isFixed = false;
-            $(input).parents().each(function () {
-                isFixed |= $(this).css('position') == 'fixed';
-                return !isFixed;
-            });
-
-            var offset = { left: $.timepicker._pos[0], top: $.timepicker._pos[1] };
-
-            $.timepicker._pos = null;
-            // determine sizing offscreen
-            inst.tpDiv.css({ position: 'absolute', display: 'block', top: '-1000px' });
-            $.timepicker._updateTimepicker(inst);
-
-
-            // position with the ui position utility, if loaded
-            if ( ( ! inst.inline )  && ( typeof $.ui.position == 'object' ) ) {
-                inst.tpDiv.position({
-                    of: inst.input,
-                    my: $.timepicker._get( inst, 'myPosition' ),
-                    at: $.timepicker._get( inst, 'atPosition' ),
-                    // offset: $( "#offset" ).val(),
-                    // using: using,
-                    collision: 'flip'
-                });
-                var offset = inst.tpDiv.offset();
-                $.timepicker._pos = [offset.top, offset.left];
-            }
-
-
-            // reset clicked state
-            inst._hoursClicked = false;
-            inst._minutesClicked = false;
-
-            // fix width for dynamic number of time pickers
-            // and adjust position before showing
-            offset = $.timepicker._checkOffset(inst, offset, isFixed);
-            inst.tpDiv.css({ position: ($.timepicker._inDialog && $.blockUI ?
-			    'static' : (isFixed ? 'fixed' : 'absolute')), display: 'none',
-                left: offset.left + 'px', top: offset.top + 'px'
-            });
-            if ( ! inst.inline ) {
-                var showAnim = $.timepicker._get(inst, 'showAnim');
-                var duration = $.timepicker._get(inst, 'duration');
-
-                var postProcess = function () {
-                    $.timepicker._timepickerShowing = true;
-                    var borders = $.timepicker._getBorders(inst.tpDiv);
-                    inst.tpDiv.find('iframe.ui-timepicker-cover'). // IE6- only
-					css({ left: -borders[0], top: -borders[1],
-					    width: inst.tpDiv.outerWidth(), height: inst.tpDiv.outerHeight()
-					});
-                };
-
-                // Fixed the zIndex problem for real (I hope) - FG - v 0.2.9
-                $.timepicker._adjustZIndex(input);
-                //inst.tpDiv.css('zIndex', $.timepicker._getZIndex(input) +1);
-
-                if ($.effects && $.effects[showAnim]) {
-                    inst.tpDiv.show(showAnim, $.timepicker._get(inst, 'showOptions'), duration, postProcess);
-                }
-                else {
-                    inst.tpDiv.show((showAnim ? duration : null), postProcess);
-                }
-                if (!showAnim || !duration) { postProcess(); }
-                if (inst.input.is(':visible') && !inst.input.is(':disabled')) { inst.input.focus(); }
-                $.timepicker._curInst = inst;
-            }
-        },
-
-        // This is an enhanced copy of the zIndex function of UI core 1.8.?? For backward compatibility.
-        // Enhancement returns maximum zindex value discovered while traversing parent elements,
-        // rather than the first zindex value found. Ensures the timepicker popup will be in front,
-        // even in funky scenarios like non-jq dialog containers with large fixed zindex values and
-        // nested zindex-influenced elements of their own.
-        _getZIndex: function (target) {
-            var elem = $(target);
-            var maxValue = 0;
-            var position, value;
-            while (elem.length && elem[0] !== document) {
-                position = elem.css("position");
-                if (position === "absolute" || position === "relative" || position === "fixed") {
-                    value = parseInt(elem.css("zIndex"), 10);
-                    if (!isNaN(value) && value !== 0) {
-                        if (value > maxValue) { maxValue = value; }
-                    }
-                }
-                elem = elem.parent();
-            }
-
-            return maxValue;
-        },
-
-        /* Refresh the time picker
-           @param   target  element - The target input field or inline container element. */
-        _refreshTimepicker: function(target) {
-            var inst = this._getInst(target);
-            if (inst) {
-                this._updateTimepicker(inst);
-            }
-        },
-
-
-        /* Generate the time picker content. */
-        _updateTimepicker: function (inst) {
-            inst.tpDiv.empty().append(this._generateHTML(inst));
-            this._rebindDialogEvents(inst);
-
-        },
-
-        _rebindDialogEvents: function (inst) {
-            var borders = $.timepicker._getBorders(inst.tpDiv),
-                self = this;
-            inst.tpDiv
-			.find('iframe.ui-timepicker-cover') // IE6- only
-				.css({ left: -borders[0], top: -borders[1],
-				    width: inst.tpDiv.outerWidth(), height: inst.tpDiv.outerHeight()
-				})
-			.end()
-            // after the picker html is appended bind the click & double click events (faster in IE this way
-            // then letting the browser interpret the inline events)
-            // the binding for the minute cells also exists in _updateMinuteDisplay
-            .find('.ui-timepicker-minute-cell')
-                .unbind()
-                .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectMinutes, this))
-                .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectMinutes, this))
-            .end()
-            .find('.ui-timepicker-hour-cell')
-                .unbind()
-                .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectHours, this))
-                .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectHours, this))
-            .end()
-			.find('.ui-timepicker td a')
-                .unbind()
-				.bind('mouseout', function () {
-				    $(this).removeClass('ui-state-hover');
-				    if (this.className.indexOf('ui-timepicker-prev') != -1) $(this).removeClass('ui-timepicker-prev-hover');
-				    if (this.className.indexOf('ui-timepicker-next') != -1) $(this).removeClass('ui-timepicker-next-hover');
-				})
-				.bind('mouseover', function () {
-				    if ( ! self._isDisabledTimepicker(inst.inline ? inst.tpDiv.parent()[0] : inst.input[0])) {
-				        $(this).parents('.ui-timepicker-calendar').find('a').removeClass('ui-state-hover');
-				        $(this).addClass('ui-state-hover');
-				        if (this.className.indexOf('ui-timepicker-prev') != -1) $(this).addClass('ui-timepicker-prev-hover');
-				        if (this.className.indexOf('ui-timepicker-next') != -1) $(this).addClass('ui-timepicker-next-hover');
-				    }
-				})
-			.end()
-			.find('.' + this._dayOverClass + ' a')
-				.trigger('mouseover')
-			.end()
-            .find('.ui-timepicker-now').bind("click", function(e) {
-                    $.timepicker.selectNow(e);
-            }).end()
-            .find('.ui-timepicker-deselect').bind("click",function(e) {
-                    $.timepicker.deselectTime(e);
-            }).end()
-            .find('.ui-timepicker-close').bind("click",function(e) {
-                    $.timepicker._hideTimepicker();
-            }).end();
-        },
-
-        /* Generate the HTML for the current state of the time picker. */
-        _generateHTML: function (inst) {
-
-            var h, m, row, col, html, hoursHtml, minutesHtml = '',
-                showPeriod = (this._get(inst, 'showPeriod') == true),
-                showPeriodLabels = (this._get(inst, 'showPeriodLabels') == true),
-                showLeadingZero = (this._get(inst, 'showLeadingZero') == true),
-                showHours = (this._get(inst, 'showHours') == true),
-                showMinutes = (this._get(inst, 'showMinutes') == true),
-                amPmText = this._get(inst, 'amPmText'),
-                rows = this._get(inst, 'rows'),
-                amRows = 0,
-                pmRows = 0,
-                amItems = 0,
-                pmItems = 0,
-                amFirstRow = 0,
-                pmFirstRow = 0,
-                hours = Array(),
-                hours_options = this._get(inst, 'hours'),
-                hoursPerRow = null,
-                hourCounter = 0,
-                hourLabel = this._get(inst, 'hourText'),
-                showCloseButton = this._get(inst, 'showCloseButton'),
-                closeButtonText = this._get(inst, 'closeButtonText'),
-                showNowButton = this._get(inst, 'showNowButton'),
-                nowButtonText = this._get(inst, 'nowButtonText'),
-                showDeselectButton = this._get(inst, 'showDeselectButton'),
-                deselectButtonText = this._get(inst, 'deselectButtonText'),
-                showButtonPanel = showCloseButton || showNowButton || showDeselectButton;
-
-
-
-            // prepare all hours and minutes, makes it easier to distribute by rows
-            for (h = hours_options.starts; h <= hours_options.ends; h++) {
-                hours.push (h);
-            }
-            hoursPerRow = Math.ceil(hours.length / rows); // always round up
-
-            if (showPeriodLabels) {
-                for (hourCounter = 0; hourCounter < hours.length; hourCounter++) {
-                    if (hours[hourCounter] < 12) {
-                        amItems++;
-                    }
-                    else {
-                        pmItems++;
-                    }
-                }
-                hourCounter = 0;
-
-                amRows = Math.floor(amItems / hours.length * rows);
-                pmRows = Math.floor(pmItems / hours.length * rows);
-
-                // assign the extra row to the period that is more densely populated
-                if (rows != amRows + pmRows) {
-                    // Make sure: AM Has Items and either PM Does Not, AM has no rows yet, or AM is more dense
-                    if (amItems && (!pmItems || !amRows || (pmRows && amItems / amRows >= pmItems / pmRows))) {
-                        amRows++;
-                    } else {
-                        pmRows++;
-                    }
-                }
-                amFirstRow = Math.min(amRows, 1);
-                pmFirstRow = amRows + 1;
-
-                if (amRows == 0) {
-                    hoursPerRow = Math.ceil(pmItems / pmRows);
-                } else if (pmRows == 0) {
-                    hoursPerRow = Math.ceil(amItems / amRows);
-                } else {
-                    hoursPerRow = Math.ceil(Math.max(amItems / amRows, pmItems / pmRows));
-                }
-            }
-
-
-            html = '<table class="ui-timepicker-table ui-widget-content ui-corner-all"><tr>';
-
-            if (showHours) {
-
-                html += '<td class="ui-timepicker-hours">' +
-                        '<div class="ui-timepicker-title ui-widget-header ui-helper-clearfix ui-corner-all">' +
-                        hourLabel +
-                        '</div>' +
-                        '<table class="ui-timepicker">';
-
-                for (row = 1; row <= rows; row++) {
-                    html += '<tr>';
-                    // AM
-                    if (row == amFirstRow && showPeriodLabels) {
-                        html += '<th rowspan="' + amRows.toString() + '" class="periods" scope="row">' + amPmText[0] + '</th>';
-                    }
-                    // PM
-                    if (row == pmFirstRow && showPeriodLabels) {
-                        html += '<th rowspan="' + pmRows.toString() + '" class="periods" scope="row">' + amPmText[1] + '</th>';
-                    }
-                    for (col = 1; col <= hoursPerRow; col++) {
-                        if (showPeriodLabels && row < pmFirstRow && hours[hourCounter] >= 12) {
-                            html += this._generateHTMLHourCell(inst, undefined, showPeriod, showLeadingZero);
-                        } else {
-                            html += this._generateHTMLHourCell(inst, hours[hourCounter], showPeriod, showLeadingZero);
-                            hourCounter++;
-                        }
-                    }
-                    html += '</tr>';
-                }
-                html += '</table>' + // Close the hours cells table
-                        '</td>'; // Close the Hour td
-            }
-
-            if (showMinutes) {
-                html += '<td class="ui-timepicker-minutes">';
-                html += this._generateHTMLMinutes(inst);
-                html += '</td>';
-            }
-
-            html += '</tr>';
-
-
-            if (showButtonPanel) {
-                var buttonPanel = '<tr><td colspan="3"><div class="ui-timepicker-buttonpane ui-widget-content">';
-                if (showNowButton) {
-                    buttonPanel += '<button type="button" class="ui-timepicker-now ui-state-default ui-corner-all" '
-                                   + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >'
-                                   + nowButtonText + '</button>';
-                }
-                if (showDeselectButton) {
-                    buttonPanel += '<button type="button" class="ui-timepicker-deselect ui-state-default ui-corner-all" '
-                                   + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >'
-                                   + deselectButtonText + '</button>';
-                }
-                if (showCloseButton) {
-                    buttonPanel += '<button type="button" class="ui-timepicker-close ui-state-default ui-corner-all" '
-                                   + ' data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" >'
-                                   + closeButtonText + '</button>';
-                }
-
-                html += buttonPanel + '</div></td></tr>';
-            }
-            html += '</table>';
-
-            return html;
-        },
-
-        /* Special function that update the minutes selection in currently visible timepicker
-         * called on hour selection when onMinuteShow is defined  */
-        _updateMinuteDisplay: function (inst) {
-            var newHtml = this._generateHTMLMinutes(inst);
-            inst.tpDiv.find('td.ui-timepicker-minutes').html(newHtml);
-            this._rebindDialogEvents(inst);
-                // after the picker html is appended bind the click & double click events (faster in IE this way
-                // then letting the browser interpret the inline events)
-                // yes I know, duplicate code, sorry
-/*                .find('.ui-timepicker-minute-cell')
-                    .bind("click", { fromDoubleClick:false }, $.proxy($.timepicker.selectMinutes, this))
-                    .bind("dblclick", { fromDoubleClick:true }, $.proxy($.timepicker.selectMinutes, this));
-*/
-
-        },
-
-        /*
-         * Generate the minutes table
-         * This is separated from the _generateHTML function because is can be called separately (when hours changes)
-         */
-        _generateHTMLMinutes: function (inst) {
-
-            var m, row, html = '',
-                rows = this._get(inst, 'rows'),
-                minutes = Array(),
-                minutes_options = this._get(inst, 'minutes'),
-                minutesPerRow = null,
-                minuteCounter = 0,
-                showMinutesLeadingZero = (this._get(inst, 'showMinutesLeadingZero') == true),
-                onMinuteShow = this._get(inst, 'onMinuteShow'),
-                minuteLabel = this._get(inst, 'minuteText');
-
-            if ( ! minutes_options.starts) {
-                minutes_options.starts = 0;
-            }
-            if ( ! minutes_options.ends) {
-                minutes_options.ends = 59;
-            }
-            if ( ! minutes_options.manual) {
-                minutes_options.manual = [];
-            }
-            for (m = minutes_options.starts; m <= minutes_options.ends; m += minutes_options.interval) {
-                minutes.push(m);
-            }
-            for (i = 0; i < minutes_options.manual.length;i++) {
-                var currMin = minutes_options.manual[i];
-
-                // Validate & filter duplicates of manual minute input
-                if (typeof currMin != 'number' || currMin < 0 || currMin > 59 || $.inArray(currMin, minutes) >= 0) {
-                    continue;
-                }
-                minutes.push(currMin);
-            }
-
-            // Sort to get correct order after adding manual minutes
-            // Use compare function to sort by number, instead of string (default)
-            minutes.sort(function(a, b) {
-                return a-b;
-            });
-
-            minutesPerRow = Math.round(minutes.length / rows + 0.49); // always round up
-
-            /*
-             * The minutes table
-             */
-            // if currently selected minute is not enabled, we have a problem and need to select a new minute.
-            if (onMinuteShow &&
-                (onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours , inst.minutes]) == false) ) {
-                // loop minutes and select first available
-                for (minuteCounter = 0; minuteCounter < minutes.length; minuteCounter += 1) {
-                    m = minutes[minuteCounter];
-                    if (onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours, m])) {
-                        inst.minutes = m;
-                        break;
-                    }
-                }
-            }
-
-
-
-            html += '<div class="ui-timepicker-title ui-widget-header ui-helper-clearfix ui-corner-all">' +
-                    minuteLabel +
-                    '</div>' +
-                    '<table class="ui-timepicker">';
-
-            minuteCounter = 0;
-            for (row = 1; row <= rows; row++) {
-                html += '<tr>';
-                while (minuteCounter < row * minutesPerRow) {
-                    var m = minutes[minuteCounter];
-                    var displayText = '';
-                    if (m !== undefined ) {
-                        displayText = (m < 10) && showMinutesLeadingZero ? "0" + m.toString() : m.toString();
-                    }
-                    html += this._generateHTMLMinuteCell(inst, m, displayText);
-                    minuteCounter++;
-                }
-                html += '</tr>';
-            }
-
-            html += '</table>';
-
-            return html;
-        },
-
-        /* Generate the content of a "Hour" cell */
-        _generateHTMLHourCell: function (inst, hour, showPeriod, showLeadingZero) {
-
-            var displayHour = hour;
-            if ((hour > 12) && showPeriod) {
-                displayHour = hour - 12;
-            }
-            if ((displayHour == 0) && showPeriod) {
-                displayHour = 12;
-            }
-            if ((displayHour < 10) && showLeadingZero) {
-                displayHour = '0' + displayHour;
-            }
-
-            var html = "";
-            var enabled = true;
-            var onHourShow = this._get(inst, 'onHourShow');		//custom callback
-            var maxTime = this._get(inst, 'maxTime');
-            var minTime = this._get(inst, 'minTime');
-
-            if (hour == undefined) {
-                html = '<td><span class="ui-state-default ui-state-disabled">&nbsp;</span></td>';
-                return html;
-            }
-
-            if (onHourShow) {
-            	enabled = onHourShow.apply((inst.input ? inst.input[0] : null), [hour]);
-            }
-			
-            if (enabled) {
-                if ( !isNaN(parseInt(maxTime.hour)) && hour > maxTime.hour ) enabled = false;
-                if ( !isNaN(parseInt(minTime.hour)) && hour < minTime.hour ) enabled = false;
-            }
-			
-            if (enabled) {
-                html = '<td class="ui-timepicker-hour-cell" data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" data-hour="' + hour.toString() + '">' +
-                   '<a class="ui-state-default ' +
-                   (hour == inst.hours ? 'ui-state-active' : '') +
-                   '">' +
-                   displayHour.toString() +
-                   '</a></td>';
-            }
-            else {
-            	html =
-            		'<td>' +
-		                '<span class="ui-state-default ui-state-disabled ' +
-		                (hour == inst.hours ? ' ui-state-active ' : ' ') +
-		                '">' +
-		                displayHour.toString() +
-		                '</span>' +
-		            '</td>';
-            }
-            return html;
-        },
-
-        /* Generate the content of a "Hour" cell */
-        _generateHTMLMinuteCell: function (inst, minute, displayText) {
-             var html = "";
-             var enabled = true;
-             var hour = inst.hours;
-             var onMinuteShow = this._get(inst, 'onMinuteShow');		//custom callback
-             var maxTime = this._get(inst, 'maxTime');
-             var minTime = this._get(inst, 'minTime');
-
-             if (onMinuteShow) {
-            	 //NEW: 2011-02-03  we should give the hour as a parameter as well!
-             	enabled = onMinuteShow.apply((inst.input ? inst.input[0] : null), [inst.hours,minute]);		//trigger callback
-             }
-
-             if (minute == undefined) {
-                 html = '<td><span class="ui-state-default ui-state-disabled">&nbsp;</span></td>';
-                 return html;
-             }
-
-            if (enabled && hour !== null) {
-                if ( !isNaN(parseInt(maxTime.hour)) && !isNaN(parseInt(maxTime.minute)) && hour >= maxTime.hour && minute > maxTime.minute ) enabled = false;
-                if ( !isNaN(parseInt(minTime.hour)) && !isNaN(parseInt(minTime.minute)) && hour <= minTime.hour && minute < minTime.minute ) enabled = false;
-            }
-			
-             if (enabled) {
-	             html = '<td class="ui-timepicker-minute-cell" data-timepicker-instance-id="#' + inst.id.replace(/\\\\/g,"\\") + '" data-minute="' + minute.toString() + '" >' +
-	                   '<a class="ui-state-default ' +
-	                   (minute == inst.minutes ? 'ui-state-active' : '') +
-	                   '" >' +
-	                   displayText +
-	                   '</a></td>';
-             }
-             else {
-
-            	html = '<td>' +
-	                 '<span class="ui-state-default ui-state-disabled" >' +
-	                 	displayText +
-	                 '</span>' +
-                 '</td>';
-             }
-             return html;
-        },
-
-
-        /* Detach a timepicker from its control.
-           @param  target    element - the target input field or division or span */
-        _destroyTimepicker: function(target) {
-            var $target = $(target);
-            var inst = $.data(target, PROP_NAME);
-            if (!$target.hasClass(this.markerClassName)) {
-                return;
-            }
-            var nodeName = target.nodeName.toLowerCase();
-            $.removeData(target, PROP_NAME);
-            if (nodeName == 'input') {
-                inst.append.remove();
-                inst.trigger.remove();
-                $target.removeClass(this.markerClassName)
-                    .unbind('focus.timepicker', this._showTimepicker)
-                    .unbind('click.timepicker', this._adjustZIndex);
-            } else if (nodeName == 'div' || nodeName == 'span')
-                $target.removeClass(this.markerClassName).empty();
-        },
-
-        /* Enable the date picker to a jQuery selection.
-           @param  target    element - the target input field or division or span */
-        _enableTimepicker: function(target) {
-            var $target = $(target),
-                target_id = $target.attr('id'),
-                inst = $.data(target, PROP_NAME);
-
-            if (!$target.hasClass(this.markerClassName)) {
-                return;
-            }
-            var nodeName = target.nodeName.toLowerCase();
-            if (nodeName == 'input') {
-                target.disabled = false;
-                var button = this._get(inst, 'button');
-                $(button).removeClass('ui-state-disabled').disabled = false;
-                inst.trigger.filter('button').
-                    each(function() { this.disabled = false; }).end();
-            }
-            else if (nodeName == 'div' || nodeName == 'span') {
-                var inline = $target.children('.' + this._inlineClass);
-                inline.children().removeClass('ui-state-disabled');
-                inline.find('button').each(
-                    function() { this.disabled = false }
-                )
-            }
-            this._disabledInputs = $.map(this._disabledInputs,
-                function(value) { return (value == target_id ? null : value); }); // delete entry
-        },
-
-        /* Disable the time picker to a jQuery selection.
-           @param  target    element - the target input field or division or span */
-        _disableTimepicker: function(target) {
-            var $target = $(target);
-            var inst = $.data(target, PROP_NAME);
-            if (!$target.hasClass(this.markerClassName)) {
-                return;
-            }
-            var nodeName = target.nodeName.toLowerCase();
-            if (nodeName == 'input') {
-                var button = this._get(inst, 'button');
-
-                $(button).addClass('ui-state-disabled').disabled = true;
-                target.disabled = true;
-
-                inst.trigger.filter('button').
-                    each(function() { this.disabled = true; }).end();
-
-            }
-            else if (nodeName == 'div' || nodeName == 'span') {
-                var inline = $target.children('.' + this._inlineClass);
-                inline.children().addClass('ui-state-disabled');
-                inline.find('button').each(
-                    function() { this.disabled = true }
-                )
-
-            }
-            this._disabledInputs = $.map(this._disabledInputs,
-                function(value) { return (value == target ? null : value); }); // delete entry
-            this._disabledInputs[this._disabledInputs.length] = $target.attr('id');
-        },
-
-        /* Is the first field in a jQuery collection disabled as a timepicker?
-        @param  target_id element - the target input field or division or span
-        @return boolean - true if disabled, false if enabled */
-        _isDisabledTimepicker: function (target_id) {
-            if ( ! target_id) { return false; }
-            for (var i = 0; i < this._disabledInputs.length; i++) {
-                if (this._disabledInputs[i] == target_id) { return true; }
-            }
-            return false;
-        },
-
-        /* Check positioning to remain on screen. */
-        _checkOffset: function (inst, offset, isFixed) {
-            var tpWidth = inst.tpDiv.outerWidth();
-            var tpHeight = inst.tpDiv.outerHeight();
-            var inputWidth = inst.input ? inst.input.outerWidth() : 0;
-            var inputHeight = inst.input ? inst.input.outerHeight() : 0;
-            var viewWidth = document.documentElement.clientWidth + $(document).scrollLeft();
-            var viewHeight = document.documentElement.clientHeight + $(document).scrollTop();
-
-            offset.left -= (this._get(inst, 'isRTL') ? (tpWidth - inputWidth) : 0);
-            offset.left -= (isFixed && offset.left == inst.input.offset().left) ? $(document).scrollLeft() : 0;
-            offset.top -= (isFixed && offset.top == (inst.input.offset().top + inputHeight)) ? $(document).scrollTop() : 0;
-
-            // now check if timepicker is showing outside window viewport - move to a better place if so.
-            offset.left -= Math.min(offset.left, (offset.left + tpWidth > viewWidth && viewWidth > tpWidth) ?
-			Math.abs(offset.left + tpWidth - viewWidth) : 0);
-            offset.top -= Math.min(offset.top, (offset.top + tpHeight > viewHeight && viewHeight > tpHeight) ?
-			Math.abs(tpHeight + inputHeight) : 0);
-
-            return offset;
-        },
-
-        /* Find an object's position on the screen. */
-        _findPos: function (obj) {
-            var inst = this._getInst(obj);
-            var isRTL = this._get(inst, 'isRTL');
-            while (obj && (obj.type == 'hidden' || obj.nodeType != 1)) {
-                obj = obj[isRTL ? 'previousSibling' : 'nextSibling'];
-            }
-            var position = $(obj).offset();
-            return [position.left, position.top];
-        },
-
-        /* Retrieve the size of left and top borders for an element.
-        @param  elem  (jQuery object) the element of interest
-        @return  (number[2]) the left and top borders */
-        _getBorders: function (elem) {
-            var convert = function (value) {
-                return { thin: 1, medium: 2, thick: 3}[value] || value;
-            };
-            return [parseFloat(convert(elem.css('border-left-width'))),
-			parseFloat(convert(elem.css('border-top-width')))];
-        },
-
-
-        /* Close time picker if clicked elsewhere. */
-        _checkExternalClick: function (event) {
-            if (!$.timepicker._curInst) { return; }
-            var $target = $(event.target);
-            if ($target[0].id != $.timepicker._mainDivId &&
-				$target.parents('#' + $.timepicker._mainDivId).length == 0 &&
-				!$target.hasClass($.timepicker.markerClassName) &&
-				!$target.hasClass($.timepicker._triggerClass) &&
-				$.timepicker._timepickerShowing && !($.timepicker._inDialog && $.blockUI))
-                $.timepicker._hideTimepicker();
-        },
-
-        /* Hide the time picker from view.
-        @param  input  element - the input field attached to the time picker */
-        _hideTimepicker: function (input) {
-            var inst = this._curInst;
-            if (!inst || (input && inst != $.data(input, PROP_NAME))) { return; }
-            if (this._timepickerShowing) {
-                var showAnim = this._get(inst, 'showAnim');
-                var duration = this._get(inst, 'duration');
-                var postProcess = function () {
-                    $.timepicker._tidyDialog(inst);
-                    this._curInst = null;
-                };
-                if ($.effects && $.effects[showAnim]) {
-                    inst.tpDiv.hide(showAnim, $.timepicker._get(inst, 'showOptions'), duration, postProcess);
-                }
-                else {
-                    inst.tpDiv[(showAnim == 'slideDown' ? 'slideUp' :
-					    (showAnim == 'fadeIn' ? 'fadeOut' : 'hide'))]((showAnim ? duration : null), postProcess);
-                }
-                if (!showAnim) { postProcess(); }
-
-                this._timepickerShowing = false;
-
-                this._lastInput = null;
-                if (this._inDialog) {
-                    this._dialogInput.css({ position: 'absolute', left: '0', top: '-100px' });
-                    if ($.blockUI) {
-                        $.unblockUI();
-                        $('body').append(this.tpDiv);
-                    }
-                }
-                this._inDialog = false;
-
-                var onClose = this._get(inst, 'onClose');
-                 if (onClose) {
-                     onClose.apply(
-                         (inst.input ? inst.input[0] : null),
- 					    [(inst.input ? inst.input.val() : ''), inst]);  // trigger custom callback
-                 }
-
-            }
-        },
-
-
-
-        /* Tidy up after a dialog display. */
-        _tidyDialog: function (inst) {
-            inst.tpDiv.removeClass(this._dialogClass).unbind('.ui-timepicker');
-        },
-
-        /* Retrieve the instance data for the target control.
-        @param  target  element - the target input field or division or span
-        @return  object - the associated instance data
-        @throws  error if a jQuery problem getting data */
-        _getInst: function (target) {
-            try {
-                return $.data(target, PROP_NAME);
-            }
-            catch (err) {
-                throw 'Missing instance data for this timepicker';
-            }
-        },
-
-        /* Get a setting value, defaulting if necessary. */
-        _get: function (inst, name) {
-            return inst.settings[name] !== undefined ?
-			inst.settings[name] : this._defaults[name];
-        },
-
-        /* Parse existing time and initialise time picker. */
-        _setTimeFromField: function (inst) {
-            if (inst.input.val() == inst.lastVal) { return; }
-            var defaultTime = this._get(inst, 'defaultTime');
-
-            var timeToParse = defaultTime == 'now' ? this._getCurrentTimeRounded(inst) : defaultTime;
-            if ((inst.inline == false) && (inst.input.val() != '')) { timeToParse = inst.input.val() }
-
-            if (timeToParse instanceof Date) {
-                inst.hours = timeToParse.getHours();
-                inst.minutes = timeToParse.getMinutes();
-            } else {
-                var timeVal = inst.lastVal = timeToParse;
-                if (timeToParse == '') {
-                    inst.hours = -1;
-                    inst.minutes = -1;
-                } else {
-                    var time = this.parseTime(inst, timeVal);
-                    inst.hours = time.hours;
-                    inst.minutes = time.minutes;
-                }
-            }
-
-
-            $.timepicker._updateTimepicker(inst);
-        },
-
-        /* Update or retrieve the settings for an existing time picker.
-           @param  target  element - the target input field or division or span
-           @param  name    object - the new settings to update or
-                           string - the name of the setting to change or retrieve,
-                           when retrieving also 'all' for all instance settings or
-                           'defaults' for all global defaults
-           @param  value   any - the new value for the setting
-                       (omit if above is an object or to retrieve a value) */
-        _optionTimepicker: function(target, name, value) {
-            var inst = this._getInst(target);
-            if (arguments.length == 2 && typeof name == 'string') {
-                return (name == 'defaults' ? $.extend({}, $.timepicker._defaults) :
-                    (inst ? (name == 'all' ? $.extend({}, inst.settings) :
-                    this._get(inst, name)) : null));
-            }
-            var settings = name || {};
-            if (typeof name == 'string') {
-                settings = {};
-                settings[name] = value;
-            }
-            if (inst) {
-                extendRemove(inst.settings, settings);
-                if (this._curInst == inst) {
-                    this._hideTimepicker();
-                	this._updateTimepicker(inst);
-                }
-                if (inst.inline) {
-                    this._updateTimepicker(inst);
-                }
-            }
-        },
-
-
-        /* Set the time for a jQuery selection.
-	    @param  target  element - the target input field or division or span
-	    @param  time    String - the new time */
-	    _setTimeTimepicker: function(target, time) {
-		    var inst = this._getInst(target);
-		    if (inst) {
-			    this._setTime(inst, time);
-    			this._updateTimepicker(inst);
-	    		this._updateAlternate(inst, time);
-		    }
-	    },
-
-        /* Set the time directly. */
-        _setTime: function(inst, time, noChange) {
-            var origHours = inst.hours;
-            var origMinutes = inst.minutes;
-            if (time instanceof Date) {
-                inst.hours = time.getHours();
-                inst.minutes = time.getMinutes();
-            } else {
-                var time = this.parseTime(inst, time);
-                inst.hours = time.hours;
-                inst.minutes = time.minutes;
-            }
-
-            if ((origHours != inst.hours || origMinutes != inst.minutes) && !noChange) {
-                inst.input.trigger('change');
-            }
-            this._updateTimepicker(inst);
-            this._updateSelectedValue(inst);
-        },
-
-        /* Return the current time, ready to be parsed, rounded to the closest minute by interval */
-        _getCurrentTimeRounded: function (inst) {
-            var currentTime = new Date(),
-                currentMinutes = currentTime.getMinutes(),
-                minutes_options = this._get(inst, 'minutes'),
-                // round to closest interval
-                adjustedMinutes = Math.round(currentMinutes / minutes_options.interval) * minutes_options.interval;
-            currentTime.setMinutes(adjustedMinutes);
-            return currentTime;
-        },
-
-        /*
-        * Parse a time string into hours and minutes
-        */
-        parseTime: function (inst, timeVal) {
-            var retVal = new Object();
-            retVal.hours = -1;
-            retVal.minutes = -1;
-
-            if(!timeVal)
-                return '';
-
-            var timeSeparator = this._get(inst, 'timeSeparator'),
-                amPmText = this._get(inst, 'amPmText'),
-                showHours = this._get(inst, 'showHours'),
-                showMinutes = this._get(inst, 'showMinutes'),
-                optionalMinutes = this._get(inst, 'optionalMinutes'),
-                showPeriod = (this._get(inst, 'showPeriod') == true),
-                p = timeVal.indexOf(timeSeparator);
-
-            // check if time separator found
-            if (p != -1) {
-                retVal.hours = parseInt(timeVal.substr(0, p), 10);
-                retVal.minutes = parseInt(timeVal.substr(p + 1), 10);
-            }
-            // check for hours only
-            else if ( (showHours) && ( !showMinutes || optionalMinutes ) ) {
-                retVal.hours = parseInt(timeVal, 10);
-            }
-            // check for minutes only
-            else if ( ( ! showHours) && (showMinutes) ) {
-                retVal.minutes = parseInt(timeVal, 10);
-            }
-
-            if (showHours) {
-                var timeValUpper = timeVal.toUpperCase();
-                if ((retVal.hours < 12) && (showPeriod) && (timeValUpper.indexOf(amPmText[1].toUpperCase()) != -1)) {
-                    retVal.hours += 12;
-                }
-                // fix for 12 AM
-                if ((retVal.hours == 12) && (showPeriod) && (timeValUpper.indexOf(amPmText[0].toUpperCase()) != -1)) {
-                    retVal.hours = 0;
-                }
-            }
-
-            return retVal;
-        },
-
-        selectNow: function(event) {
-            var id = $(event.target).attr("data-timepicker-instance-id"),
-                $target = $(id),
-                inst = this._getInst($target[0]);
-            //if (!inst || (input && inst != $.data(input, PROP_NAME))) { return; }
-            var currentTime = new Date();
-            inst.hours = currentTime.getHours();
-            inst.minutes = currentTime.getMinutes();
-            this._updateSelectedValue(inst);
-            this._updateTimepicker(inst);
-            this._hideTimepicker();
-        },
-
-        deselectTime: function(event) {
-            var id = $(event.target).attr("data-timepicker-instance-id"),
-                $target = $(id),
-                inst = this._getInst($target[0]);
-            inst.hours = -1;
-            inst.minutes = -1;
-            this._updateSelectedValue(inst);
-            this._hideTimepicker();
-        },
-
-
-        selectHours: function (event) {
-            var $td = $(event.currentTarget),
-                id = $td.attr("data-timepicker-instance-id"),
-                newHours = parseInt($td.attr("data-hour")),
-                fromDoubleClick = event.data.fromDoubleClick,
-                $target = $(id),
-                inst = this._getInst($target[0]),
-                showMinutes = (this._get(inst, 'showMinutes') == true);
-
-            // don't select if disabled
-            if ( $.timepicker._isDisabledTimepicker($target.attr('id')) ) { return false }
-
-            $td.parents('.ui-timepicker-hours:first').find('a').removeClass('ui-state-active');
-            $td.children('a').addClass('ui-state-active');
-            inst.hours = newHours;
-
-            // added for onMinuteShow callback
-            var onMinuteShow = this._get(inst, 'onMinuteShow'),
-                maxTime = this._get(inst, 'maxTime'),
-                minTime = this._get(inst, 'minTime');
-            if (onMinuteShow || maxTime.minute || minTime.minute) {
-                // this will trigger a callback on selected hour to make sure selected minute is allowed. 
-                this._updateMinuteDisplay(inst);
-            }
-
-            this._updateSelectedValue(inst);
-
-            inst._hoursClicked = true;
-            if ((inst._minutesClicked) || (fromDoubleClick) || (showMinutes == false)) {
-                $.timepicker._hideTimepicker();
-            }
-            // return false because if used inline, prevent the url to change to a hashtag
-            return false;
-        },
-
-        selectMinutes: function (event) {
-            var $td = $(event.currentTarget),
-                id = $td.attr("data-timepicker-instance-id"),
-                newMinutes = parseInt($td.attr("data-minute")),
-                fromDoubleClick = event.data.fromDoubleClick,
-                $target = $(id),
-                inst = this._getInst($target[0]),
-                showHours = (this._get(inst, 'showHours') == true);
-
-            // don't select if disabled
-            if ( $.timepicker._isDisabledTimepicker($target.attr('id')) ) { return false }
-
-            $td.parents('.ui-timepicker-minutes:first').find('a').removeClass('ui-state-active');
-            $td.children('a').addClass('ui-state-active');
-
-            inst.minutes = newMinutes;
-            this._updateSelectedValue(inst);
-
-            inst._minutesClicked = true;
-            if ((inst._hoursClicked) || (fromDoubleClick) || (showHours == false)) {
-                $.timepicker._hideTimepicker();
-                // return false because if used inline, prevent the url to change to a hashtag
-                return false;
-            }
-
-            // return false because if used inline, prevent the url to change to a hashtag
-            return false;
-        },
-
-        _updateSelectedValue: function (inst) {
-            var newTime = this._getParsedTime(inst);
-            if (inst.input) {
-                inst.input.val(newTime);
-                inst.input.trigger('change');
-            }
-            var onSelect = this._get(inst, 'onSelect');
-            if (onSelect) { onSelect.apply((inst.input ? inst.input[0] : null), [newTime, inst]); } // trigger custom callback
-            this._updateAlternate(inst, newTime);
-            return newTime;
-        },
-
-        /* this function process selected time and return it parsed according to instance options */
-        _getParsedTime: function(inst) {
-
-            if (inst.hours == -1 && inst.minutes == -1) {
-                return '';
-            }
-
-            // default to 0 AM if hours is not valid
-            if ((inst.hours < inst.hours.starts) || (inst.hours > inst.hours.ends )) { inst.hours = 0; }
-            // default to 0 minutes if minute is not valid
-            if ((inst.minutes < inst.minutes.starts) || (inst.minutes > inst.minutes.ends)) { inst.minutes = 0; }
-
-            var period = "",
-                showPeriod = (this._get(inst, 'showPeriod') == true),
-                showLeadingZero = (this._get(inst, 'showLeadingZero') == true),
-                showHours = (this._get(inst, 'showHours') == true),
-                showMinutes = (this._get(inst, 'showMinutes') == true),
-                optionalMinutes = (this._get(inst, 'optionalMinutes') == true),
-                amPmText = this._get(inst, 'amPmText'),
-                selectedHours = inst.hours ? inst.hours : 0,
-                selectedMinutes = inst.minutes ? inst.minutes : 0,
-                displayHours = selectedHours ? selectedHours : 0,
-                parsedTime = '';
-
-            // fix some display problem when hours or minutes are not selected yet
-            if (displayHours == -1) { displayHours = 0 }
-            if (selectedMinutes == -1) { selectedMinutes = 0 }
-
-            if (showPeriod) {
-                if (inst.hours == 0) {
-                    displayHours = 12;
-                }
-                if (inst.hours < 12) {
-                    period = amPmText[0];
-                }
-                else {
-                    period = amPmText[1];
-                    if (displayHours > 12) {
-                        displayHours -= 12;
-                    }
-                }
-            }
-
-            var h = displayHours.toString();
-            if (showLeadingZero && (displayHours < 10)) { h = '0' + h; }
-
-            var m = selectedMinutes.toString();
-            if (selectedMinutes < 10) { m = '0' + m; }
-
-            if (showHours) {
-                parsedTime += h;
-            }
-            if (showHours && showMinutes && (!optionalMinutes || m != 0)) {
-                parsedTime += this._get(inst, 'timeSeparator');
-            }
-            if (showMinutes && (!optionalMinutes || m != 0)) {
-                parsedTime += m;
-            }
-            if (showHours) {
-                if (period.length > 0) { parsedTime += this._get(inst, 'periodSeparator') + period; }
-            }
-
-            return parsedTime;
-        },
-
-        /* Update any alternate field to synchronise with the main field. */
-        _updateAlternate: function(inst, newTime) {
-            var altField = this._get(inst, 'altField');
-            if (altField) { // update alternate field too
-                $(altField).each(function(i,e) {
-                    $(e).val(newTime);
-                });
-            }
-        },
-
-        _getTimeAsDateTimepicker: function(input) {
-            var inst = this._getInst(input);
-            if (inst.hours == -1 && inst.minutes == -1) {
-                return '';
-            }
-
-            // default to 0 AM if hours is not valid
-            if ((inst.hours < inst.hours.starts) || (inst.hours > inst.hours.ends )) { inst.hours = 0; }
-            // default to 0 minutes if minute is not valid
-            if ((inst.minutes < inst.minutes.starts) || (inst.minutes > inst.minutes.ends)) { inst.minutes = 0; }
-
-            return new Date(0, 0, 0, inst.hours, inst.minutes, 0);
-        },
-        /* This might look unused but it's called by the $.fn.timepicker function with param getTime */
-        /* added v 0.2.3 - gitHub issue #5 - Thanks edanuff */
-        _getTimeTimepicker : function(input) {
-            var inst = this._getInst(input);
-            return this._getParsedTime(inst);
-        },
-        _getHourTimepicker: function(input) {
-            var inst = this._getInst(input);
-            if ( inst == undefined) { return -1; }
-            return inst.hours;
-        },
-        _getMinuteTimepicker: function(input) {
-            var inst= this._getInst(input);
-            if ( inst == undefined) { return -1; }
-            return inst.minutes;
-        }
-
-    });
-
-
-
-    /* Invoke the timepicker functionality.
-    @param  options  string - a command, optionally followed by additional parameters or
-    Object - settings for attaching new timepicker functionality
-    @return  jQuery object */
-    $.fn.timepicker = function (options) {
-        /* Initialise the time picker. */
-        if (!$.timepicker.initialized) {
-            $(document).mousedown($.timepicker._checkExternalClick);
-            $.timepicker.initialized = true;
-        }
-
-         /* Append timepicker main container to body if not exist. */
-        if ($("#"+$.timepicker._mainDivId).length === 0) {
-            $('body').append($.timepicker.tpDiv);
-        }
-
-        var otherArgs = Array.prototype.slice.call(arguments, 1);
-        if (typeof options == 'string' && (options == 'getTime' || options == 'getTimeAsDate' || options == 'getHour' || options == 'getMinute' ))
-            return $.timepicker['_' + options + 'Timepicker'].
-			    apply($.timepicker, [this[0]].concat(otherArgs));
-        if (options == 'option' && arguments.length == 2 && typeof arguments[1] == 'string')
-            return $.timepicker['_' + options + 'Timepicker'].
-                apply($.timepicker, [this[0]].concat(otherArgs));
-        return this.each(function () {
-            typeof options == 'string' ?
-			$.timepicker['_' + options + 'Timepicker'].
-				apply($.timepicker, [this].concat(otherArgs)) :
-			$.timepicker._attachTimepicker(this, options);
-        });
-    };
-
-    /* jQuery extend now ignores nulls! */
-    function extendRemove(target, props) {
-        $.extend(target, props);
-        for (var name in props)
-            if (props[name] == null || props[name] == undefined)
-                target[name] = props[name];
-        return target;
-    };
-
-    $.timepicker = new Timepicker(); // singleton instance
-    $.timepicker.initialized = false;
-    $.timepicker.uuid = new Date().getTime();
-    $.timepicker.version = "0.3.3";
-
-    // Workaround for #4055
-    // Add another global to avoid noConflict issues with inline event handlers
-    window['TP_jQuery_' + tpuuid] = $;
-
-})(jQuery);
diff --git a/tailbone/static/js/lib/tag-it.min.js b/tailbone/static/js/lib/tag-it.min.js
deleted file mode 100644
index fd6140c8..00000000
--- a/tailbone/static/js/lib/tag-it.min.js
+++ /dev/null
@@ -1,17 +0,0 @@
-(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
-this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
-this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
-0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
-d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
-(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
-b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
-a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
-destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
-b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
-""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
-"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
-if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
-this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
-duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
-null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
-this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);
diff --git a/tailbone/static/js/login.js b/tailbone/static/js/login.js
deleted file mode 100644
index f2a072b8..00000000
--- a/tailbone/static/js/login.js
+++ /dev/null
@@ -1,32 +0,0 @@
-
-$(function() {
-
-    $('input[name="username"]').keydown(function(event) {
-        if (event.which == 13) {
-            $('input[name="password"]').focus().select();
-            return false;
-        }
-        return true;
-    });
-
-    $('form').submit(function() {
-        if (! $('input[name="username"]').val()) {
-            with ($('input[name="username"]').get(0)) {
-                select();
-                focus();
-            }
-            return false;
-        }
-        if (! $('input[name="password"]').val()) {
-            with ($('input[name="password"]').get(0)) {
-                select();
-                focus();
-            }
-            return false;
-        }
-        return true;
-    });
-
-    $('input[name="username"]').focus();
-
-});
diff --git a/tailbone/static/js/tailbone.appsettings.js b/tailbone/static/js/tailbone.appsettings.js
deleted file mode 100644
index ae378931..00000000
--- a/tailbone/static/js/tailbone.appsettings.js
+++ /dev/null
@@ -1,29 +0,0 @@
-
-/************************************************************
- *
- * tailbone.appsettings.js
- *
- * Logic for App Settings page.
- *
- ************************************************************/
-
-
-function show_group(group) {
-    if (group == "(All)") {
-        $('.panel').show();
-    } else {
-        $('.panel').hide();
-        $('.panel[data-groupname="' + group + '"]').show();
-    }
-}
-
-
-$(function() {
-
-    $('#settings-group').on('selectmenuchange', function(event, ui) {
-        show_group(ui.item.value);
-    });
-
-    show_group($('#settings-group').val());
-
-});
diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js
deleted file mode 100644
index 2844c0b4..00000000
--- a/tailbone/static/js/tailbone.batch.js
+++ /dev/null
@@ -1,41 +0,0 @@
-
-/************************************************************
- *
- * tailbone.batch.js
- *
- * Common logic for view/edit batch pages
- *
- ************************************************************/
-
-
-$(function() {
-    
-    $('#execute-batch').click(function() {
-        if (has_execution_options) {
-            $('#execution-options-dialog').dialog({
-                title: "Execution Options",
-                width: 600,
-                modal: true,
-                buttons: [
-                    {
-                        text: "Execute",
-                        click: function(event) {
-                            dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable');
-                            $('form[name="batch-execution"]').submit();
-                        }
-                    },
-                    {
-                        text: "Cancel",
-                        click: function() {
-                            $(this).dialog('close');
-                        }
-                    }
-                ]
-            });
-        } else {
-            $(this).button('option', 'label', "Executing, please wait...").button('disable');
-            $('form[name="batch-execution"]').submit();
-        }
-    });
-
-});
diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js
index fc64a073..b4070fab 100644
--- a/tailbone/static/js/tailbone.buefy.autocomplete.js
+++ b/tailbone/static/js/tailbone.buefy.autocomplete.js
@@ -4,13 +4,61 @@ const TailboneAutocomplete = {
     template: '#tailbone-autocomplete-template',
 
     props: {
+
+        // this is the "input" field name essentially.  primarily is
+        // useful for "traditional" tailbone forms; it normally is not
+        // used otherwise.  it is passed as-is to the buefy
+        // autocomplete component `name` prop
         name: String,
+
+        // the url from which search results are to be obtained.  the
+        // url should expect a GET request with a query string with a
+        // single `term` parameter, and return results as a JSON array
+        // containing objects with `value` and `label` properties.
         serviceUrl: String,
+
+        // callers do not specify this directly but rather by way of
+        // the `v-model` directive.  this component will emit `input`
+        // events when the value changes
         value: String,
+
+        // callers may set an initial label if needed.  this is useful
+        // in cases where the autocomplete needs to "already have a
+        // value" on page load.  for instance when a user fills out
+        // the autocomplete field, but leaves other required fields
+        // blank and submits the form; page will re-load showing
+        // errors but the autocomplete field should remain "set" -
+        // normally it is only given a "value" (e.g. uuid) but this
+        // allows for the "label" to display correctly as well
         initialLabel: String,
+
+        // while the `initialLabel` above is useful for setting the
+        // *initial* label (of course), it cannot be used to
+        // arbitrarily update the label during the component's life.
+        // if you do need to *update* the label after initial page
+        // load, then you should set `assignedLabel` instead.  one
+        // place this happens is in /custorders/create page, where
+        // product autocomplete shows some results, and user clicks
+        // one, but then handler logic can forcibly "swap" the
+        // selection, causing *different* product data to come back
+        // from the server, and autocomplete label should be updated
+        // to match.  this feels a bit awkward still but does work..
+        assignedLabel: String,
+
+        // simple placeholder text for the input box
+        placeholder: String,
+
+        // TODO: pretty sure this can be ignored..?
+        // (should deprecate / remove if so)
+        assignedValue: String,
     },
 
     data() {
+
+        // we want to track the "currently selected option" - which
+        // should normally be `null` to begin with, unless we were
+        // given a value, in which case we use `initialLabel` to
+        // complete the option
         let selected = null
         if (this.value) {
             selected = {
@@ -18,82 +66,71 @@ const TailboneAutocomplete = {
                 label: this.initialLabel,
             }
         }
+
         return {
+
+            // this contains the search results; its contents may
+            // change over time as new searches happen.  the
+            // "currently selected option" should be one of these,
+            // unless it is null
             data: [],
+
+            // this tracks our "currently selected option" - per above
             selected: selected,
-            isFetching: false,
+
+            // since we are wrapping a component which also makes use
+            // of the "value" paradigm, we must separate the concerns.
+            // so we use our own `value` prop to interact with the
+            // caller, but then we use this `buefyValue` data point to
+            // communicate with the buefy autocomplete component.
+            // note that `this.value` will always be either a uuid or
+            // null, whereas `this.buefyValue` may be raw text as
+            // entered by the user.
+            buefyValue: this.value,
+
+            // // TODO: we are "setting" this at the appropriate time,
+            // // but not clear if that actually affects anything.
+            // // should we just remove it?
+            // isFetching: false,
         }
     },
 
-    watch: {
-        value(to, from) {
-            if (from && !to) {
-                this.clearSelection(false)
-            }
-        },
-    },
+    // watch: {
+    //     // TODO: yikes this feels hacky.  what happens is, when the
+    //     // caller explicitly assigns a new UUID value to the tailbone
+    //     // autocomplate component, the underlying buefy autocomplete
+    //     // component was not getting the new value.  so here we are
+    //     // explicitly making sure it is in sync.  this issue was
+    //     // discovered on the "new vendor catalog batch" page
+    //     value(val) {
+    //         this.$nextTick(() => {
+    //             if (this.buefyValue != val) {
+    //                 this.buefyValue = val
+    //             }
+    //         })
+    //     },
+    // },
 
     methods: {
 
-        clearSelection(focus) {
-            if (focus === undefined) {
-                focus = true
-            }
-            this.selected = null
-            this.value = null
-            if (focus) {
-                this.$nextTick(function() {
-                    this.$refs.autocomplete.focus()
-                })
-            }
+        // fetch new search results from the server.  this is invoked
+        // via the `@typing` event from buefy autocomplete component.
+        // the doc at https://buefy.org/documentation/autocomplete
+        // mentions `debounce` as being optional. at one point i
+        // thought it would fix a performance bug; not sure `debounce`
+        // helped but figured might as well leave it
+        getAsyncData: debounce(function (entry) {
 
-            // TODO: should emit event for caller logic (can they cancel?)
-            // $('#' + oid + '-textbox').trigger('autocompletevaluecleared');
-        },
-
-        getDisplayText() {
-            if (this.selected) {
-                return this.selected.label
-            }
-            return ""
-        },
-
-        // TODO: should we allow custom callback? or is event enough?
-        // function (oid) {
-        //     $('#' + oid + '-textbox').on('autocompletevaluecleared', function() {
-        //         ${cleared_callback}();
-        //     });
-        // }
-
-        selectionMade(option) {
-            this.selected = option
-
-            // TODO: should emit event for caller logic (can they cancel?)
-            // $('#' + oid + '-textbox').trigger('autocompletevalueselected',
-            //                                   [ui.item.value, ui.item.label]);
-        },
-
-        // TODO: should we allow custom callback? or is event enough?
-        // function (oid) {
-        //     $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) {
-        //         ${selected_callback}(uuid, label);
-        //     });
-        // }
-
-        itemSelected(value) {
-            if (this.selected || !value) {
-                this.$emit('input', value)
-            }
-        },
-
-        // TODO: buefy example uses `debounce()` here and perhaps we should too?
-        // https://buefy.org/documentation/autocomplete
-        getAsyncData: function (entry) {
+            // since the `@typing` event from buefy component does not
+            // "self-regulate" in any way, we a) use `debounce` above,
+            // but also b) skip the search unless we have at least 3
+            // characters of input from user
             if (entry.length < 3) {
                 this.data = []
                 return
             }
-            this.isFetching = true
+
+            // and perform the search
             this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry))
                 .then(({ data }) => {
                     this.data = data
@@ -102,10 +139,96 @@ const TailboneAutocomplete = {
                     this.data = []
                     throw error
                 })
-                    .finally(() => {
-                        this.isFetching = false
-                    })
-                        },
+        }),
+
+        // this method is invoked via the `@select` event of the buefy
+        // autocomplete component.  the `option` received will either
+        // be `null` or else a simple object with (at least) `value`
+        // and `label` properties
+        selectionMade(option) {
+
+            // we want to keep track of the "currently selected
+            // option" so we can display its label etc.  also this
+            // helps control the visibility of the autocomplete input
+            // field vs. the button which indicates the field has a
+            // value
+            this.selected = option
+
+            // reset the internal value for buefy autocomplete
+            // component.  note that this value will normally hold
+            // either the raw text entered by the user, or a uuid.  we
+            // will not be needing either of those b/c they are not
+            // visible to user once selection is made, and if the
+            // selection is cleared we want user to start over anyway
+            this.buefyValue = null
+
+            // here is where we alert callers to the new value
+            if (option) {
+                this.$emit('new-label', option.label)
+            }
+            this.$emit('input', option ? option.value : null)
+        },
+
+        // set selection to the given option, which should a simple
+        // object with (at least) `value` and `label` properties
+        setSelection(option) {
+            this.$refs.autocomplete.setSelected(option)
+        },
+
+        // clear the field of any value, i.e. set the "currently
+        // selected option" to null.  this is invoked when you click
+        // the button, which is visible while the field has a value.
+        // but callers can invoke it directly as well.
+        clearSelection(focus) {
+
+            // clear selection for the buefy autocomplete component
+            this.$refs.autocomplete.setSelected(null)
+
+            // maybe set focus to our (autocomplete) component
+            if (focus) {
+                this.$nextTick(function() {
+                    this.focus()
+                })
+            }
+        },
+
+        // set focus to this component, which will just set focus to
+        // the buefy autocomplete component
+        focus() {
+            this.$refs.autocomplete.focus()
+        },
+
+        // this determines the "display text" for the button, which is
+        // shown when a selection has been made (or rather, when the
+        // field actually has a value)
+        getDisplayText() {
+
+            // always use the "assigned" label if we have one
+            // TODO: where is this used?  what is the use case?
+            if (this.assignedLabel) {
+                return this.assignedLabel
+            }
+
+            // if we have a "currently selected option" then use its
+            // label.  all search results / options have a `label`
+            // property as that is shown directly in the autocomplete
+            // dropdown.  but if the option also has a `display`
+            // property then that is what we will show in the button.
+            // this way search results can show one thing in the
+            // search dropdown, and another in the button.
+            if (this.selected) {
+                return this.selected.display || this.selected.label
+            }
+
+            // we have nothing to go on here..
+            return ""
+        },
+
+        // returns the "raw" user input from the underlying buefy
+        // autocomplete component
+        getUserInput() {
+            return this.buefyValue
+        },
     },
 }
 
diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js
index fe649380..0b861fd6 100644
--- a/tailbone/static/js/tailbone.buefy.datepicker.js
+++ b/tailbone/static/js/tailbone.buefy.datepicker.js
@@ -11,7 +11,7 @@ const TailboneDatepicker = {
         'icon="calendar-alt"',
         ':date-formatter="formatDate"',
         ':date-parser="parseDate"',
-        ':value="value ? parseDate(value) : null"',
+        ':value="buefyValue"',
         '@input="dateChanged"',
         ':disabled="disabled"',
         'ref="trueDatePicker"',
@@ -26,6 +26,18 @@ const TailboneDatepicker = {
         disabled: Boolean,
     },
 
+    data() {
+        return {
+            buefyValue: this.parseDate(this.value),
+        }
+    },
+
+    watch: {
+        value(to, from) {
+            this.buefyValue = this.parseDate(to)
+        },
+    },
+
     methods: {
 
         formatDate(date) {
@@ -43,9 +55,12 @@ const TailboneDatepicker = {
         },
 
         parseDate(date) {
-            // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
-            var parts = date.split('-')
-            return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+            if (typeof(date) == 'string') {
+                // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
+                var parts = date.split('-')
+                return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+            }
+            return date
         },
 
         dateChanged(date) {
diff --git a/tailbone/static/js/tailbone.buefy.grid.js b/tailbone/static/js/tailbone.buefy.grid.js
deleted file mode 100644
index f4ebf170..00000000
--- a/tailbone/static/js/tailbone.buefy.grid.js
+++ /dev/null
@@ -1,90 +0,0 @@
-
-const GridFilterDateValue = {
-    template: '#grid-filter-date-value-template',
-    props: {
-        value: String,
-        dateRange: Boolean,
-    },
-    data() {
-        return {
-            startDate: null,
-            endDate: null,
-        }
-    },
-    mounted() {
-        if (this.dateRange) {
-            if (this.value.includes('|')) {
-                let values = this.value.split('|')
-                if (values.length == 2) {
-                    this.startDate = values[0]
-                    this.endDate = values[1]
-                } else {
-                    this.startDate = this.value
-                }
-            } else {
-                this.startDate = this.value
-            }
-        } else {
-            this.startDate = this.value
-        }
-    },
-    methods: {
-        focus() {
-            this.$refs.startDate.focus()
-        },
-        startDateChanged(value) {
-            if (this.dateRange) {
-                value += '|' + this.endDate
-            }
-            this.$emit('input', value)
-        },
-        endDateChanged(value) {
-            value = this.startDate + '|' + value
-            this.$emit('input', value)
-        },
-    },
-}
-
-Vue.component('grid-filter-date-value', GridFilterDateValue)
-
-
-const GridFilter = {
-    template: '#grid-filter-template',
-    props: {
-        filter: Object
-    },
-
-    methods: {
-
-        changeVerb() {
-            // set focus to value input, "as quickly as we can"
-            this.$nextTick(function() {
-                this.focusValue()
-            })
-        },
-
-        valuedVerb() {
-            /* this returns true if the filter's current verb should expose value input(s) */
-
-            // if filter has no "valueless" verbs, then all verbs should expose value inputs
-            if (!this.filter.valueless_verbs) {
-                return true
-            }
-
-            // if filter *does* have valueless verbs, check if "current" verb is valueless
-            if (this.filter.valueless_verbs.includes(this.filter.verb)) {
-                return false
-            }
-
-            // current verb is *not* valueless
-            return true
-        },
-
-        focusValue: function() {
-            this.$refs.valueInput.focus()
-            // this.$refs.valueInput.select()
-        }
-    }
-}
-
-Vue.component('grid-filter', GridFilter)
diff --git a/tailbone/static/js/tailbone.buefy.numericinput.js b/tailbone/static/js/tailbone.buefy.numericinput.js
index 47a5e610..b2f2ac0c 100644
--- a/tailbone/static/js/tailbone.buefy.numericinput.js
+++ b/tailbone/static/js/tailbone.buefy.numericinput.js
@@ -4,8 +4,14 @@ const NumericInput = {
         '<b-input',
         ':name="name"',
         ':value="value"',
-        '@focus="focus"',
-        '@blur="blur"',
+        'ref="input"',
+        ':placeholder="placeholder"',
+        ':size="size"',
+        ':icon-pack="iconPack"',
+        ':icon="icon"',
+        ':disabled="disabled"',
+        '@focus="notifyFocus"',
+        '@blur="notifyBlur"',
         '@keydown.native="keyDown"',
         '@input="valueChanged"',
         '>',
@@ -14,17 +20,26 @@ const NumericInput = {
 
     props: {
         name: String,
-        value: String,
+        value: [Number, String],
+        placeholder: String,
+        iconPack: String,
+        icon: String,
+        size: String,
+        disabled: Boolean,
         allowEnter: Boolean
     },
 
     methods: {
 
-        focus(event) {
+        focus() {
+            this.$refs.input.focus()
+        },
+
+        notifyFocus(event) {
             this.$emit('focus', event)
         },
 
-        blur(event) {
+        notifyBlur(event) {
             this.$emit('blur', event)
         },
 
@@ -38,6 +53,10 @@ const NumericInput = {
             }
         },
 
+        select() {
+            this.$el.children[0].select()
+        },
+
         valueChanged(value) {
             this.$emit('input', value)
         }
diff --git a/tailbone/static/js/tailbone.buefy.timepicker.js b/tailbone/static/js/tailbone.buefy.timepicker.js
index 6cca75f3..207a7940 100644
--- a/tailbone/static/js/tailbone.buefy.timepicker.js
+++ b/tailbone/static/js/tailbone.buefy.timepicker.js
@@ -9,15 +9,55 @@ const TailboneTimepicker = {
         'placeholder="Click to select ..."',
         'icon-pack="fas"',
         'icon="clock"',
+        ':value="value ? parseTime(value) : null"',
         'hour-format="12"',
+        '@input="timeChanged"',
+        ':time-formatter="formatTime"',
         '>',
         '</b-timepicker>'
     ].join(' '),
 
     props: {
         name: String,
-        id: String
-    }
+        id: String,
+        value: String,
+    },
+
+    methods: {
+
+        formatTime(time) {
+            if (time === null) {
+                return null
+            }
+
+            let h = time.getHours()
+            let m = time.getMinutes()
+            let s = time.getSeconds()
+
+            h = h < 10 ? '0' + h : h
+            m = m < 10 ? '0' + m : m
+            s = s < 10 ? '0' + s : s
+
+            return h + ':' + m + ':' + s
+        },
+
+        parseTime(time) {
+
+            if (time.getHours) {
+                return time
+            }
+
+            let found = time.match(/^(\d\d):(\d\d):\d\d$/)
+            if (found) {
+                return new Date(null, null, null,
+                                parseInt(found[1]), parseInt(found[2]))
+            }
+        },
+
+        timeChanged(time) {
+            this.$emit('input', time)
+        },
+    },
 }
 
 Vue.component('tailbone-timepicker', TailboneTimepicker)
diff --git a/tailbone/static/js/tailbone.edit-shifts.js b/tailbone/static/js/tailbone.edit-shifts.js
deleted file mode 100644
index 87dc4a21..00000000
--- a/tailbone/static/js/tailbone.edit-shifts.js
+++ /dev/null
@@ -1,193 +0,0 @@
-
-/************************************************************
- *
- * tailbone.edit-shifts.js
- *
- * Common logic for editing time sheet / schedule data.
- *
- ************************************************************/
-
-
-var editing_day = null;
-var new_shift_id = 1;
-
-function add_shift(focus, uuid, start_time, end_time) {
-    var shift = $('#snippets .shift').clone();
-    if (! uuid) {
-        uuid = 'new-' + (new_shift_id++).toString();
-    }
-    shift.attr('data-uuid', uuid);
-    shift.children('input').each(function() {
-        var name = $(this).attr('name') + '-' + uuid;
-        $(this).attr('name', name);
-        $(this).attr('id', name);
-    });
-    shift.children('input[name|="edit_start_time"]').val(start_time || '');
-    shift.children('input[name|="edit_end_time"]').val(end_time || '');
-    $('#day-editor .shifts').append(shift);
-    shift.children('input').timepicker({showPeriod: true});
-    if (focus) {
-        shift.children('input:first').focus();
-    }
-}
-
-function calc_minutes(start_time, end_time) {
-    var start = parseTime(start_time);
-    start = new Date(2000, 0, 1, start.hh, start.mm);
-    var end = parseTime(end_time);
-    end = new Date(2000, 0, 1, end.hh, end.mm);
-    return Math.floor((end - start) / 1000 / 60);
-}
-
-function format_minutes(minutes) {
-    var hours = Math.floor(minutes / 60);
-    if (hours) {
-        minutes -= hours * 60;
-    }
-    return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
-}
-
-// stolen from http://stackoverflow.com/a/1788084
-function parseTime(s) {
-    var part = s.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
-    var hh = parseInt(part[1], 10);
-    var mm = parseInt(part[2], 10);
-    var ap = part[3] ? part[3].toUpperCase() : null;
-    if (ap == 'AM') {
-        if (hh == 12) {
-            hh = 0;
-        }
-    } else if (ap == 'PM') {
-        if (hh != 12) {
-            hh += 12;
-        }
-    }
-    return { hh: hh, mm: mm };
-}
-
-function time_input(shift, type) {
-    var input = shift.children('input[name|="' + type + '_time"]');
-    if (! input.length) {
-        input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
-        shift.append(input);
-    }
-    return input;
-}
-
-function update_row_hours(row) {
-    var minutes = 0;
-    row.find('.day .shift:not(.deleted)').each(function() {
-        var time_range = $.trim($(this).children('span').text()).split(' - ');
-        minutes += calc_minutes(time_range[0], time_range[1]);
-    });
-    row.children('.total').text(minutes ? format_minutes(minutes) : '0');
-}
-
-$(function() {
-
-    $('.timesheet').on('click', '.day', function() {
-        editing_day = $(this);
-        var editor = $('#day-editor');
-        var employee = editing_day.siblings('.employee').text();
-        var date = weekdays[editing_day.get(0).cellIndex - 1];
-        var shifts = editor.children('.shifts');
-        shifts.empty();
-        editing_day.children('.shift:not(.deleted)').each(function() {
-            var uuid = $(this).data('uuid');
-            var time_range = $.trim($(this).children('span').text()).split(' - ');
-            add_shift(false, uuid, time_range[0], time_range[1]);
-        });
-        if (! shifts.children('.shift').length) {
-            add_shift();
-        }
-        editor.dialog({
-            modal: true,
-            title: employee + ' - ' + date,
-            position: {my: 'center', at: 'center', of: editing_day},
-            width: 'auto',
-            autoResize: true,
-            buttons: [
-                {
-                    text: "Update",
-                    click: function() {
-
-                        // TODO: is this hacky? invoking timepicker to format the time values
-                        // in all cases, to avoid "invalid format" from user input
-                        editor.find('.shifts .shift').each(function() {
-                            var start_time = $(this).children('input[name|="edit_start_time"]');
-                            var end_time = $(this).children('input[name|="edit_end_time"]');
-                            $.timepicker._setTime(start_time.data('timepicker'), start_time.val());
-                            $.timepicker._setTime(end_time.data('timepicker'), end_time.val());
-                        });
-
-                        // create / update shifts in time table, as needed
-                        editor.find('.shifts .shift').each(function() {
-                            var uuid = $(this).data('uuid');
-                            var start_time = $(this).children('input[name|="edit_start_time"]').val();
-                            var end_time = $(this).children('input[name|="edit_end_time"]').val();
-                            var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
-                            if (! shift.length) {
-                                shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
-                                shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
-                                               + editing_day.parents('tr:first').data('employee-uuid') + '" />'));
-                                editing_day.append(shift);
-                            }
-                            shift.children('span').text(start_time + ' - ' + end_time);
-                            time_input(shift, 'start').val(date + ' ' + start_time);
-                            time_input(shift, 'end').val(date + ' ' + end_time);
-                        });
-
-                        // remove shifts from time table, as needed
-                        editing_day.children('.shift').each(function() {
-                            var uuid = $(this).data('uuid');
-                            if (! editor.find('.shifts .shift[data-uuid="' + uuid + '"]').length) {
-                                if (uuid.match(/^new-/)) {
-                                    $(this).remove();
-                                } else {
-                                    $(this).addClass('deleted');
-                                    $(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
-                                }
-                            }
-                        });
-
-                        // mark day as modified, close dialog
-                        editing_day.addClass('modified');
-                        $('.save-changes').button('enable');
-                        $('.undo-changes').button('enable');
-                        update_row_hours(editing_day.parents('tr:first'));
-                        editor.dialog('close');
-                        data_modified = true;
-                        okay_to_leave = false;
-                    }
-                },
-                {
-                    text: "Cancel",
-                    click: function() {
-                        editor.dialog('close');
-                    }
-                }
-            ]
-        });
-    });
-
-    $('#day-editor #add-shift').click(function() {
-        add_shift(true);
-    });
-
-    $('#day-editor').on('click', '.shifts button', function() {
-        $(this).parents('.shift:first').remove();
-    });
-
-    $('.save-changes').click(function() {
-        $(this).button('disable').button('option', 'label', "Saving Changes...");
-        okay_to_leave = true;
-        $('#timetable-form').submit();
-    });
-
-    $('.undo-changes').click(function() {
-        $(this).button('disable').button('option', 'label', "Refreshing...");
-        okay_to_leave = true;
-        location.href = location.href;
-    });
-
-});
diff --git a/tailbone/static/js/tailbone.feedback.js b/tailbone/static/js/tailbone.feedback.js
index f6d44875..648c9695 100644
--- a/tailbone/static/js/tailbone.feedback.js
+++ b/tailbone/static/js/tailbone.feedback.js
@@ -1,58 +1,55 @@
 
-$(function() {
+let FeedbackForm = {
+    props: ['action', 'message'],
+    template: '#feedback-template',
+    mixins: [FormPosterMixin],
+    methods: {
 
-    $('#feedback').click(function() {
-        var dialog = $('#feedback-dialog');
-        var form = dialog.find('form');
-        var textarea = form.find('textarea');
-        dialog.find('.referrer .field').html(location.href);
-        textarea.val('');
-        dialog.dialog({
-            title: "User Feedback",
-            width: 600,
-            modal: true,
-            buttons: [
-                {
-                    text: "Send",
-                    click: function(event) {
+        pleaseReplyChanged(value) {
+            this.$nextTick(() => {
+                this.$refs.userEmail.focus()
+            })
+        },
 
-                        var msg = $.trim(textarea.val());
-                        if (! msg) {
-                            alert("Please enter a message.");
-                            textarea.select();
-                            textarea.focus();
-                            return;
-                        }
+        showFeedback() {
+            this.referrer = location.href
+            this.showDialog = true
+            this.$nextTick(function() {
+                this.$refs.textarea.focus()
+            })
+        },
 
-                        disable_button(dialog_button(event));
+        sendFeedback() {
 
-                        var data = {
-                            _csrf: form.find('input[name="_csrf"]').val(),
-                            referrer: location.href,
-                            user: form.find('input[name="user"]').val(),
-                            user_name: form.find('input[name="user_name"]').val(),
-                            message: msg
-                        };
+            let params = {
+                referrer: this.referrer,
+                user: this.userUUID,
+                user_name: this.userName,
+                please_reply_to: this.pleaseReply ? this.userEmail : null,
+                message: this.message.trim(),
+            }
 
-                        $.ajax(form.attr('action'), {
-                            method: 'POST',
-                            data: data,
-                            success: function(data) {
-                                dialog.dialog('close');
-                                alert("Message successfully sent.\n\nThank you for your feedback.");
-                            }
-                        });
+            this.submitForm(this.action, params, response => {
 
-                    }
-                },
-                {
-                    text: "Cancel",
-                    click: function() {
-                        dialog.dialog('close');
-                    }
-                }
-            ]
-        });
-    });
-    
-});
+                this.$buefy.toast.open({
+                    message: "Message sent!  Thank you for your feedback.",
+                    type: 'is-info',
+                    duration: 4000, // 4 seconds
+                })
+
+                this.showDialog = false
+                // clear out message, in case they need to send another
+                this.message = ""
+            })
+        },
+    }
+}
+
+let FeedbackFormData = {
+    referrer: null,
+    userUUID: null,
+    userName: null,
+    pleaseReply: false,
+    userEmail: null,
+    showDialog: false,
+}
diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js
deleted file mode 100644
index 4d3212df..00000000
--- a/tailbone/static/js/tailbone.js
+++ /dev/null
@@ -1,386 +0,0 @@
-
-/************************************************************
- *
- * tailbone.js
- *
- ************************************************************/
-
-
-/*
- * Initialize the disabled filters array.  This is populated from within the
- * /grids/search.mako template.
- */
-var filters_to_disable = [];
-
-
-/*
- * Disables options within the "add filter" dropdown which correspond to those
- * filters already being displayed.  Called from /grids/search.mako template.
- */
-function disable_filter_options() {
-    while (filters_to_disable.length) {
-        var filter = filters_to_disable.shift();
-        var option = $('#add-filter option[value="' + filter + '"]');
-        option.attr('disabled', 'disabled');
-    }
-}
-
-
-/*
- * Convenience function to disable a UI button.
- */
-function disable_button(button, label) {
-    $(button).button('disable');
-    if (label === undefined) {
-        label = $(button).data('working-label') || "Working, please wait...";
-    }
-    if (label) {
-        if (label.slice(-3) != '...') {
-            label += '...';
-        }
-        $(button).button('option', 'label', label);
-    }
-}
-
-
-function disable_submit_button(form, label) {
-    // for some reason chrome requires us to do things this way...
-    // https://stackoverflow.com/questions/16867080/onclick-javascript-stops-form-submit-in-chrome
-    // https://stackoverflow.com/questions/5691054/disable-submit-button-on-form-submit
-    var submit = $(form).find('input[type="submit"]');
-    if (! submit.length) {
-        submit = $(form).find('button[type="submit"]');
-    }
-    if (submit.length) {
-        disable_button(submit, label);
-    }
-}
-
-
-/*
- * Load next / previous page of results to grid.  This function is called on
- * the click event from the pager links, via inline script code.
- */
-function grid_navigate_page(link, url) {
-    var wrapper = $(link).parents('div.grid-wrapper');
-    var grid = wrapper.find('div.grid');
-    wrapper.mask("Loading...");
-    $.get(url, function(data) {
-        wrapper.unmask();
-        grid.replaceWith(data);
-    });
-}
-
-
-/*
- * Fetch the UUID value associated with a table row.
- */
-function get_uuid(obj) {
-    obj = $(obj);
-    if (obj.attr('uuid')) {
-        return obj.attr('uuid');
-    }
-    var tr = obj.parents('tr:first');
-    if (tr.attr('uuid')) {
-        return tr.attr('uuid');
-    }
-    return undefined;
-}
-
-
-/*
- * Return a jQuery object containing a button from a dialog.  This is a
- * convenience function to help with browser differences.  It is assumed
- * that it is being called from within the relevant button click handler.
- * @param {event} event - Click event object.
- */
-function dialog_button(event) {
-    var button = $(event.target);
-
-    // TODO: not sure why this workaround is needed for Chrome..?
-    if (! button.hasClass('ui-button')) {
-        button = button.parents('.ui-button:first');
-    }
-
-    return button;
-}
-
-
-/**
- * Scroll screen as needed to ensure all options are visible, for the given
- * select menu widget.
- */
-function show_all_options(select) {
-    if (! select.is(':visible')) {
-        /*
-         * Note that the following code was largely stolen from
-         * http://brianseekford.com/2013/06/03/how-to-scroll-a-container-or-element-into-view-using-jquery-javascript-in-your-html/
-         */
-
-        var docViewTop = $(window).scrollTop();
-        var docViewBottom = docViewTop + $(window).height();
-
-        var widget = select.selectmenu('menuWidget');
-        var elemTop = widget.offset().top;
-        var elemBottom = elemTop + widget.height();
-
-        var isScrolled = ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
-
-        if (!isScrolled) {
-            if (widget.height() > $(window).height()) { //then just bring to top of the container
-                $(window).scrollTop(elemTop)
-            } else { //try and and bring bottom of container to bottom of screen
-                $(window).scrollTop(elemTop -  ($(window).height() - widget.height()));
-            }
-        }
-    }
-}
-
-
-/*
- * reference to existing timeout warning dialog, if any
- */
-var session_timeout_warning = null;
-
-
-/**
- * Warn user of impending session timeout.
- */
-function timeout_warning() {
-    if (! session_timeout_warning) {
-        session_timeout_warning = $('<div id="session-timeout-warning">' +
-                                    'You will be logged out in <span class="seconds"></span> ' +
-                                    'seconds...</div>');
-    }
-    session_timeout_warning.find('.seconds').text('60');
-    session_timeout_warning.dialog({
-        title: "Session Timeout Warning",
-        modal: true,
-        buttons: {
-            "Stay Logged In": function() {
-                session_timeout_warning.dialog('close');
-                $.get(noop_url, set_timeout_warning_timer);
-            },
-            "Logout Now": function() {
-                location.href = logout_url;
-            }
-        }
-    });
-    window.setTimeout(timeout_warning_update, 1000);
-}
-
-
-/**
- * Decrement the 'seconds' counter for the current timeout warning
- */
-function timeout_warning_update() {
-    if (session_timeout_warning.is(':visible')) {
-        var span = session_timeout_warning.find('.seconds');
-        var seconds = parseInt(span.text()) - 1;
-        if (seconds) {
-            span.text(seconds.toString());
-            window.setTimeout(timeout_warning_update, 1000);
-        } else {
-            location.href = logout_url;
-        }
-    }
-}
-
-
-/**
- * Warn user of impending session timeout.
- */
-function set_timeout_warning_timer() {
-    // timout dialog says we're 60 seconds away, but we actually trigger when
-    // 70 seconds away from supposed timeout, in case of timer drift?
-    window.setTimeout(timeout_warning, session_timeout * 1000 - 70000);
-}
-
-
-/*
- * set initial timer for timeout warning, if applicable
- */
-if (session_timeout) {
-    set_timeout_warning_timer();
-}
-
-
-$(function() {
-
-    /*
-     * enhance buttons
-     */
-    $('button, a.button').button();
-    $('input[type=submit]').button();
-    $('input[type=reset]').button();
-    $('a.button.autodisable').click(function() {
-        disable_button(this);
-    });
-    $('form.autodisable').submit(function() {
-        disable_submit_button(this);
-    });
-
-    // quickie button
-    $('#submit-quickie').button('option', 'icons', {primary: 'ui-icon-zoomin'});
-
-    /*
-     * enhance dropdowns
-     */
-    $('select[auto-enhance="true"]').selectmenu();
-    $('select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) {
-        show_all_options($(this));
-    });
-
-    /* Also automatically disable any buttons marked for that. */
-    $('a.button[disabled=disabled]').button('option', 'disabled', true);
-
-    /*
-     * Apply timepicker behavior to text inputs which are marked for it.
-     */
-    $('input[type=text].timepicker').timepicker({
-        showPeriod: true
-    });
-
-    /*
-     * When filter labels are clicked, (un)check the associated checkbox.
-     */
-    $('body').on('click', '.grid-wrapper .filter label', function() {
-        var checkbox = $(this).prev('input[type="checkbox"]');
-        if (checkbox.prop('checked')) {
-            checkbox.prop('checked', false);
-            return false;
-        }
-        checkbox.prop('checked', true);
-    });
-
-    /*
-     * When a new filter is selected in the "add filter" dropdown, show it in
-     * the UI.  This selects the filter's checkbox and puts focus to its input
-     * element.  If all available filters have been displayed, the "add filter"
-     * dropdown will be hidden.
-     */
-    $('body').on('change', '#add-filter', function() {
-        var select = $(this);
-        var filters = select.parents('div.filters:first');
-        var filter = filters.find('#filter-' + select.val());
-        var checkbox = filter.find('input[type="checkbox"]:first');
-        var input = filter.find(':last-child');
-
-        checkbox.prop('checked', true);
-        filter.show();
-        input.select();
-        input.focus();
-
-        filters.find('input[type="submit"]').show();
-        filters.find('button[type="reset"]').show();
-
-        select.find('option:selected').attr('disabled', true);
-        select.val('add a filter');
-        if (select.find('option:enabled').length == 1) {
-            select.hide();
-        }
-    });
-
-    /*
-     * When user clicks the grid filters search button, perform the search in
-     * the background and reload the grid in-place.
-     */
-    $('body').on('submit', '.filters form', function() {
-        var form = $(this);
-        var wrapper = form.parents('div.grid-wrapper');
-        var grid = wrapper.find('div.grid');
-        var data = form.serializeArray();
-        data.push({name: 'partial', value: true});
-        wrapper.mask("Loading...");
-        $.get(grid.attr('url'), data, function(data) {
-            wrapper.unmask();
-            grid.replaceWith(data);
-        });
-        return false;
-    });
-
-    /*
-     * When user clicks the grid filters reset button, manually clear all
-     * filter input elements, and submit a new search.
-     */
-    $('body').on('click', '.filters form button[type="reset"]', function() {
-        var form = $(this).parents('form');
-        form.find('div.filter').each(function() {
-            $(this).find('div.value input').val('');
-        });
-        form.submit();
-        return false;
-    });
-
-    $('body').on('click', '.grid thead th.sortable a', function() {
-        var th = $(this).parent();
-        var wrapper = th.parents('div.grid-wrapper');
-        var grid = wrapper.find('div.grid');
-        var data = {
-            sort: th.attr('field'),
-            dir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc',
-            page: 1,
-            partial: true
-        };
-        wrapper.mask("Loading...");
-        $.get(grid.attr('url'), data, function(data) {
-            wrapper.unmask();
-            grid.replaceWith(data);
-        });
-        return false;
-    });
-
-    $('body').on('mouseenter', '.grid.hoverable tbody tr', function() {
-        $(this).addClass('hovering');
-    });
-
-    $('body').on('mouseleave', '.grid.hoverable tbody tr', function() {
-        $(this).removeClass('hovering');
-    });
-
-    $('body').on('click', '.grid tbody td.view', function() {
-        var url = $(this).attr('url');
-        if (url) {
-            location.href = url;
-        }
-    });
-
-    $('body').on('click', '.grid tbody td.edit', function() {
-        var url = $(this).attr('url');
-        if (url) {
-            location.href = url;
-        }
-    });
-
-    $('body').on('click', '.grid tbody td.delete', function() {
-        var url = $(this).attr('url');
-        if (url) {
-            if (confirm("Do you really wish to delete this object?")) {
-                location.href = url;
-            }
-        }
-    });
-
-    // $('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() {
-    $('body').on('change', '.grid .pager #grid-page-count', function() {
-        var select = $(this);
-        var wrapper = select.parents('div.grid-wrapper');
-        var grid = wrapper.find('div.grid');
-        var data = {
-            per_page: select.val(),
-            partial: true
-        };
-        wrapper.mask("Loading...");
-        $.get(grid.attr('url'), data, function(data) {
-            wrapper.unmask();
-            grid.replaceWith(data);
-        });
-
-    });
-    
-    $('body').on('click', 'div.dialog button.close', function() {
-        var dialog = $(this).parents('div.dialog:first');
-        dialog.dialog('close');
-    });
-
-});
diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js
deleted file mode 100644
index 432f3170..00000000
--- a/tailbone/static/js/tailbone.mobile.js
+++ /dev/null
@@ -1,308 +0,0 @@
-
-/************************************************************
- *
- * tailbone.mobile.js
- *
- * Global logic for mobile app
- *
- ************************************************************/
-
-
-$(function() {
-
-    // must init header/footer toolbars since ours are "external"
-    $('[data-role="header"], [data-role="footer"]').toolbar({theme: 'a'});
-});
-
-
-$(document).on('pagecontainerchange', function(event, ui) {
-
-    // in some cases (i.e. when no user is logged in) we may want the (external)
-    // header toolbar button to change between pages.  here's how we do that.
-    // note however that we do this *always* even when not technically needed
-    var link = $('[data-role="header"] a:first');
-    var newlink = ui.toPage.find('.replacement-header a');
-    link.text(newlink.text());
-    link.attr('href', newlink.attr('href'));
-    link.removeClass('ui-icon-home ui-icon-user');
-    link.addClass(newlink.attr('class'));
-});
-
-
-$(document).on('click', '#feedback-button', function() {
-
-    // prepare and display 'feedback' popup dialog
-    var popup = $('.ui-page-active #feedback-popup');
-    popup.find('.referrer .field').html(location.href);
-    popup.find('.referrer input').val(location.href);
-    popup.find('.user_name input').val('');
-    popup.find('.message textarea').val('');
-    popup.data('feedback-sent', false);
-    popup.popup('open');
-});
-
-
-$(document).on('click', '#feedback-popup .submit', function() {
-
-    // send message when 'feedback' submit button pressed
-    var popup = $('.ui-page-active #feedback-popup');
-    var form = popup.find('form');
-    $.post(form.attr('action'), form.serializeArray(), function(data) {
-        if (data.ok) {
-
-            // mark "feedback sent" flag, for popupafterclose
-            popup.data('feedback-sent', true);
-            popup.popup('close');
-        }
-    });
-
-});
-
-
-$(document).on('click', '#feedback-form-buttons .cancel', function() {
-
-    // close 'feedback' popup when user clicks Cancel
-    var popup = $('.ui-page-active #feedback-popup');
-    popup.popup('close');
-});
-
-
-$(document).on('popupafterclose', '#feedback-popup', function() {
-
-    // thank the user for their feedback, after msg is sent
-    if ($(this).data('feedback-sent')) {
-        var popup = $('.ui-page-active #feedback-thanks');
-        popup.popup('open');
-    }
-});
-
-
-$(document).on('pagecreate', function() {
-
-    // setup any autocomplete fields
-    $('.field.autocomplete').mobileautocomplete();
-
-});
-
-
-// submit "quick row" form upon autocomplete selection
-$(document).on('autocompleteitemselected', function(event, uuid) {
-    var field = $(event.target);
-    if (field.hasClass('quick-row')) {
-        var form = field.parents('form:first');
-        form.find('[name="quick_entry"]').val(uuid);
-        form.submit();
-    }
-});
-
-
-/**
- * Automatically set focus to certain fields, on various pages
- * TODO: should be letting the form declare a "focus spec" instead, to avoid
- * hard-coding these field names below!
- */
-function setfocus() {
-    var el = null;
-    var queries = [
-        '#username',
-        '#new-purchasing-batch-vendor-text',
-        '#new-receiving-batch-vendor-text',
-    ];
-    $.each(queries, function(i, query) {
-        el = $(query);
-        if (el.is(':visible')) {
-            el.focus();
-            return false;
-        }
-    });
-}
-
-
-$(document).on('pageshow', function() {
-
-    setfocus();
-
-    // if current page has form, which has declared a "focus spec", then try to
-    // set focus accordingly
-    var form = $('.ui-page-active form');
-    if (form) {
-        var spec = form.data('focus');
-        if (spec) {
-            var input = $(spec);
-            if (input) {
-                if (input.is(':visible')) {
-                    input.focus();
-                }
-            }
-        }
-    }
-
-});
-
-
-// handle radio button value change for "simple" grid filter
-$(document).on('change', '.simple-filter .ui-radio', function() {
-    $(this).parents('form:first').submit();
-});
-
-
-// vendor validation for new purchasing batch
-$(document).on('click', 'form[name="new-purchasing-batch"] input[type="submit"]', function() {
-    var $form = $(this).parents('form');
-    if (! $form.find('[name="vendor"]').val()) {
-        alert("Please select a vendor");
-        $form.find('[name="new-purchasing-batch-vendor-text"]').focus();
-        return false;
-    }
-});
-
-
-// disable datasync restart button when clicked
-$(document).on('click', '#datasync-restart', function() {
-    $(this).button('disable');
-});
-
-
-// TODO: this should go away in favor of quick_row approach
-// handle global keypress on product batch "row" page, for sake of scanner wedge
-var product_batch_routes = [
-    'mobile.batch.inventory.view',
-];
-$(document).on('keypress', function(event) {
-    var current_route = $('.ui-page-active [role="main"]').data('route');
-    for (var route of product_batch_routes) {
-        if (current_route == route) {
-            var upc = $('.ui-page-active #upc-search');
-            if (upc.length) {
-                if (upc.is(':focus')) {
-                    if (event.which == 13) {
-                        if (upc.val()) {
-                            $.mobile.navigate(upc.data('url') + '?upc=' + upc.val());
-                        }
-                    }
-                } else {
-                    if (event.which >= 48 && event.which <= 57) { // numeric (qwerty)
-                        upc.val(upc.val() + event.key);
-                        // TODO: these codes are correct for 'keydown' but apparently not 'keypress' ?
-                        // } else if (event.which >= 96 && event.which <= 105) { // numeric (10-key)
-                        //     upc.val(upc.val() + event.key);
-                    } else if (event.which == 13) {
-                        if (upc.val()) {
-                            $.mobile.navigate(upc.data('url') + '?upc=' + upc.val());
-                        }
-                    }
-                    return false;
-                }
-            }
-        }
-    }
-});
-
-
-// handle various keypress events for quick entry forms
-$(document).on('keypress', function(event) {
-    var quick_entry = $('.ui-page-active #quick_entry');
-    if (quick_entry.length) {
-
-        // if user hits enter with quick row input focused, submit form
-        if (quick_entry.is(':focus')) {
-            if (event.which == 13) { // ENTER
-                if (quick_entry.val()) {
-                    var form = quick_entry.parents('form:first');
-                    form.submit();
-                    return false;
-                }
-            }
-
-        } else { // quick row input not focused
-
-            // mimic keyboard wedge if we're so instructed
-            if (quick_entry.data('wedge')) {
-
-                if (event.which >= 48 && event.which <= 57) { // numeric (qwerty)
-                    if (!event.altKey && !event.ctrlKey && !event.metaKey) {
-                        quick_entry.val(quick_entry.val() + event.key);
-                        return false;
-                    }
-
-                // TODO: these codes are correct for 'keydown' but apparently not 'keypress' ?
-                // } else if (event.which >= 96 && event.which <= 105) { // numeric (10-key)
-                //     upc.val(upc.val() + event.key);
-
-                } else if (event.which == 13) { // ENTER
-                    // submit form when ENTER is received via keyboard "wedge"
-                    if (quick_entry.val()) {
-                        var form = quick_entry.parents('form:first');
-                        form.submit();
-                        return false;
-                    }
-                }
-            }
-        }
-    }
-});
-
-
-// when numeric keypad button is clicked, update quantity accordingly
-$(document).on('click', '.quantity-keypad-thingy .keypad-button', function() {
-    var keypad = $(this).parents('.quantity-keypad-thingy');
-    var quantity = keypad.find('.keypad-quantity');
-    var value = quantity.text();
-    var key = $(this).text();
-    var changed = keypad.data('changed');
-    if (key == 'Del') {
-        if (value.length == 1) {
-            quantity.text('0');
-        } else {
-            quantity.text(value.substring(0, value.length - 1));
-        }
-        changed = true;
-    } else if (key == '.') {
-        if (value.indexOf('.') == -1) {
-            if (changed) {
-                quantity.text(value + '.');
-            } else {
-                quantity.text('0.');
-                changed = true;
-            }
-        }
-    } else {
-        if (value == '0') {
-            quantity.text(key);
-            changed = true;
-        } else if (changed) {
-            quantity.text(value + key);
-        } else {
-            quantity.text(key);
-            changed = true;
-        }
-    }
-    if (changed) {
-        keypad.data('changed', true);
-    }
-});
-
-
-// show/hide expiration date per receiving mode selection
-$(document).on('change', 'fieldset.receiving-mode input[name="mode"]', function() {
-    var mode = $(this).val();
-    if (mode == 'expired') {
-        $('#expiration-row').show();
-    } else {
-        $('#expiration-row').hide();
-    }
-});
-
-
-// handle inventory save button
-$(document).on('click', '.inventory-actions button.save', function() {
-    var form = $(this).parents('form:first');
-    var uom = form.find('[name="keypad-uom"]:checked').val();
-    var qty = form.find('.keypad-quantity').text();
-    if (uom == 'CS') {
-        form.find('input[name="cases"]').val(qty);
-    } else { // units
-        form.find('input[name="units"]').val(qty);
-    }
-    form.submit();
-});
diff --git a/tailbone/static/js/tailbone.mobile.receiving.js b/tailbone/static/js/tailbone.mobile.receiving.js
deleted file mode 100644
index d46740ac..00000000
--- a/tailbone/static/js/tailbone.mobile.receiving.js
+++ /dev/null
@@ -1,92 +0,0 @@
-
-/************************************************************
- *
- * tailbone.mobile.receiving.js
- *
- * Global logic for mobile receiving feature
- *
- ************************************************************/
-
-
-// toggle visibility of "Receive" type buttons based on whether vendor is set
-$(document).on('autocompleteitemselected', 'form[name="new-receiving-batch"] .vendor', function(event, uuid) {
-    $('#new-receiving-types').show();
-});
-$(document).on('autocompleteitemcleared', 'form[name="new-receiving-batch"] .vendor', function(event) {
-    $('#new-receiving-types').hide();
-});
-$(document).on('change', 'form[name="new-receiving-batch"] select[name="vendor"]', function(event) {
-    if ($(this).val()) {
-        $('#new-receiving-types').show();
-    } else {
-        $('#new-receiving-types').hide();
-    }
-});
-
-
-// submit new receiving batch form when user clicks "Receive" type button
-$(document).on('click', 'form[name="new-receiving-batch"] .start-receiving', function() {
-    var form = $(this).parents('form');
-    form.find('input[name="workflow"]').val($(this).data('workflow'));
-    form.submit();
-});
-
-
-// submit new receiving batch form when user clicks Purchase Order option
-$(document).on('click', 'form[name="new-receiving-batch"] [data-role="listview"] a', function() {
-    var form = $(this).parents('form');
-    var key = $(this).parents('li').data('key');
-    form.find('[name="workflow"]').val('from_po');
-    form.find('.purchase-order-field').val(key);
-    form.submit();
-    return false;
-});
-
-
-// handle receiving action buttons
-$(document).on('click', 'form.receiving-update .receiving-actions button', function() {
-    var action = $(this).data('action');
-    var form = $(this).parents('form:first');
-    var uom = form.find('[name="keypad-uom"]:checked').val();
-    var mode = form.find('[name="mode"]:checked').val();
-    var qty = form.find('.keypad-quantity').text();
-    if (action == 'add' || action == 'subtract') {
-        if (qty != '0') {
-            if (action == 'subtract') {
-                qty = '-' + qty;
-            }
-
-            if (uom == 'CS') {
-                form.find('[name="cases"]').val(qty);
-            } else { // units
-                form.find('[name="units"]').val(qty);
-            }
-
-            if (action == 'add' && mode == 'expired') {
-                var expiry = form.find('input[name="expiration_date"]');
-                if (! /^\d{4}-\d{2}-\d{2}$/.test(expiry.val())) {
-                    alert("Please enter a valid expiration date.");
-                    expiry.focus();
-                    return;
-                }
-            }
-
-            form.submit();
-        }
-    }
-});
-
-
-// quick-receive (1 case or unit)
-$(document).on('click', 'form.receiving-update .quick-receive', function() {
-    var form = $(this).parents('form:first');
-    form.find('[name="mode"]').val('received');
-    var quantity = $(this).data('quantity');
-    if ($(this).data('uom') == 'CS') {
-        form.find('[name="cases"]').val(quantity);
-    } else {
-        form.find('[name="units"]').val(quantity);
-    }
-    form.find('input[name="quick_receive"]').val('true');
-    form.submit();
-});
diff --git a/tailbone/static/js/tailbone.timesheet.edit.js b/tailbone/static/js/tailbone.timesheet.edit.js
deleted file mode 100644
index f2fcb271..00000000
--- a/tailbone/static/js/tailbone.timesheet.edit.js
+++ /dev/null
@@ -1,267 +0,0 @@
-
-/************************************************************
- *
- * tailbone.timesheet.edit.js
- *
- * Common logic for editing time sheet / schedule data.
- *
- ************************************************************/
-
-
-var editing_day = null;
-var new_shift_id = 1;
-var show_timepicker = true;
-
-
-/*
- * Add a new shift entry to the editor dialog.
- * @param {boolean} focus - Whether to set focus to the start_time input
- *   element after adding the shift.
- * @param {string} uuid - UUID value for the shift, if applicable.
- * @param {string} start_time - Value for start_time input element.
- * @param {string} end_time - Value for end_time input element.
- */
-
-function add_shift(focus, uuid, start_time, end_time) {
-    var shift = $('#snippets .shift').clone();
-    if (! uuid) {
-        uuid = 'new-' + (new_shift_id++).toString();
-    }
-    shift.attr('data-uuid', uuid);
-    shift.children('input').each(function() {
-        var name = $(this).attr('name') + '-' + uuid;
-        $(this).attr('name', name);
-        $(this).attr('id', name);
-    });
-    shift.children('input[name|="edit_start_time"]').val(start_time);
-    shift.children('input[name|="edit_end_time"]').val(end_time);
-    $('#day-editor .shifts').append(shift);
-
-    // maybe trick timepicker into never showing itself
-    var args = {showPeriod: true};
-    if (! show_timepicker) {
-        args.showOn = 'button';
-        args.button = '#nevershow';
-    }
-    shift.children('input').timepicker(args);
-
-    if (focus) {
-        shift.children('input:first').focus();
-    }
-}
-
-
-/**
- * Calculate the number of minutes between given the times.
- * @param {string} start_time - Value from start_time input element.
- * @param {string} end_time - Value from end_time input element.
- */
-function calc_minutes(start_time, end_time) {
-    var start = parseTime(start_time);
-    var end = parseTime(end_time);
-    if (start && end) {
-        start = new Date(2000, 0, 1, start.hh, start.mm);
-        end = new Date(2000, 0, 1, end.hh, end.mm);
-        return Math.floor((end - start) / 1000 / 60);
-    }
-}
-
-
-/**
- * Converts a number of minutes into string of HH:MM format.
- * @param {number} minutes - Number of minutes to be converted.
- */
-function format_minutes(minutes) {
-    var hours = Math.floor(minutes / 60);
-    if (hours) {
-        minutes -= hours * 60;
-    }
-    return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
-}
-
-
-/**
- * NOTE: most of this logic was stolen from http://stackoverflow.com/a/1788084
- *
- * Parse a time string and convert to simple object with hh and mm keys.
- * @param {string} time - Time value in 'HH:MM PP' format, or close enough.
- */
-function parseTime(time) {
-    if (time) {
-        var part = time.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
-        if (part) {
-            var hh = parseInt(part[1], 10);
-            var mm = parseInt(part[2], 10);
-            var ap = part[3] ? part[3].toUpperCase() : null;
-            if (ap == 'AM') {
-                if (hh == 12) {
-                    hh = 0;
-                }
-            } else if (ap == 'PM') {
-                if (hh != 12) {
-                    hh += 12;
-                }
-            }
-            return { hh: hh, mm: mm };
-        }
-    }
-}
-
-
-/**
- * Return a jQuery object containing the hidden start or end time input element
- * for the shift (i.e. within the *main* timesheet form).  This will create the
- * input if necessary.
- * @param {jQuery} shift - A jQuery object for the shift itself.
- * @param {string} type - Should be 'start' or 'end' only.
- */
-function time_input(shift, type) {
-    var input = shift.children('input[name|="' + type + '_time"]');
-    if (! input.length) {
-        input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
-        shift.append(input);
-    }
-    return input;
-}
-
-
-/**
- * Update the weekly hour total for a given row (employee).
- * @param {jQuery} row - A jQuery object for the row to be updated.
- */
-function update_row_hours(row) {
-    var minutes = 0;
-    row.find('.day .shift:not(.deleted)').each(function() {
-        var time_range = $.trim($(this).children('span').text()).split(' - ');
-        minutes += calc_minutes(time_range[0], time_range[1]);
-    });
-    row.children('.total').text(minutes ? format_minutes(minutes) : '0');
-}
-
-
-/**
- * Clean up user input within the editor dialog, e.g. '8:30am' => '08:30 AM'.
- * This also should ensure invalid input will become empty string.
- */
-function cleanup_editor_input() {
-    // TODO: is this hacky? invoking timepicker to format the time values
-    // in all cases, to avoid "invalid format" from user input
-    var backward = false;
-    $('#day-editor .shifts .shift').each(function() {
-        var start_time = $(this).children('input[name|="edit_start_time"]');
-        var end_time = $(this).children('input[name|="edit_end_time"]');
-        $.timepicker._setTime(start_time.data('timepicker'), start_time.val() || '??');
-        $.timepicker._setTime(end_time.data('timepicker'), end_time.val() || '??');
-        var t_start = parseTime(start_time.val());
-        var t_end = parseTime(end_time.val());
-        if (t_start && t_end) {
-            if ((t_start.hh > t_end.hh) || ((t_start.hh == t_end.hh) && (t_start.mm > t_end.mm))) {
-                alert("Start time falls *after* end time!  Please fix...");
-                start_time.focus().select();
-                backward = true;
-                return false;
-            }
-        }
-    });
-    return !backward;
-}
-
-
-/**
- * Update the main timesheet table based on editor dialog input.  This updates
- * both the displayed timesheet, as well as any hidden input elements on the
- * main form.
- */
-function update_timetable() {
-
-    var date = weekdays[editing_day.get(0).cellIndex - 1];
-
-    // add or update
-    $('#day-editor .shifts .shift').each(function() {
-        var uuid = $(this).data('uuid');
-        var start_time = $(this).children('input[name|="edit_start_time"]').val();
-        var end_time = $(this).children('input[name|="edit_end_time"]').val();
-        var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
-        if (! shift.length) {
-            if (! (start_time || end_time)) {
-                return;
-            }
-            shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
-            shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
-                           + editing_day.parents('tr:first').data('employee-uuid') + '" />'));
-            editing_day.append(shift);
-        }
-        shift.children('span').text((start_time || '??') + ' - ' + (end_time || '??'));
-        start_time = start_time ? (date + ' ' + start_time) : '';
-        end_time = end_time ? (date + ' ' + end_time) : '';
-        time_input(shift, 'start').val(start_time);
-        time_input(shift, 'end').val(end_time);
-    });
-
-
-    // remove / mark for deletion
-    editing_day.children('.shift').each(function() {
-        var uuid = $(this).data('uuid');
-        if (! $('#day-editor .shifts .shift[data-uuid="' + uuid + '"]').length) {
-            if (uuid.match(/^new-/)) {
-                $(this).remove();
-            } else {
-                $(this).addClass('deleted');
-                $(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
-            }
-        }
-    });
-
-}
-
-
-/**
- * Perform full "save" action for time sheet form, direct from day editor dialog.
- */
-function save_dialog() {
-    if (! cleanup_editor_input()) {
-        return false;
-    }
-    var save = $('#day-editor').parents('.ui-dialog').find('.ui-dialog-buttonpane button:first');
-    save.button('disable').button('option', 'label', "Saving...");
-    update_timetable();
-    $('#timetable-form').submit();
-    return true;
-}
-
-
-/*
- * on document load...
- */
-$(function() {
-
-    /*
-     * Within editor dialog, clicking Add Shift button will create a new/empty
-     * shift and set focus to its start_time input.
-     */
-    $('#day-editor #add-shift').click(function() {
-        add_shift(true);
-    });
-
-    /*
-     * Within editor dialog, clicking a shift's "trash can" button will remove
-     * the shift.
-     */
-    $('#day-editor').on('click', '.shifts button', function() {
-        $(this).parents('.shift:first').remove();
-    });
-
-    /*
-     * Within editor dialog, Enter press within time field "might" trigger
-     * save.  Note that this is only done for timesheet editing, not schedule.
-     */
-    $('#day-editor').on('keydown', '.shifts input[type="text"]', function(event) {
-        if (!show_timepicker) { // TODO: this implies too much, should be cleaner
-            if (event.which == 13) {
-                save_dialog();
-                return false;
-            }
-        }
-    });
-
-});
diff --git a/tailbone/static/themes/bobcat/css/base.css b/tailbone/static/themes/bobcat/css/base.css
deleted file mode 100644
index 758ea304..00000000
--- a/tailbone/static/themes/bobcat/css/base.css
+++ /dev/null
@@ -1,114 +0,0 @@
-
-/* /\****************************** */
-/*  * General */
-/*  ******************************\/ */
-
-/* * { */
-/*     margin: 0px; */
-/* } */
-
-/* body { */
-/*     font-family: Verdana, Arial, sans-serif; */
-/*     font-size: 11pt; */
-/* } */
-
-/* a { */
-/*     color: #0972a5; */
-/*     text-decoration: none; */
-/* } */
-
-/* a:hover { */
-/*     text-decoration: underline; */
-/* } */
-
-/* h1 { */
-/*     margin-bottom: 15px; */
-/* } */
-
-/* h2 { */
-/*     font-size: 12pt; */
-/*     margin: 20px auto 10px auto; */
-/* } */
-
-/* li { */
-/*     line-height: 2em; */
-/* } */
-
-/* p { */
-/*     margin-bottom: 5px; */
-/* } */
-
-/* .left { */
-/*     float: left; */
-/*     text-align: left; */
-/* } */
-
-/* .right { */
-/*     text-align: right; */
-/* } */
-
-/* .wrapper { */
-/*     overflow: auto; */
-/* } */
-
-/* div.buttons { */
-/*     clear: both; */
-/*     margin-top: 10px; */
-/* } */
-
-/* div.dialog { */
-/*     display: none; */
-/* } */
-
-/* div.flash-message { */
-/*     background-color: #dddddd; */
-/*     margin-bottom: 8px; */
-/*     padding: 3px; */
-/* } */
-
-/* div.flash-messages div.ui-state-highlight { */
-/*     padding: .3em; */
-/*     margin-bottom: 8px; */
-/* } */
-
-/* div.error-messages div.ui-state-error { */
-/*     padding: .3em; */
-/*     margin-bottom: 8px; */
-/* } */
-
-/* .flash-messages, */
-/* .error-messages { */
-/*     margin: 0.5em 0 0 0; */
-/* } */
-
-/* ul.error { */
-/*     color: #dd6666; */
-/*     font-weight: bold; */
-/*     padding: 0px; */
-/* } */
-
-/* ul.error li { */
-/*     list-style-type: none; */
-/* } */
-
-/* /\****************************** */
-/*  * jQuery UI tweaks */
-/*  ******************************\/ */
-
-/* ul.ui-menu { */
-/*     max-height: 30em; */
-/* } */
-
-/******************************
- * tweaks for root user
- ******************************/
-
-.navbar .navbar-end .navbar-link.root-user,
-.navbar .navbar-end .navbar-link.root-user:hover,
-.navbar .navbar-end .navbar-link.root-user.is_active,
-.navbar .navbar-end .navbar-item.root-user,
-.navbar .navbar-end .navbar-item.root-user:hover,
-.navbar .navbar-end .navbar-item.root-user.is_active {
-    background-color: red;
-    font-weight: bold;
-}
diff --git a/tailbone/static/themes/bobcat/css/forms.css b/tailbone/static/themes/bobcat/css/forms.css
deleted file mode 100644
index 3ae22da3..00000000
--- a/tailbone/static/themes/bobcat/css/forms.css
+++ /dev/null
@@ -1,141 +0,0 @@
-
-/* /\****************************** */
-/*  * Form Wrapper */
-/*  ******************************\/ */
-
-/* div.form-wrapper { */
-/*     overflow: auto; */
-/* } */
-
-
-/******************************
- * context menu
- ******************************/
-
-/* #context-menu { */
-/*     /\* background-color: #ddcccc; *\/ */
-/*     /\* background-color: green; *\/ */
-/*     float: right; */
-/*     /\* list-style-type: none; *\/ */
-/*     /\* margin: 0px; *\/ */
-/*     text-align: right; */
-/* } */
-
-/* div.form-wrapper ul.context-menu li { */
-/*     line-height: 2em; */
-/* } */
-
-
-/* /\****************************** */
-/*  * "object helper" panel */
-/*  ******************************\/ */
-
-/* .object-helper { */
-/*     border: 1px solid black; */
-/*     float: right; */
-/*     margin-top: 1em; */
-/*     padding: 1em; */
-/*     width: 20em; */
-/* } */
-
-/* .object-helper-content { */
-/*     margin-top: 1em; */
-/* } */
-
-
-/******************************
- * forms
- ******************************/
-
-/* div.form, */
-/* div.fieldset-form, */
-/* div.fieldset { */
-/*     clear: left; */
-/*     float: left; */
-/*     margin-top: 10px; */
-/* } */
-
-/* TODO: replace this with bulma equivalent */
-.form {
-    padding-left: 5em;
-}
-
-
-/******************************
- * fieldsets
- ******************************/
-
-/* TODO: replace this with bulma equivalent */
-.field-wrapper {
-    clear: both;
-    min-height: 30px;
-    overflow: auto;
-    margin: 15px;
-}
-
-/* .field-wrapper.with-error { */
-/*     background-color: #ddcccc; */
-/*     border: 2px solid #dd6666; */
-/*     padding-bottom: 1em; */
-/* } */
-
-/* TODO: replace this with bulma equivalent */
-.field-wrapper .field-row {
-    display: table-row;
-}
-
-/* TODO: replace this with bulma equivalent */
-.field-wrapper label {
-    display: table-cell;
-    vertical-align: top;
-    width: 18em;
-    font-weight: bold;
-    padding-top: 2px;
-    white-space: nowrap;
-}
-
-/* .field-wrapper.with-error label { */
-/*     padding-left: 1em; */
-/* } */
-
-/* .field-wrapper .field-error { */
-/*     padding: 1em 0 0.5em 1em; */
-/* } */
-
-/* .field-wrapper .field-error .error-msg { */
-/*     color: #dd6666; */
-/*     font-weight: bold; */
-/* } */
-
-/* TODO: replace this with bulma equivalent */
-.field-wrapper .field {
-    display: table-cell;
-    line-height: 25px;
-}
-
-/* .field-wrapper .field input[type=text], */
-/* .field-wrapper .field input[type=password], */
-/* .field-wrapper .field select, */
-/* .field-wrapper .field textarea { */
-/*     width: 320px; */
-/* } */
-
-/* label input[type="checkbox"], */
-/* label input[type="radio"] { */
-/*     margin-right: 0.5em; */
-/* } */
-
-/* .field ul { */
-/*     margin: 0px; */
-/*     padding-left: 15px; */
-/* } */
-
-
-/* /\****************************** */
-/*  * Buttons */
-/*  ******************************\/ */
-
-/* div.buttons { */
-/*     clear: both; */
-/*     margin: 10px 0px; */
-/* } */
diff --git a/tailbone/static/themes/bobcat/css/layout.css b/tailbone/static/themes/bobcat/css/layout.css
deleted file mode 100644
index 1c490cbe..00000000
--- a/tailbone/static/themes/bobcat/css/layout.css
+++ /dev/null
@@ -1,208 +0,0 @@
-
-/******************************
- * main layout
- ******************************/
-
-body {
-    display: flex;
-    flex-direction: column;
-    min-height: 100vh;
-}
-
-.content-wrapper {
-    display: flex;
-    flex: 1;
-    flex-direction: column;
-    justify-content: space-between;
-}
-
-
-/******************************
- * header
- ******************************/
-
-header .level {
-    /* height: 60px; */
-    line-height: 60px;
-    padding-left: 0.5em;
-    padding-right: 0.5em;
-}
-
-header .level #header-logo {
-    display: inline-block;
-}
-
-header .level .global-title,
-header .level-left .global-title {
-    font-size: 2em;
-    font-weight: bold;
-}
-
-header .level #current-context,
-header .level-left #current-context {
-    font-size: 2em;
-    font-weight: bold;
-}
-
-header .level #current-context span,
-header .level-left #current-context span {
-    margin-right: 10px;
-}
-
-header .level .theme-picker {
-    display: inline-flex;
-}
-
-/* header .global .grid-nav { */
-/*     display: inline-block; */
-/*     font-size: 16px; */
-/*     font-weight: bold; */
-/*     line-height: 60px; */
-/*     margin-left: 5em; */
-/* } */
-
-/* header .global .grid-nav .ui-button, */
-/* header .global .grid-nav span.viewing { */
-/*     margin-left: 1em; */
-/* } */
-
-#content-title h1 {
-    font-size: 2em;
-}
-
-/* /\****************************** */
-/*  * Logo */
-/*  ******************************\/ */
-
-/* #logo { */
-/*     display: block; */
-/*     margin: 40px auto; */
-/* } */
-
-
-/******************************
- * content
- ******************************/
-
-#page-body {
-    padding: 0.4em;
-}
-
-/* body > #body-wrapper { */
-/*     margin: 0px; */
-/*     position: relative; */
-/* } */
-
-/* .content-wrapper { */
-/*     height: 100%; */
-/*     padding-bottom: 30px; */
-/* } */
-
-/* #scrollpane { */
-/*     height: 100%; */
-/* } */
-
-/* #scrollpane .inner-content { */
-/*     padding: 0 0.5em 0.5em 0.5em; */
-/* } */
-
-
-/******************************
- * context menu
- ******************************/
-
-#context-menu {
-    text-align: right;
-    white-space: nowrap;
-}
-
-/******************************
- * "object helper" panel
- ******************************/
-
-.object-helper {
-    border: 1px solid black;
-    margin: 1em;
-    padding: 1em;
-    min-width: 20em;
-}
-
-.object-helper-content {
-    margin-top: 1em;
-}
-
-/* /\****************************** */
-/*  * Panels */
-/*  ******************************\/ */
-
-/* .panel-wrapper { */
-/*     float: left; */
-/*     margin-right: 15px; */
-/*     width: 40%; */
-/* } */
-
-/* .panel, */
-/* .panel-grid { */
-/*     border-left: 1px solid Black; */
-/*     margin-bottom: 15px; */
-/* } */
-
-/* .panel { */
-/*     border-bottom: 1px solid Black; */
-/*     border-right: 1px solid Black; */
-/*     padding: 0px; */
-/* } */
-
-/* .panel h2, */
-/* .panel-grid h2 { */
-/*     border-bottom: 1px solid Black; */
-/*     border-top: 1px solid Black; */
-/*     padding: 5px; */
-/*     margin: 0px; */
-/* } */
-
-/* .panel-grid h2 { */
-/*     border-right: 1px solid Black; */
-/* } */
-
-/* .panel-body { */
-/*     overflow: auto; */
-/*     padding: 5px; */
-/* } */
-
-/******************************
- * feedback
- ******************************/
-
-#feedback-dialog {
-    display: none;
-}
-
-#feedback-dialog p {
-    margin-top: 1em;
-}
-
-#feedback-dialog .red {
-    color: red;
-    font-weight: bold;
-}
-
-#feedback-dialog .field-wrapper {
-    margin-top: 1em;
-    padding: 0;
-}
-
-#feedback-dialog .field {
-    margin-bottom: 0;
-    margin-top: 0.5em;
-}
-
-#feedback-dialog .referrer .field {
-    clear: both;
-    float: none;
-    margin-top: 1em;
-}
-
-#feedback-dialog textarea {
-    width: auto;
-}
diff --git a/tailbone/static/themes/dodo/css/admin.css b/tailbone/static/themes/dodo/css/admin.css
deleted file mode 100644
index a362b64f..00000000
--- a/tailbone/static/themes/dodo/css/admin.css
+++ /dev/null
@@ -1,84 +0,0 @@
-/* copied from https://github.com/dansup/bulma-templates/blob/master/css/admin.css */
-
-html, body {
-  font-family: 'Open Sans', serif;
-  font-size: 16px;
-  line-height: 1.5;
-  height: 100%;
-  background: #ECF0F3;
-}
-nav.navbar {
-  border-top: 4px solid #276cda;
-  margin-bottom: 1rem;
-}
-.navbar-item.brand-text {
-  font-weight: 300;
-}
-.navbar-item, .navbar-link {
-  font-size: 14px;
-  font-weight: 700;
-}
-.columns {
-  width: 100%;
-  height: 100%;
-  margin-left: 0;
-}
-.menu-label {
-  color: #8F99A3;
-  letter-spacing: 1.3;
-  font-weight: 700;
-}
-.menu-list a {
-  color: #0F1D38;
-  font-size: 14px;
-  font-weight: 700;
-}
-.menu-list a:hover {
-  background-color: transparent;
-  color: #276cda;
-}
-.menu-list a.is-active {
-  background-color: transparent;
-  color: #276cda;
-  font-weight: 700;
-}
-.card {
-  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.18);
-  margin-bottom: 2rem;
-}
-.card-header-title {
-  color: #8F99A3;
-  font-weight: 400;
-}
-.info-tiles {
-  margin: 1rem 0;
-}
-.info-tiles .subtitle {
-  font-weight: 300;
-  color: #8F99A3;
-}
-.hero.welcome.is-info {
-  background: #36D1DC;
-  background: -webkit-linear-gradient(to right, #5B86E5, #36D1DC);
-  background: linear-gradient(to right, #5B86E5, #36D1DC);
-}
-.hero.welcome .title, .hero.welcome .subtitle {
-  color: hsl(192, 17%, 99%);
-}
-.card .content {
-  font-size: 14px;
-}
-.card-footer-item {
-  font-size: 14px;
-  font-weight: 700;
-  color: #8F99A3;
-}
-.card-footer-item:hover {
-}
-.card-table .table {
-  margin-bottom: 0;
-}
-.events-card .card-table {
-  max-height: 250px;
-  overflow-y: scroll;
-}
\ No newline at end of file
diff --git a/tailbone/static/themes/dodo/css/base.css b/tailbone/static/themes/dodo/css/base.css
deleted file mode 100644
index 27f44c9f..00000000
--- a/tailbone/static/themes/dodo/css/base.css
+++ /dev/null
@@ -1,11 +0,0 @@
-
-/******************************
- * tweaks for root user
- ******************************/
-
-.navbar .navbar-menu .navbar-link.root-user,
-.navbar .navbar-menu .navbar-item.root-user,
-.navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link.root-user,
-.navbar.is-white .navbar-item.has-dropdown:hover .navbar-link.root-user {
-    background-color: red;
-}
diff --git a/tailbone/static/themes/dodo/js/bulma.js b/tailbone/static/themes/dodo/js/bulma.js
deleted file mode 100644
index a2e2dc9a..00000000
--- a/tailbone/static/themes/dodo/js/bulma.js
+++ /dev/null
@@ -1,12 +0,0 @@
-// copied from https://github.com/dansup/bulma-templates/blob/master/js/bulma.js
-
-// The following code is based off a toggle menu by @Bradcomp
-// source: https://gist.github.com/Bradcomp/a9ef2ef322a8e8017443b626208999c1
-(function() {
-    var burger = document.querySelector('.burger');
-    var menu = document.querySelector('#'+burger.dataset.target);
-    burger.addEventListener('click', function() {
-        burger.classList.toggle('is-active');
-        menu.classList.toggle('is-active');
-    });
-})();
diff --git a/tailbone/static/themes/falafel/css/filters.css b/tailbone/static/themes/falafel/css/filters.css
deleted file mode 100644
index 6deff7b0..00000000
--- a/tailbone/static/themes/falafel/css/filters.css
+++ /dev/null
@@ -1,22 +0,0 @@
-
-/******************************
- * Grid Filters
- ******************************/
-
-.filters .filter {
-    margin-bottom: 0.5rem;
-}
-
-.filters .filter-fieldname .field,
-.filters .filter-fieldname .field label {
-    width: 100%;
-}
-
-.filters .filter-fieldname .field label {
-    justify-content: left;
-}
-
-.filters .filter-verb .select,
-.filters .filter-verb .select select {
-    width: 100%;
-}
diff --git a/tailbone/static/themes/falafel/css/forms.css b/tailbone/static/themes/falafel/css/forms.css
deleted file mode 100644
index b5b10c74..00000000
--- a/tailbone/static/themes/falafel/css/forms.css
+++ /dev/null
@@ -1,28 +0,0 @@
-
-/******************************
- * forms
- ******************************/
-
-/* note that this should only apply to "normal" primary forms */
-/* TODO: replace this with bulma equivalent */
-.form {
-    padding-left: 5em;
-}
-
-/* note that this should only apply to "normal" primary forms */
-.form-wrapper .form .field.is-horizontal .field-label .label {
-    text-align: left;
-    white-space: nowrap;
-    width: 18em;
-}
-
-/* note that this should only apply to "normal" primary forms */
-.form-wrapper .form .field.is-horizontal .field-body {
-    min-width: 30em;
-}
-
-/* note that this should only apply to "normal" primary forms */
-.form-wrapper .form .field.is-horizontal .field-body .select,
-.form-wrapper .form .field.is-horizontal .field-body .select select {
-    width: 100%;
-}
diff --git a/tailbone/static/themes/falafel/css/grids.css b/tailbone/static/themes/falafel/css/grids.css
deleted file mode 100644
index b24e9cb0..00000000
--- a/tailbone/static/themes/falafel/css/grids.css
+++ /dev/null
@@ -1,15 +0,0 @@
-
-/********************************************************************************
- * grids.css
- *
- * Style tweaks for the Buefy grids.
- ********************************************************************************/
-
-
-/******************************
- * actions column
- ******************************/
-
-a.grid-action {
-    white-space: nowrap;
-}
diff --git a/tailbone/static/themes/falafel/css/layout.css b/tailbone/static/themes/falafel/css/layout.css
deleted file mode 100644
index b22b6f97..00000000
--- a/tailbone/static/themes/falafel/css/layout.css
+++ /dev/null
@@ -1,123 +0,0 @@
-
-/******************************
- * main layout
- ******************************/
-
-body {
-    display: flex;
-    flex-direction: column;
-    min-height: 100vh;
-}
-
-.content-wrapper {
-    display: flex;
-    flex: 1;
-    flex-direction: column;
-    justify-content: space-between;
-}
-
-
-/******************************
- * header
- ******************************/
-
-header .level {
-    /* TODO: not sure what this 60px was supposed to do? but it broke the */
-    /* styles for the feedback dialog, so disabled it is.
-    /* height: 60px; */
-    /* line-height: 60px; */
-    padding-left: 0.5em;
-    padding-right: 0.5em;
-}
-
-header .level #header-logo {
-    display: inline-block;
-}
-
-header .level .global-title,
-header .level-left .global-title {
-    font-size: 2em;
-    font-weight: bold;
-}
-
-header .level #current-context,
-header .level-left #current-context {
-    font-size: 2em;
-    font-weight: bold;
-}
-
-header .level #current-context span,
-header .level-left #current-context span {
-    margin-right: 10px;
-}
-
-header .level .theme-picker {
-    display: inline-flex;
-}
-
-#content-title {
-    padding: 0.3rem;
-}
-
-#content-title h1 {
-    font-size: 2rem;
-    margin-left: 1rem;
-}
-
-/******************************
- * content
- ******************************/
-
-#page-body {
-    padding: 0.4em;
-}
-
-/******************************
- * context menu
- ******************************/
-
-#context-menu {
-    margin-bottom: 1em;
-    margin-left: 1em;
-    text-align: right;
-    white-space: nowrap;
-}
-
-/******************************
- * "object helper" panel
- ******************************/
-
-.object-helper {
-    border: 1px solid black;
-    margin: 1em;
-    padding: 1em;
-    width: 20em;
-}
-
-.object-helper-content {
-    margin-top: 1em;
-}
-
-/******************************
- * fix datepicker within modals
- * TODO: someday this may not be necessary? cf.
- * https://github.com/buefy/buefy/issues/292#issuecomment-347365637
- ******************************/
-
-.modal .animation-content .modal-card {
-    overflow: visible !important;
-}
-
-.modal-card-body {
-    overflow: visible !important;
-}
-
-
-/******************************
- * feedback
- ******************************/
-
-.feedback-dialog .red {
-    color: red;
-    font-weight: bold;
-}
diff --git a/tailbone/static/themes/falafel/js/tailbone.feedback.js b/tailbone/static/themes/falafel/js/tailbone.feedback.js
deleted file mode 100644
index a3cd2af2..00000000
--- a/tailbone/static/themes/falafel/js/tailbone.feedback.js
+++ /dev/null
@@ -1,48 +0,0 @@
-
-let FeedbackForm = {
-    props: ['action'],
-    template: '#feedback-template',
-    methods: {
-
-        showFeedback() {
-            this.message = ''
-            this.showDialog = true
-            this.$nextTick(function() {
-                this.$refs.textarea.focus()
-            })
-        },
-
-        sendFeedback() {
-
-            let params = {
-                referrer: this.referrer,
-                user: this.userUUID,
-                user_name: this.userName,
-                message: this.message.trim(),
-            }
-
-            let headers = {
-                // TODO: should find a better way to handle CSRF token
-                'X-CSRF-TOKEN': this.csrftoken,
-            }
-
-            this.$http.post(this.action, params, {headers: headers}).then(({ data }) => {
-                if (data.ok) {
-                    alert("Message successfully sent.\n\nThank you for your feedback.")
-                    this.showDialog = false
-                } else {
-                    alert("Sorry!  Your message could not be sent.\n\n"
-                          + "Please try to contact the site admin some other way.")
-                }
-            })
-        },
-    }
-}
-
-let FeedbackFormData = {
-    referrer: null,
-    userUUID: null,
-    userName: null,
-    message: '',
-    showDialog: false,
-}
diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py
index af88f7a7..268d4818 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,127 +24,181 @@
 Event Subscribers
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-import json
 import datetime
+import logging
+import warnings
+from collections import OrderedDict
 
 import rattail
-from rattail.db import model
-from rattail.db.auth import cache_permissions
 
 import colander
 import deform
 from pyramid import threadlocal
 from webhelpers2.html import tags
 
+from wuttaweb import subscribers as base
+
 import tailbone
 from tailbone import helpers
 from tailbone.db import Session
-from tailbone.menus import make_simple_menus
+from tailbone.config import csrf_header_name, should_expose_websockets
+from tailbone.util import get_available_themes, get_global_search_options
 
 
-def new_request(event):
+log = logging.getLogger(__name__)
+
+
+def new_request(event, session=None):
     """
-    Identify the current user, and cache their current permissions.  Also adds
-    the ``rattail_config`` attribute to the request.
+    Event hook called when processing a new request.
 
-    A global Rattail ``config`` should already be present within the Pyramid
-    application registry's settings, which would normally be accessed via::
-    
-       request.registry.settings['rattail_config']
+    This first invokes the upstream hooks:
 
-    This function merely "promotes" that config object so that it is more
-    directly accessible, a la::
+    * :func:`wuttaweb:wuttaweb.subscribers.new_request()`
+    * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
 
-       request.rattail_config
+    It then adds more things to the request object; among them:
 
-    .. note::
-       This of course assumes that a Rattail ``config`` object *has* in fact
-       already been placed in the application registry settings.  If this is
-       not the case, this function will do nothing.
+    .. attribute:: request.rattail_config
+
+       Reference to the app :term:`config object`.  Note that this
+       will be the same as :attr:`wuttaweb:request.wutta_config`.
+
+    .. method:: request.register_component(tagname, classname)
+
+       Function to register a Vue component for use with the app.
+
+       This can be called from wherever a component is defined, and
+       then in the base template all registered components will be
+       properly loaded.
     """
     request = event.request
-    rattail_config = request.registry.settings.get('rattail_config')
-    if rattail_config:
-        request.rattail_config = rattail_config
 
-    request.user = None
-    uuid = request.authenticated_userid
-    if uuid:
-        request.user = Session.query(model.User).get(uuid)
-        if request.user:
-            # assign user to the session, for sake of versioning
-            Session().set_continuum_user(request.user)
+    # invoke main upstream logic
+    # nb. this sets request.wutta_config
+    base.new_request(event)
+
+    config = request.wutta_config
+    app = config.get_app()
+    auth = app.get_auth_handler()
+    session = session or Session()
+
+    # compatibility
+    rattail_config = config
+    request.rattail_config = rattail_config
+
+    def user_getter(request, db_session=None):
+        user = base.default_user_getter(request, db_session=db_session)
+        if user:
+            # nb. we also assign continuum user to session
+            session = db_session or Session()
+            session.set_continuum_user(user)
+            return user
+
+    # invoke upstream hook to set user
+    base.new_request_set_user(event, user_getter=user_getter, db_session=session)
 
     # assign client IP address to the session, for sake of versioning
-    Session().continuum_remote_addr = request.client_addr
+    if hasattr(request, 'client_addr'):
+        session.continuum_remote_addr = request.client_addr
 
-    request.is_admin = bool(request.user) and request.user.is_admin()
-    request.is_root = request.is_admin and request.session.get('is_root', False)
+    # request.register_component()
+    def register_component(tagname, classname):
+        """
+        Register a Vue 3 component, so the base template knows to
+        declare it for use within the app (page).
+        """
+        if not hasattr(request, '_tailbone_registered_components'):
+            request._tailbone_registered_components = OrderedDict()
 
-    request.tailbone_cached_permissions = cache_permissions(Session(), request.user)
+        if tagname in request._tailbone_registered_components:
+            log.warning("component with tagname '%s' already registered "
+                        "with class '%s' but we are replacing that with "
+                        "class '%s'",
+                        tagname,
+                        request._tailbone_registered_components[tagname],
+                        classname)
+
+        request._tailbone_registered_components[tagname] = classname
+    request.register_component = register_component
 
 
 def before_render(event):
     """
     Adds goodies to the global template renderer context.
     """
+    # log.debug("before_render: %s", event)
+
+    # invoke upstream logic
+    base.before_render(event)
 
     request = event.get('request') or threadlocal.get_current_request()
+    config = request.wutta_config
+    app = config.get_app()
 
     renderer_globals = event
+
+    # overrides
     renderer_globals['h'] = helpers
-    renderer_globals['url'] = request.route_url
-    renderer_globals['rattail'] = rattail
-    renderer_globals['tailbone'] = tailbone
-    renderer_globals['model'] = request.rattail_config.get_model()
-    renderer_globals['enum'] = request.rattail_config.get_enum()
-    renderer_globals['six'] = six
-    renderer_globals['json'] = json
+
+    # misc.
     renderer_globals['datetime'] = datetime
     renderer_globals['colander'] = colander
     renderer_globals['deform'] = deform
+    renderer_globals['csrf_header_name'] = csrf_header_name(config)
+
+    # TODO: deprecate / remove these
+    renderer_globals['rattail_app'] = app
+    renderer_globals['app_title'] = app.get_title()
+    renderer_globals['app_version'] = app.get_version()
+    renderer_globals['rattail'] = rattail
+    renderer_globals['tailbone'] = tailbone
+    renderer_globals['model'] = app.model
+    renderer_globals['enum'] = app.enum
 
     # theme  - we only want do this for classic web app, *not* API
     # TODO: so, clearly we need a better way to distinguish the two
     if 'tailbone.theme' in request.registry.settings:
         renderer_globals['theme'] = request.registry.settings['tailbone.theme']
         # note, this is just a global flag; user still needs permission to see picker
-        expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
-                                                       default=False)
+        expose_picker = config.get_bool('tailbone.themes.expose_picker',
+                                        default=False)
         renderer_globals['expose_theme_picker'] = expose_picker
         if expose_picker:
-            # tailbone's config extension provides a default theme selection,
-            # so the default we specify here *probably* should not matter
-            available = request.rattail_config.getlist('tailbone', 'themes',
-                                                       default=['falafel'])
-            if 'default' not in available:
-                available.insert(0, 'default')
-            options = [tags.Option(theme) for theme in available]
+
+            # TODO: should remove 'falafel' option altogether
+            available = get_available_themes(config)
+
+            options = [tags.Option(theme, value=theme) for theme in available]
             renderer_globals['theme_picker_options'] = options
 
-        # heck while we're assuming the classic web app here...
-        # (we don't want this to happen for the API either!)
-        # TODO: just..awful *shrug*
-        # note that we assume "simple" menus nowadays
-        if request.rattail_config.getbool('tailbone', 'menus.simple', default=True):
-            renderer_globals['menus'] = make_simple_menus(request)
-
         # TODO: ugh, same deal here
-        renderer_globals['messaging_enabled'] = request.rattail_config.getbool(
-            'tailbone', 'messaging.enabled', default=False)
+        renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
+                                                                default=False)
 
         # background color may be set per-request, by some apps
         if hasattr(request, 'background_color') and request.background_color:
             renderer_globals['background_color'] = request.background_color
         else: # otherwise we use the one from config
-            renderer_globals['background_color'] = request.rattail_config.get(
-                'tailbone', 'background_color')
+            renderer_globals['background_color'] = config.get('tailbone.background_color')
+
+        # maybe set custom stylesheet
+        css = None
+        if request.user:
+            css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
+            if not css:
+                css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
+                if css:
+                    warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be"
+                                  f"changed to 'tailbone.{request.user.uuid}.user_css'",
+                                  DeprecationWarning)
+        renderer_globals['user_css'] = css
+
+        # add global search data for quick access
+        renderer_globals['global_search_data'] = get_global_search_options(request)
 
         # here we globally declare widths for grid filter pseudo-columns
-        widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths')
+        widths = config.get('tailbone.grids.filters.column_widths')
         if widths:
             widths = widths.split(';')
             if len(widths) < 2:
@@ -154,6 +208,9 @@ def before_render(event):
         renderer_globals['filter_fieldname_width'] = widths[0]
         renderer_globals['filter_verb_width'] = widths[1]
 
+        # declare global support for websockets, or lack thereof
+        renderer_globals['expose_websockets'] = should_expose_websockets(config)
+
 
 def add_inbox_count(event):
     """
@@ -166,6 +223,8 @@ def add_inbox_count(event):
     request = event.get('request') or threadlocal.get_current_request()
     if request.user:
         renderer_globals = event
+        app = request.rattail_config.get_app()
+        model = app.model
         enum = request.rattail_config.get_enum()
         renderer_globals['inbox_count'] = Session.query(model.Message)\
                                                  .outerjoin(model.MessageRecipient)\
@@ -176,53 +235,14 @@ def add_inbox_count(event):
 
 def context_found(event):
     """
-    Attach some goodies to the request object.
+    Attach some more goodies to the request object:
 
     The following is attached to the request:
 
-    * The currently logged-in user instance (if any), as ``user``.
-
-    * ``is_admin`` flag indicating whether user has the Administrator role.
-
-    * ``is_root`` flag indicating whether user is currently elevated to root.
-
-    * A shortcut method for permission checking, as ``has_perm()``.
-
-    * A shortcut method for fetching the referrer, as ``get_referrer()``.
+    * ``get_session_timeout()`` function
     """
-
     request = event.request
 
-    def has_perm(name):
-        if name in request.tailbone_cached_permissions:
-            return True
-        return request.is_root
-    request.has_perm = has_perm
-
-    def has_any_perm(*names):
-        for name in names:
-            if has_perm(name):
-                return True
-        return False
-    request.has_any_perm = has_any_perm
-
-    def get_referrer(default=None, mobile=False):
-        if request.params.get('referrer'):
-            return request.params['referrer']
-        if request.session.get('referrer'):
-            return request.session.pop('referrer')
-        referrer = request.referrer
-        if (not referrer or referrer == request.current_route_url()
-            or not referrer.startswith(request.host_url)):
-            if default:
-                referrer = default
-            elif mobile:
-                referrer = request.route_url('mobile.home')
-            else:
-                referrer = request.route_url('home')
-        return referrer
-    request.get_referrer = get_referrer
-
     def get_session_timeout():
         """
         Returns the timeout in effect for the current session
diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
new file mode 100644
index 00000000..9d866cea
--- /dev/null
+++ b/tailbone/templates/appinfo/configure.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
new file mode 100644
index 00000000..faaea935
--- /dev/null
+++ b/tailbone/templates/appinfo/index.mako
@@ -0,0 +1,31 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/appinfo/index.mako" />
+
+<%def name="page_content()">
+  <div class="buttons">
+
+    <once-button type="is-primary"
+                 tag="a" href="${url('tables')}"
+                 icon-pack="fas"
+                 icon-left="eye"
+                 text="Tables">
+    </once-button>
+
+    <once-button type="is-primary"
+                 tag="a" href="${url('model_views')}"
+                 icon-pack="fas"
+                 icon-left="eye"
+                 text="Model Views">
+    </once-button>
+
+    <once-button type="is-primary"
+                 tag="a" href="${url('configure_menus')}"
+                 icon-pack="fas"
+                 icon-left="cog"
+                 text="Configure Menus">
+    </once-button>
+
+  </div>
+
+  ${parent.page_content()}
+</%def>
diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako
index 36783c69..ba667e0e 100644
--- a/tailbone/templates/appsettings.mako
+++ b/tailbone/templates/appsettings.mako
@@ -5,34 +5,6 @@
 
 <%def name="content_title()"></%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.appsettings.js') + '?ver={}'.format(tailbone.__version__))}
-  % endif
-</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  % if not use_buefy:
-  <style type="text/css">
-    div.form {
-        float: none;
-    }
-    div.panel {
-        width: 85%;
-    }
-    .field-wrapper {
-        margin-bottom: 2em;
-    }
-    .panel .field-wrapper label {
-        font-family: monospace;
-        width: 50em;
-    }
-  </style>
-  % endif
-</%def>
-
 <%def name="context_menu_items()">
   % if request.has_perm('settings.list'):
       <li>${h.link_to("View Raw Settings", url('settings'))}</li>
@@ -43,8 +15,8 @@
   <app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="app-settings-template">
 
     <div class="form">
@@ -52,31 +24,50 @@
       ${h.csrf_token(request)}
 
       % if dform.error:
-          <div class="error-messages">
-            <div class="ui-state-error ui-corner-all">
-              <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-              Please see errors below.
-            </div>
-            <div class="ui-state-error ui-corner-all">
-              <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-              ${dform.error}
-            </div>
-          </div>
+          <b-notification type="is-warning">
+            Please see errors below.
+          </b-notification>
+          <b-notification type="is-warning">
+            ${dform.error}
+          </b-notification>
       % endif
 
       <div class="app-wrapper">
 
-        <div class="field-wrapper">
-          <label for="settings-group">Showing Group</label>
-          <b-select name="settings-group"
-                    v-model="showingGroup">
-            <option value="">(All)</option>
-            <option v-for="group in groups"
-                    :key="group.label"
-                    :value="group.label">
-              {{ group.label }}
-            </option>
-          </b-select>
+        <div class="level">
+
+          <div class="level-left">
+            <div class="level-item">
+              <b-field label="Showing Group">
+                <b-select name="settings-group"
+                          v-model="showingGroup">
+                  <option value="">(All)</option>
+                  <option v-for="group in groups"
+                          :key="group.label"
+                          :value="group.label">
+                    {{ group.label }}
+                  </option>
+                </b-select>
+              </b-field>
+            </div>
+          </div>
+
+          <div class="level-right"
+               v-if="configOptions.length">
+            <div class="level-item">
+              <b-field label="Go To Configure...">
+                <b-select v-model="gotoConfigureURL"
+                          @input="gotoConfigure()">
+                  <option v-for="option in configOptions"
+                          :key="option.url"
+                          :value="option.url">
+                    {{ option.label }}
+                  </option>
+                </b-select>
+              </b-field>
+            </div>
+          </div>
+
         </div>
 
         <div v-for="group in groups"
@@ -88,25 +79,31 @@
           </header>
           <div class="card-content">
             <div v-for="setting in group.settings"
-                :class="'field-wrapper' + (setting.error ? ' with-error' : '')">
+                 ## TODO: not sure how the error handling looks now?
+                 ## :class="'field-wrapper' + (setting.error ? ' with-error' : '')"
+                 >
 
-              <div v-if="setting.error" class="field-error">
-                <span v-for="msg in setting.error_messages"
-                      class="error-msg">
-                  {{ msg }}
-                </span>
-              </div>
+              <div style="margin-bottom: 2rem;">
 
-              <div class="field-row">
-                <label :for="setting.field_name">{{ setting.label }}</label>
-                <div class="field">
+                <b-field horizontal
+                         :label="setting.label"
+                         :type="setting.error ? 'is-danger' : null"
+                         ## TODO: what if there are multiple error messages?
+                         :message="setting.error ? setting.error_messages[0] : null">
 
-                  <input v-if="setting.data_type == 'bool'"
-                         type="checkbox"
-                         :name="setting.field_name"
-                         :id="setting.field_name"
-                         v-model="setting.value"
-                         value="true" />
+                  <b-checkbox v-if="setting.data_type == 'bool'"
+                              :name="setting.field_name"
+                              :id="setting.field_name"
+                              v-model="setting.value"
+                              native-value="true">
+                    {{ setting.value || false }}
+                  </b-checkbox>
+
+                  <b-input v-else-if="setting.data_type == 'list'"
+                           type="textarea"
+                           :name="setting.field_name"
+                           v-model="setting.value">
+                  </b-input>
 
                   <b-select v-else-if="setting.choices"
                             :name="setting.field_name"
@@ -122,26 +119,28 @@
                            :name="setting.field_name"
                            :id="setting.field_name"
                            v-model="setting.value" />
-                </div>
+
+                </b-field>
+
+                <span v-if="setting.helptext"
+                      v-html="setting.helptext"
+                      class="instructions">
+                </span>
               </div>
 
-              <span v-if="setting.helptext" class="instructions">
-                {{ setting.helptext }}
-              </span>
-
-            </div><!-- field-wrapper -->
+            </div>
           </div><!-- card-content -->
         </div><!-- card -->
 
         <div class="buttons">
+          <once-button tag="a" href="${form.cancel_url}"
+                       text="Cancel">
+          </once-button>
           <b-button type="is-primary"
                     native-type="submit"
                     :disabled="formSubmitting">
             {{ formButtonText }}
           </b-button>
-          <once-button tag="a" href="${form.cancel_url}"
-                       text="Cancel">
-          </once-button>
         </div>
 
       </div><!-- app-wrapper -->
@@ -151,19 +150,18 @@
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
 
-    ThisPageData.groups = ${json.dumps(buefy_data)|n}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.groups = ${json.dumps(settings_data)|n}
     ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
-
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
 
     Vue.component('app-settings', {
         template: '#app-settings-template',
@@ -175,90 +173,22 @@
             return {
                 formSubmitting: false,
                 formButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
+                configOptions: ${json.dumps(config_options)|n},
+                gotoConfigureURL: null,
             }
         },
         methods: {
             submitForm() {
                 this.formSubmitting = true
                 this.formButtonText = "Working, please wait..."
-            }
+            },
+            gotoConfigure() {
+                if (this.gotoConfigureURL) {
+                    location.href = this.gotoConfigureURL
+                }
+            },
         }
     })
 
   </script>
 </%def>
-
-
-% if use_buefy:
-    ${parent.body()}
-
-% else:
-## legacy / not buefy
-<div class="form">
-  ${h.form(form.action_url, id=dform.formid, method='post', class_='autodisable')}
-  ${h.csrf_token(request)}
-
-  % if dform.error:
-      <div class="error-messages">
-        <div class="ui-state-error ui-corner-all">
-          <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-          Please see errors below.
-        </div>
-        <div class="ui-state-error ui-corner-all">
-          <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-          ${dform.error}
-        </div>
-      </div>
-  % endif
-
-  <div class="group-picker">
-    <div class="field-wrapper">
-      <label for="settings-group">Showing Group</label>
-      <div class="field select">
-        ${h.select('settings-group', current_group, group_options, **{'auto-enhance': 'true'})}
-      </div>
-    </div>
-  </div>
-
-  % for group in groups:
-      <div class="panel" data-groupname="${group}">
-        <h2>${group}</h2>
-        <div class="panel-body">
-
-          % for setting in settings:
-              % if setting.group == group:
-                  <% field = dform[setting.node_name] %>
-
-                  <div class="field-wrapper ${field.name} ${'with-error' if field.error else ''}">
-                    % if field.error:
-                        <div class="field-error">
-                          % for msg in field.error.messages():
-                              <span class="error-msg">${msg}</span>
-                          % endfor
-                        </div>
-                    % endif
-                    <div class="field-row">
-                      <label for="${field.oid}">${form.get_label(field.name)}</label>
-                      <div class="field">
-                        ${field.serialize()|n}
-                      </div>
-                    </div>
-                    % if form.has_helptext(field.name):
-                        <span class="instructions">${form.render_helptext(field.name)}</span>
-                    % endif
-                  </div>
-              % endif
-          % endfor
-
-        </div><!-- panel-body -->
-      </div><! -- panel -->
-  % endfor
-
-  <div class="buttons">
-    ${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')}
-    ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))}
-  </div>
-
-  ${h.end_form()}
-</div>
-% endif
diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako
index c9de4507..4c413757 100644
--- a/tailbone/templates/autocomplete.mako
+++ b/tailbone/templates/autocomplete.mako
@@ -1,85 +1,27 @@
 ## -*- coding: utf-8; -*-
 
-## TODO: This function signature is getting out of hand...
-<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})">
-  <div id="${field_name}-container" class="autocomplete-container">
-    ${h.hidden(field_name, id=field_name, value=field_value)}
-    ${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display,
-        class_='autocomplete-textbox', style='display: none;' if field_value else '')}
-    <div id="${field_name}-display" class="autocomplete-display"${'' if field_value else ' style="display: none;"'|n}>
-      <span>${field_display or ''}</span>
-      <button type="button" id="${field_name}-change" class="autocomplete-change">Change</button>
-    </div>
-  </div>
-  <script type="text/javascript">
-    $(function() {
-        $('#${field_name}-textbox').autocomplete({
-            source: '${service_url}',
-            autoFocus: true,
-            % for key, value in options.items():
-                ${key}: ${value},
-            % endfor
-            focus: function(event, ui) {
-                return false;
-            },
-            % if select:
-                select: ${select}
-            % else:
-                select: function(event, ui) {
-                    $('#${field_name}').val(ui.item.value);
-                    $('#${field_name}-display span:first').text(ui.item.label);
-                    $('#${field_name}-textbox').hide();
-                    $('#${field_name}-display').show();
-                    % if selected:
-                        ${selected}(ui.item.value, ui.item.label);
-                    % endif
-                    return false;
-                }
-            % endif
-        });
-        $('#${field_name}-change').click(function() {
-            % if change_clicked:
-                if (! ${change_clicked}()) {
-                    return false;
-                }
-            % endif
-            $('#${field_name}').val('');
-            $('#${field_name}-display').hide();
-            with ($('#${field_name}-textbox')) {
-                val('');
-                show();
-                focus();
-            }
-            % if cleared:
-                ${cleared}();
-            % endif
-        });
-    });
-  </script>
-</%def>
-
 <%def name="tailbone_autocomplete_template()">
   <script type="text/x-template" id="tailbone-autocomplete-template">
     <div>
 
       <b-autocomplete ref="autocomplete"
                       :name="name"
-                      v-show="!selected"
-                      v-model="value"
+                      v-show="!value && !selected"
+                      v-model="buefyValue"
+                      :placeholder="placeholder"
                       :data="data"
                       @typing="getAsyncData"
                       @select="selectionMade"
-                      @input="itemSelected"
                       keep-first>
         <template slot-scope="props">
           {{ props.option.label }}
         </template>
       </b-autocomplete>
 
-      <b-button v-if="selected"
+      <b-button v-if="value || selected"
                 style="width: 100%; justify-content: left;"
-                @click="clearSelection()">
-        {{ selected.label }} (click to change)
+                @click="clearSelection(true)">
+        {{ getDisplayText() }} (click to change)
       </b-button>
 
     </div>
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index daa60e2d..8228f823 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -1,8 +1,12 @@
 ## -*- coding: utf-8; -*-
-<%namespace file="/menu.mako" import="main_menu_items" />
+<%namespace file="/wutta-components.mako" import="make_wutta_components" />
 <%namespace file="/grids/nav.mako" import="grid_index_nav" />
-<%namespace file="/feedback_dialog.mako" import="feedback_dialog" />
+<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
 <%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
+<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
+<%namespace name="page_help" file="/page_help.mako" />
+<%namespace name="multi_file_upload" file="/multi_file_upload.mako" />
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -13,13 +17,17 @@
 
     % if background_color:
         <style type="text/css">
-          body { background-color: ${background_color}; }
+          body, .navbar, .footer {
+              background-color: ${background_color};
+          }
         </style>
     % endif
 
     % if not request.rattail_config.production():
         <style type="text/css">
-          body { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); }
+          body, .navbar, .footer {
+            background-image: url(${request.static_url('tailbone:static/img/testing.png')});
+          }
         </style>
     % endif
 
@@ -27,143 +35,21 @@
   </head>
 
   <body>
-    <div id="body-wrapper">
+    <div id="app" style="height: 100%;">
+      <whole-page></whole-page>
+    </div>
 
-      <header>
-        <nav>
-          <ul class="menubar">
-            ${main_menu_items()}
-          </ul>
-        </nav>
+    ## TODO: this must come before the self.body() call..but why?
+    ${declare_formposter_mixin()}
 
-        <div class="global">
-          <a class="home" href="${url('home')}">
-            ${base_meta.header_logo()}
-            <span class="global-title">${base_meta.global_title()}</span>
-          </a>
-          % if master:
-              <span class="global">&raquo;</span>
-              % if master.listing:
-                  <span class="global">${index_title}</span>
-              % else:
-                  ${h.link_to(index_title, index_url, class_='global')}
-                  % if parent_url is not Undefined:
-                      <span class="global">&raquo;</span>
-                      ${h.link_to(parent_title, parent_url, class_='global')}
-                  % elif instance_url is not Undefined:
-                      <span class="global">&raquo;</span>
-                      ${h.link_to(instance_title, instance_url, class_='global')}
-                  % endif
-                  % if master.viewing and grid_index:
-                      ${grid_index_nav()}
-                  % endif
-              % endif
-          % elif index_title:
-              <span class="global">&raquo;</span>
-              % if index_url:
-                  ${h.link_to(index_title, index_url, class_='global')}
-              % else:
-                  <span class="global">${index_title}</span>
-              % endif
-          % endif
+    ## content body from derived/child template
+    ${self.body()}
 
-          <div class="feedback">
-            % if help_url is not Undefined and help_url:
-                ${h.link_to("Help", help_url, target='_blank', class_='button')}
-            % endif
-            % if request.has_perm('common.feedback'):
-                <button type="button" id="feedback">Feedback</button>
-            % endif
-          </div>
-
-          % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-              <div class="after-feedback">
-                ${h.form(url('change_theme'), name="theme_changer", method="post")}
-                ${h.csrf_token(request)}
-                Theme:
-                ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')}
-                ${h.end_form()}
-              </div>
-          % endif
-
-          % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
-              <div class="after-feedback">
-                ${h.form(quickie.url, name="quickie", method="get")}
-                ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')}
-                <button type="submit" id="submit-quickie">Lookup</button>
-                ${h.end_form()}
-              </div>
-          % endif
-
-        </div><!-- global -->
-
-        <div class="page">
-          % if capture(self.content_title):
-
-              % if show_prev_next is not Undefined and show_prev_next:
-                  <div style="float: right;">
-                    ## NOTE: the u"" literals seem to be required for python2..not sure why
-                    % if prev_url:
-                        ${h.link_to(u"« Older", prev_url, class_='button autodisable')}
-                    % else:
-                        ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')}
-                    % endif
-                    % if next_url:
-                        ${h.link_to(u"Newer »", next_url, class_='button autodisable')}
-                    % else:
-                        ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')}
-                    % endif
-                  </div>
-              % endif
-
-              <h1>${self.content_title()}</h1>
-          % endif
-        </div>
-      </header>
-
-      <div class="content-wrapper">
-        
-        <div id="scrollpane">
-          <div id="content">
-            <div class="inner-content">
-
-              % if request.session.peek_flash('error'):
-                  <div class="error-messages">
-                    % for error in request.session.pop_flash('error'):
-                        <div class="ui-state-error ui-corner-all">
-                          <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-                          ${error}
-                        </div>
-                    % endfor
-                  </div>
-              % endif
-
-              % if request.session.peek_flash():
-                  <div class="flash-messages">
-                    % for msg in request.session.pop_flash():
-                        <div class="ui-state-highlight ui-corner-all">
-                          <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span>
-                          ${msg|n}
-                        </div>
-                    % endfor
-                  </div>
-              % endif
-
-              ${self.body()}
-
-            </div><!-- inner-content -->
-          </div><!-- content -->
-        </div><!-- scrollpane -->
-
-      </div><!-- content-wrapper -->
-
-      <div id="footer">
-        ${base_meta.footer()}
-      </div>
-
-    </div><!-- body-wrapper -->
-
-    ${feedback_dialog()}
+    ## Vue app
+    ${self.render_vue_templates()}
+    ${self.modify_vue_vars()}
+    ${self.make_vue_components()}
+    ${self.make_vue_app()}
   </body>
 </html>
 
@@ -174,82 +60,855 @@
 </%def>
 
 <%def name="header_core()">
+
   ${self.core_javascript()}
   ${self.extra_javascript()}
   ${self.core_styles()}
   ${self.extra_styles()}
 
-  ## TODO: should this be elsewhere / more customizable?
-  % if dform is not Undefined:
-      <% resources = dform.get_widget_resources() %>
-      % for path in resources['js']:
-          ${h.javascript_link(request.static_url(path))}
-      % endfor
-      % for path in resources['css']:
-          ${h.stylesheet_link(request.static_url(path))}
-      % endfor
-  % endif
+  ## TODO: should leverage deform resources for component JS?
+  ## cf. also tailbone.app.make_pyramid_config()
+##   ## TODO: should this be elsewhere / more customizable?
+##   % if dform is not Undefined:
+##       <% resources = dform.get_widget_resources() %>
+##       % for path in resources['js']:
+##           ${h.javascript_link(request.static_url(path))}
+##       % endfor
+##       % for path in resources['css']:
+##           ${h.stylesheet_link(request.static_url(path))}
+##       % endfor
+##   % endif
 </%def>
 
 <%def name="core_javascript()">
-  ${self.jquery()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.timepicker.js'))}
+  ${self.vuejs()}
+  ${self.buefy()}
+  ${self.fontawesome()}
+
+  ## some commonly-useful logic for detecting (non-)numeric input
+  ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))}
+
+  ## debounce, for better autocomplete performance
+  ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))}
+
+  ## Tailbone / Buefy stuff
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))}
+
   <script type="text/javascript">
-    var session_timeout = ${request.get_session_timeout() or 'null'};
-    var logout_url = '${request.route_url('logout')}';
-    var noop_url = '${request.route_url('noop')}';
-    $(function() {
-        $('ul.menubar').menubar({
-            buttons: true,
-            menuIcon: true,
-            autoExpand: true
-        });
-    });
-    % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-        $(function() {
-            $('#theme-picker').change(function() {
-                $(this).parents('form:first').submit();
-            });
-        });
-    % endif
+
+    ## NOTE: this code was copied from
+    ## https://bulma.io/documentation/components/navbar/#navbar-menu
+
+    document.addEventListener('DOMContentLoaded', () => {
+
+        // Get all "navbar-burger" elements
+        const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0)
+
+        // Add a click event on each of them
+        $navbarBurgers.forEach( el => {
+            el.addEventListener('click', () => {
+
+                // Get the target from the "data-target" attribute
+                const target = el.dataset.target
+                const $target = document.getElementById(target)
+
+                // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
+                el.classList.toggle('is-active')
+                $target.classList.toggle('is-active')
+
+            })
+        })
+    })
+
   </script>
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))}
 </%def>
 
-<%def name="jquery()">
-  ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')}
-  ${h.javascript_link('https://code.jquery.com/ui/{}/jquery-ui.min.js'.format(request.rattail_config.get('tailbone', 'jquery_ui.version', default='1.11.4')))}
+<%def name="vuejs()">
+  ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))}
+  ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))}
+</%def>
+
+<%def name="buefy()">
+  ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))}
+</%def>
+
+<%def name="fontawesome()">
+  <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script>
 </%def>
 
 <%def name="extra_javascript()"></%def>
 
 <%def name="core_styles()">
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))}
-  ${self.jquery_theme()}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))}
+
+  ${self.buefy_styles()}
+
   ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))}
   ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
   ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))}
   ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
   ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
   ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
+
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))}
+
+  <style type="text/css">
+    .filters .filter-fieldname,
+    .filters .filter-fieldname .button {
+        % if filter_fieldname_width is not Undefined:
+        min-width: ${filter_fieldname_width};
+        % endif
+        justify-content: left;
+    }
+    % if filter_fieldname_width is not Undefined:
+    .filters .filter-verb {
+        min-width: ${filter_verb_width};
+    }
+    % endif
+  </style>
 </%def>
 
-<%def name="jquery_theme()">
-  ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')}
+<%def name="buefy_styles()">
+  % if user_css:
+      ${h.stylesheet_link(user_css)}
+  % else:
+      ## upstream Buefy CSS
+      ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))}
+  % endif
 </%def>
 
-<%def name="extra_styles()"></%def>
+<%def name="extra_styles()">
+  ${base_meta.extra_styles()}
+</%def>
 
 <%def name="head_tags()"></%def>
 
+<%def name="render_vue_template_whole_page()">
+  <script type="text/x-template" id="whole-page-template">
+    <div>
+      <header>
+
+        <nav class="navbar" role="navigation" aria-label="main navigation">
+
+          <div class="navbar-brand">
+            <a class="navbar-item" href="${url('home')}"
+               v-show="!globalSearchActive">
+              ${base_meta.header_logo()}
+              <div id="global-header-title">
+                ${base_meta.global_title()}
+              </div>
+            </a>
+            <div v-show="globalSearchActive"
+                 class="navbar-item">
+              <b-autocomplete ref="globalSearchAutocomplete"
+                              v-model="globalSearchTerm"
+                              :data="globalSearchFilteredData"
+                              field="label"
+                              open-on-focus
+                              keep-first
+                              icon-pack="fas"
+                              clearable
+                              @keydown.native="globalSearchKeydown"
+                              @select="globalSearchSelect">
+              </b-autocomplete>
+            </div>
+            <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false">
+              <span aria-hidden="true"></span>
+              <span aria-hidden="true"></span>
+              <span aria-hidden="true"></span>
+            </a>
+          </div>
+
+          <div class="navbar-menu" id="navbar-menu">
+            <div class="navbar-start">
+
+              <div v-if="globalSearchData.length"
+                   class="navbar-item">
+                <b-button type="is-primary"
+                          size="is-small"
+                          @click="globalSearchInit()">
+                  <span><i class="fa fa-search"></i></span>
+                </b-button>
+              </div>
+
+              % for topitem in menus:
+                  % if topitem['is_link']:
+                      ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')}
+                  % else:
+                      <div class="navbar-item has-dropdown is-hoverable">
+                        <a class="navbar-link">${topitem['title']}</a>
+                        <div class="navbar-dropdown">
+                          % for item in topitem['items']:
+                              % if item['is_menu']:
+                                  <% item_hash = id(item) %>
+                                  <% toggle = 'menu_{}_shown'.format(item_hash) %>
+                                  <div>
+                                    <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')">
+                                      ${item['title']}
+                                    </a>
+                                  </div>
+                                  % for subitem in item['items']:
+                                      % if subitem['is_sep']:
+                                          <hr class="navbar-divider" v-show="${toggle}">
+                                      % else:
+                                          ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})}
+                                      % endif
+                                  % endfor
+                              % else:
+                                  % if item['is_sep']:
+                                      <hr class="navbar-divider">
+                                  % else:
+                                      ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])}
+                                  % endif
+                              % endif
+                          % endfor
+                        </div>
+                      </div>
+                  % endif
+              % endfor
+
+            </div><!-- navbar-start -->
+            ${self.render_navbar_end()}
+          </div>
+        </nav>
+
+        <nav class="level" style="margin: 0.5rem auto;">
+          <div class="level-left">
+
+            ## Current Context
+            <div id="current-context" class="level-item">
+              % if master:
+                  % if master.listing:
+                      <span class="header-text">
+                        ${index_title}
+                      </span>
+                      % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
+                          <once-button type="is-primary"
+                                       tag="a" href="${url('{}.create'.format(route_prefix))}"
+                                       icon-left="plus"
+                                       style="margin-left: 1rem;"
+                                       text="Create New">
+                          </once-button>
+                      % endif
+                  % elif index_url:
+                      <span class="header-text">
+                        ${h.link_to(index_title, index_url)}
+                      </span>
+                      % if parent_url is not Undefined:
+                          <span class="header-text">
+                            &nbsp;&raquo;
+                          </span>
+                          <span class="header-text">
+                            ${h.link_to(parent_title, parent_url)}
+                          </span>
+                      % elif instance_url is not Undefined:
+                          <span class="header-text">
+                            &nbsp;&raquo;
+                          </span>
+                          <span class="header-text">
+                            ${h.link_to(instance_title, instance_url)}
+                          </span>
+                      % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
+                          % if not request.matched_route.name.endswith('.create'):
+                              <once-button type="is-primary"
+                                           tag="a" href="${url('{}.create'.format(route_prefix))}"
+                                           icon-left="plus"
+                                           style="margin-left: 1rem;"
+                                           text="Create New">
+                              </once-button>
+                          % endif
+                      % endif
+                      % if master.viewing and grid_index:
+                          ${grid_index_nav()}
+                      % endif
+                  % else:
+                      <span class="header-text">
+                        ${index_title}
+                      </span>
+                  % endif
+              % elif index_title:
+                  % if index_url:
+                      <span class="header-text">
+                        ${h.link_to(index_title, index_url)}
+                      </span>
+                  % else:
+                      <span class="header-text">
+                        ${index_title}
+                      </span>
+                  % endif
+              % endif
+            </div>
+
+            % if expose_db_picker is not Undefined and expose_db_picker:
+                <div class="level-item">
+                  <p>DB:</p>
+                </div>
+                <div class="level-item">
+                  ${h.form(url('change_db_engine'), ref='dbPickerForm')}
+                  ${h.csrf_token(request)}
+                  ${h.hidden('engine_type', value=master.engine_type_key)}
+                  <b-select name="dbkey"
+                            value="${db_picker_selected}"
+                            @input="changeDB()">
+                    % for option in db_picker_options:
+                        <option value="${option.value}">
+                          ${option.label}
+                        </option>
+                    % endfor
+                  </b-select>
+                  ${h.end_form()}
+                </div>
+            % endif
+
+          </div><!-- level-left -->
+          <div class="level-right">
+
+            ## Quickie Lookup
+            % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
+                <div class="level-item">
+                  ${h.form(quickie.url, method="get")}
+                  <div class="level">
+                    <div class="level-right">
+                      <div class="level-item">
+                        <b-input name="entry"
+                                 placeholder="${quickie.placeholder}"
+                                 autocomplete="off">
+                        </b-input>
+                      </div>
+                      <div class="level-item">
+                        <button type="submit" class="button is-primary">
+                          <span class="icon is-small">
+                            <i class="fas fa-search"></i>
+                          </span>
+                          <span>Lookup</span>
+                        </button>
+                      </div>
+                    </div>
+                  </div>
+                  ${h.end_form()}
+                </div>
+            % endif
+
+            % if master and master.configurable and master.has_perm('configure'):
+                % if not request.matched_route.name.endswith('.configure'):
+                    <div class="level-item">
+                      <once-button type="is-primary"
+                                   tag="a"
+                                   href="${url('{}.configure'.format(route_prefix))}"
+                                   icon-left="cog"
+                                   text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}">
+                      </once-button>
+                    </div>
+                % endif
+            % endif
+
+            ## Theme Picker
+            % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+                <div class="level-item">
+                  ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
+                    ${h.csrf_token(request)}
+                    <input type="hidden" name="referrer" :value="referrer" />
+                    <div style="display: flex; align-items: center; gap: 0.5rem;">
+                      <span>Theme:</span>
+                      <b-select name="theme"
+                                v-model="globalTheme"
+                                @input="changeTheme()">
+                        % for option in theme_picker_options:
+                            <option value="${option.value}">
+                              ${option.label}
+                            </option>
+                        % endfor
+                      </b-select>
+                    </div>
+                  ${h.end_form()}
+                </div>
+            % endif
+
+            <div class="level-item">
+              <page-help
+                % if can_edit_help:
+                @configure-fields-help="configureFieldsHelp = true"
+                % endif
+                >
+              </page-help>
+            </div>
+
+            ## Feedback Button / Dialog
+            % if request.has_perm('common.feedback'):
+                <feedback-form
+                   action="${url('feedback')}"
+                   :message="feedbackMessage">
+                </feedback-form>
+            % endif
+
+          </div><!-- level-right -->
+        </nav><!-- level -->
+      </header>
+
+      ## Page Title
+      % if capture(self.content_title):
+          <section id="content-title"
+                   class="has-background-primary">
+            <div style="display: flex; align-items: center; padding: 0.5rem;">
+
+              <h1 class="title has-text-white"
+                  v-html="contentTitleHTML">
+              </h1>
+
+              <div style="flex-grow: 1; display: flex; gap: 0.5rem;">
+                ${self.render_instance_header_title_extras()}
+              </div>
+
+              <div style="display: flex; gap: 0.5rem;">
+                ${self.render_instance_header_buttons()}
+              </div>
+
+            </div>
+          </section>
+      % endif
+
+      <div class="content-wrapper">
+
+      ## Page Body
+      <section id="page-body">
+
+        % if request.session.peek_flash('error'):
+            % for error in request.session.pop_flash('error'):
+                <b-notification type="is-warning">
+                  ${error}
+                </b-notification>
+            % endfor
+        % endif
+
+        % if request.session.peek_flash('warning'):
+            % for msg in request.session.pop_flash('warning'):
+                <b-notification type="is-warning">
+                  ${msg}
+                </b-notification>
+            % endfor
+        % endif
+
+        % if request.session.peek_flash():
+            % for msg in request.session.pop_flash():
+                <b-notification type="is-info">
+                  ${msg}
+                </b-notification>
+            % endfor
+        % endif
+
+        ${self.render_this_page_component()}
+      </section>
+
+      ## Footer
+      <footer class="footer">
+        <div class="content">
+          ${base_meta.footer()}
+        </div>
+      </footer>
+
+      </div><!-- content-wrapper -->
+    </div>
+  </script>
+
+  ${page_help.render_template()}
+
+  % if request.has_perm('common.feedback'):
+  <script type="text/x-template" id="feedback-template">
+    <div>
+
+      <div class="level-item">
+        <b-button type="is-primary"
+                  @click="showFeedback()"
+                  icon-pack="fas"
+                  icon-left="comment">
+          Feedback
+        </b-button>
+      </div>
+
+      <b-modal has-modal-card
+               :active.sync="showDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">User Feedback</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              Questions, suggestions, comments, complaints, etc.
+              <span class="red">regarding this website</span> are
+              welcome and may be submitted below.
+            </p>
+
+            <b-field label="User Name">
+              <b-input v-model="userName"
+                       % if request.user:
+                       disabled
+                       % endif
+                       >
+              </b-input>
+            </b-field>
+
+            <b-field label="Referring URL">
+              <b-input
+                 v-model="referrer"
+                 disabled="true">
+              </b-input>
+            </b-field>
+
+            <b-field label="Message">
+              <b-input type="textarea"
+                       v-model="message"
+                       ref="textarea">
+              </b-input>
+            </b-field>
+
+            % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'):
+                <div class="level">
+                  <div class="level-left">
+                    <div class="level-item">
+                      <b-checkbox v-model="pleaseReply"
+                                  @input="pleaseReplyChanged">
+                        Please email me back{{ pleaseReply ? " at: " : "" }}
+                      </b-checkbox>
+                    </div>
+                    <div class="level-item" v-show="pleaseReply">
+                      <b-input v-model="userEmail"
+                               ref="userEmail">
+                      </b-input>
+                    </div>
+                  </div>
+                </div>
+            % endif
+
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="showDialog = false">
+              Cancel
+            </b-button>
+            <once-button type="is-primary"
+                         @click="sendFeedback()"
+                         :disabled="!message.trim()"
+                         text="Send Message">
+            </once-button>
+          </footer>
+        </div>
+      </b-modal>
+
+    </div>
+  </script>
+  % endif
+
+  ${tailbone_autocomplete_template()}
+  ${multi_file_upload.render_template()}
+</%def>
+
+<%def name="render_this_page_component()">
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+             :configure-fields-help="configureFieldsHelp"
+             % endif
+             >
+  </this-page>
+</%def>
+
+<%def name="render_navbar_end()">
+  <div class="navbar-end">
+    ${self.render_user_menu()}
+  </div>
+</%def>
+
+<%def name="render_user_menu()">
+  % if request.user:
+      <div class="navbar-item has-dropdown is-hoverable">
+        % if messaging_enabled:
+            <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a>
+        % else:
+            <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a>
+        % endif
+        <div class="navbar-dropdown">
+          % if request.is_root:
+              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.stopBeingRootForm.submit()"
+                 class="navbar-item root-user">
+                Stop being root
+              </a>
+              ${h.end_form()}
+          % elif request.is_admin:
+              ${h.form(url('become_root'), ref='startBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.startBeingRootForm.submit()"
+                 class="navbar-item root-user">
+                Become root
+              </a>
+              ${h.end_form()}
+          % endif
+          % if messaging_enabled:
+              ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
+          % endif
+          % if request.is_root or not request.user.prevent_password_change:
+              ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
+          % endif
+          % try:
+              ## nb. does not exist yet for wuttaweb
+              ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
+          % except:
+          % endtry
+          ${h.link_to("Logout", url('logout'), class_='navbar-item')}
+        </div>
+      </div>
+  % else:
+      ${h.link_to("Login", url('login'), class_='navbar-item')}
+  % endif
+</%def>
+
+<%def name="render_instance_header_title_extras()"></%def>
+
+<%def name="render_instance_header_buttons()">
+  ${self.render_crud_header_buttons()}
+  ${self.render_prevnext_header_buttons()}
+</%def>
+
+<%def name="render_crud_header_buttons()">
+  % if master and master.viewing:
+      ## TODO: is there a better way to check if viewing parent?
+      % if parent_instance is Undefined:
+          % if master.editable and instance_editable and master.has_perm('edit'):
+              <once-button tag="a" href="${master.get_action_url('edit', instance)}"
+                           icon-left="edit"
+                           text="Edit This">
+              </once-button>
+          % endif
+          % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
+              <once-button tag="a" href="${master.get_action_url('clone', instance)}"
+                           icon-left="object-ungroup"
+                           text="Clone This">
+              </once-button>
+          % endif
+          % if master.deletable and instance_deletable and master.has_perm('delete'):
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
+          % endif
+      % else:
+          ## viewing row
+          % if instance_deletable and master.has_perm('delete_row'):
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
+          % endif
+      % endif
+  % elif master and master.editing:
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
+      % if master.deletable and instance_deletable and master.has_perm('delete'):
+          <once-button tag="a" href="${master.get_action_url('delete', instance)}"
+                       type="is-danger"
+                       icon-left="trash"
+                       text="Delete This">
+          </once-button>
+      % endif
+  % elif master and master.deleting:
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
+      % if master.editable and instance_editable and master.has_perm('edit'):
+          <once-button tag="a" href="${master.get_action_url('edit', instance)}"
+                       icon-left="edit"
+                       text="Edit This">
+          </once-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="render_prevnext_header_buttons()">
+  % if show_prev_next is not Undefined and show_prev_next:
+      % if prev_url:
+          <b-button tag="a" href="${prev_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % endif
+      % if next_url:
+          <b-button tag="a" href="${next_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="render_vue_script_whole_page()">
+  <script>
+
+    let WholePage = {
+        template: '#whole-page-template',
+        mixins: [FormPosterMixin],
+        computed: {
+
+            globalSearchFilteredData() {
+                if (!this.globalSearchTerm.length) {
+                    return this.globalSearchData
+                }
+
+                let terms = []
+                for (let term of this.globalSearchTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+                if (!terms.length) {
+                    return this.globalSearchData
+                }
+
+                // all terms must match
+                return this.globalSearchData.filter((option) => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+            },
+
+        },
+
+        mounted() {
+            window.addEventListener('keydown', this.globalKey)
+            for (let hook of this.mountedHooks) {
+                hook(this)
+            }
+        },
+        beforeDestroy() {
+            window.removeEventListener('keydown', this.globalKey)
+        },
+
+        methods: {
+
+            changeContentTitle(newTitle) {
+                this.contentTitleHTML = newTitle
+            },
+
+            % if expose_db_picker is not Undefined and expose_db_picker:
+                changeDB() {
+                    this.$refs.dbPickerForm.submit()
+                },
+            % endif
+
+            % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+                changeTheme() {
+                    this.$refs.themePickerForm.submit()
+                },
+            % endif
+
+            globalKey(event) {
+
+                // Ctrl+8 opens global search
+                if (event.target.tagName == 'BODY') {
+                    if (event.ctrlKey && event.key == '8') {
+                        this.globalSearchInit()
+                    }
+                }
+            },
+
+            globalSearchInit() {
+                this.globalSearchTerm = ''
+                this.globalSearchActive = true
+                this.$nextTick(() => {
+                    this.$refs.globalSearchAutocomplete.focus()
+                })
+            },
+
+            globalSearchKeydown(event) {
+
+                // ESC will dismiss searchbox
+                if (event.which == 27) {
+                    this.globalSearchActive = false
+                }
+            },
+
+            globalSearchSelect(option) {
+                location.href = option.url
+            },
+
+            toggleNestedMenu(hash) {
+                const key = 'menu_' + hash + '_shown'
+                this[key] = !this[key]
+            },
+        },
+    }
+
+    let WholePageData = {
+        contentTitleHTML: ${json.dumps(capture(self.content_title))|n},
+        feedbackMessage: "",
+
+        % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+            globalTheme: ${json.dumps(theme or None)|n},
+            referrer: location.href,
+        % endif
+
+        % if can_edit_help:
+            configureFieldsHelp: false,
+        % endif
+
+        globalSearchActive: false,
+        globalSearchTerm: '',
+        globalSearchData: ${json.dumps(global_search_data or [])|n},
+
+        mountedHooks: [],
+    }
+
+    ## declare nested menu visibility toggle flags
+    % for topitem in menus:
+        % if topitem['is_menu']:
+            % for item in topitem['items']:
+                % if item['is_menu']:
+                    WholePageData.menu_${id(item)}_shown = false
+                % endif
+            % endfor
+        % endif
+    % endfor
+
+  </script>
+</%def>
+
 <%def name="wtfield(form, name, **kwargs)">
   <div class="field-wrapper${' error' if form[name].errors else ''}">
     <label for="${name}">${form[name].label}</label>
@@ -258,3 +917,101 @@
     </div>
   </div>
 </%def>
+
+<%def name="simple_field(label, value)">
+  ## TODO: keep this? only used by personal profile view currently
+  ## (although could be useful for any readonly scenario)
+  <div class="field-wrapper">
+    <div class="field-row">
+      <label>${label}</label>
+      <div class="field">
+        ${'' if value is None else value}
+      </div>
+    </div>
+  </div>
+</%def>
+
+##############################
+## vue components + app
+##############################
+
+<%def name="render_vue_templates()">
+  ${page_help.declare_vars()}
+  ${multi_file_upload.declare_vars()}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
+
+  ## DEPRECATED; called for back-compat
+  ${self.render_whole_page_template()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_whole_page_template()">
+  ${self.render_vue_template_whole_page()}
+  ${self.declare_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="declare_whole_page_vars()">
+  ${self.render_vue_script_whole_page()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ## DEPRECATED; called for back-compat
+  ${self.modify_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="modify_whole_page_vars()">
+  <script>
+
+    % if request.user:
+    FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
+    FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
+    % endif
+
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${make_wutta_components()}
+  ${make_grid_filter_components()}
+  ${page_help.make_component()}
+  ${multi_file_upload.make_component()}
+  <script>
+    FeedbackForm.data = function() { return FeedbackFormData }
+    Vue.component('feedback-form', FeedbackForm)
+  </script>
+
+  ## DEPRECATED; called for back-compat
+  ${self.finalize_whole_page_vars()}
+  ${self.make_whole_page_component()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_component()">
+  <script>
+    WholePage.data = function() { return WholePageData }
+    Vue.component('whole-page', WholePage)
+  </script>
+</%def>
+
+<%def name="make_vue_app()">
+  ## DEPRECATED; called for back-compat
+  ${self.make_whole_page_app()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_app()">
+  <script>
+    new Vue({
+        el: '#app'
+    })
+  </script>
+</%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="finalize_whole_page_vars()"></%def>
diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako
index ec097e5d..b6376448 100644
--- a/tailbone/templates/base_meta.mako
+++ b/tailbone/templates/base_meta.mako
@@ -1,17 +1,12 @@
 ## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/base_meta.mako" />
 
-<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def>
-
-<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
+<%def name="app_title()">${app.get_node_title()}</%def>
 
 <%def name="favicon()">
-  <link rel="icon" type="image/x-icon" href="${request.static_url('tailbone:static/img/rattail.ico')}" />
+  <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" />
 </%def>
 
-<%def name="header_logo()"></%def>
-
-<%def name="footer()">
-  <p class="has-text-centered">
-    powered by ${h.link_to("Rattail", url('about'))}
-  </p>
+<%def name="header_logo()">
+  ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")}
 </%def>
diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako
index 24eb6456..7d6f121f 100644
--- a/tailbone/templates/batch/importer/view_row.mako
+++ b/tailbone/templates/batch/importer/view_row.mako
@@ -68,7 +68,7 @@
 % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <tailbone-form></tailbone-form>
     <br />
@@ -76,12 +76,5 @@
   </div>
 </%def>
 
-<%def name="render_form()">
-  ${parent.render_form()}
-  % if not use_buefy:
-      ${self.field_diff_table()}
-  % endif
-</%def>
-
 
 ${parent.body()}
diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako
index 21e3d7aa..bea10a97 100644
--- a/tailbone/templates/batch/index.mako
+++ b/tailbone/templates/batch/index.mako
@@ -1,148 +1,97 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
-
-        var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'};
-        var dialog_opened = false;
-
-        $(function() {
-
-            $('#refresh-results-button').click(function() {
-                var count = $('.grid-wrapper').gridwrapper('results_count');
-                if (!count) {
-                    alert("There are no batch results to refresh.");
-                    return;
-                }
-                var form = $('form[name="refresh-results"]');
-                $(this).button('option', 'label', "Refreshing, please wait...").button('disable');
-                form.submit();
-            });
-
-            $('#execute-results-button').click(function() {
-                var count = $('.grid-wrapper').gridwrapper('results_count');
-                if (!count) {
-                    alert("There are no batch results to execute.");
-                    return;
-                }
-                var form = $('form[name="execute-results"]');
-                if (has_execution_options) {
-                    $('#execution-options-dialog').dialog({
-                        title: "Execution Options",
-                        width: 550,
-                        height: 300,
-                        modal: true,
-                        buttons: [
-                            {
-                                text: "Execute",
-                                click: function(event) {
-                                    dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable');
-                                    form.submit();
-                                }
-                            },
-                            {
-                                text: "Cancel",
-                                click: function() {
-                                    $(this).dialog('close');
-                                }
-                            }
-                        ],
-                        open: function() {
-                            if (! dialog_opened) {
-                                $('#execution-options-dialog select[auto-enhance="true"]').selectmenu();
-                                $('#execution-options-dialog select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) {
-                                    show_all_options($(this));
-                                });
-                                dialog_opened = true;
-                            }
-                        }
-                    });
-                } else {
-                    $(this).button('option', 'label', "Executing, please wait...").button('disable');
-                    form.submit();
-                }
-            });
-
-        });
-
-      </script>
-  % endif
-  % endif
-</%def>
-
 <%def name="grid_tools()">
   ${parent.grid_tools()}
 
   ## Refresh Results
   % if master.results_refreshable and master.has_perm('refresh'):
-      % if use_buefy:
-          <b-button type="is-primary"
-                    disabled
-                    title="TODO: need to implement this for new theme">
-            Refresh Results
-          </b-button>
-      % else:
-          <button type="button" id="refresh-results-button">
-            Refresh Results
-          </button>
-      % endif
+      <b-button type="is-primary"
+                :disabled="refreshResultsButtonDisabled"
+                icon-pack="fas"
+                icon-left="redo"
+                @click="refreshResults()">
+        {{ refreshResultsButtonText }}
+      </b-button>
+      ${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')}
+      ${h.csrf_token(request)}
+      ${h.end_form()}
   % endif
 
   ## Execute Results
   % if master.results_executable and master.has_perm('execute_multiple'):
-      % if use_buefy:
-          <b-button type="is-primary"
-                    @click="executeResults()"
-                    :disabled="!total">
-            Execute Results
-          </b-button>
+      <b-button type="is-primary"
+                @click="executeResults()"
+                icon-pack="fas"
+                icon-left="arrow-circle-right"
+                :disabled="!total">
+        Execute Results
+      </b-button>
 
-          <b-modal has-modal-card
-                   :active.sync="showExecutionOptions">
-            <div class="modal-card">
+      <b-modal has-modal-card
+               :active.sync="showExecutionOptions">
+        <div class="modal-card">
 
-              <header class="modal-card-head">
-                <p class="modal-card-title">Execution Options</p>
-              </header>
-
-              <section class="modal-card-body">
-                <p>
-                  Please be advised, you are about to execute {{ total }} batches!
-                </p>
-                <br />
-                <tailbone-form ref="executeResultsForm"></tailbone-form>
-              </section>
-
-              <footer class="modal-card-foot">
-                <b-button @click="showExecutionOptions = false">
-                  Cancel
-                </b-button>
-                <once-button type="is-primary"
-                             @click="submitExecuteResults()"
-                             text="Execute">
-                </once-button>
-              </footer>
+          <header class="modal-card-head">
+            <p class="modal-card-title">Execution Options</p>
+          </header>
 
+          <section class="modal-card-body">
+            <p>
+              Please be advised, you are about to execute {{ total }} batches!
+            </p>
+            <br />
+            <div class="form-wrapper">
+              <div class="form">
+                ${execute_form.render_vue_tag(ref='executeResultsForm')}
+              </div>
             </div>
-          </b-modal>
+          </section>
 
-      % else:
-          <button type="button" id="execute-results-button">Execute Results</button>
-      % endif
+          <footer class="modal-card-foot">
+            <b-button @click="showExecutionOptions = false">
+              Cancel
+            </b-button>
+            <once-button type="is-primary"
+                         @click="submitExecuteResults()"
+                         icon-left="arrow-circle-right"
+                         :text="'Execute ' + total + ' Batches'">
+            </once-button>
+          </footer>
+
+        </div>
+      </b-modal>
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
+      ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
+  % endif
+</%def>
 
-        TailboneForm.methods.submit = function() {
-            this.$refs.actualForm.submit()
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if master.results_refreshable and master.has_perm('refresh'):
+      <script>
+
+        TailboneGridData.refreshResultsButtonText = "Refresh Results"
+        TailboneGridData.refreshResultsButtonDisabled = false
+
+        TailboneGrid.methods.refreshResults = function() {
+            this.refreshResultsButtonDisabled = true
+            this.refreshResultsButtonText = "Working, please wait..."
+            this.$refs.refreshResultsForm.submit()
+        }
+
+      </script>
+  % endif
+  % if master.results_executable and master.has_perm('execute_multiple'):
+      <script>
+
+        ${execute_form.vue_component}.methods.submit = function() {
+            this.$refs.actualExecuteForm.submit()
         }
 
         TailboneGridData.hasExecutionOptions = ${json.dumps(master.has_execution_options(batch))|n}
@@ -176,46 +125,9 @@
   % endif
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
-
-        TailboneForm.data = function() { return TailboneFormData }
-
-        Vue.component('tailbone-form', TailboneForm)
-
-      </script>
+      ${execute_form.render_vue_finalize()}
   % endif
 </%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  % if master.results_executable and master.has_perm('execute_multiple'):
-      ${execute_form.render_deform(form_kwargs={'ref': 'actualForm'}, buttons=False)|n}
-  % endif
-</%def>
-
-
-${parent.body()}
-
-% if not use_buefy:
-
-## Refresh Results
-% if master.results_refreshable and master.has_perm('refresh'):
-    ${h.form(url('{}.refresh_results'.format(route_prefix)), name='refresh-results')}
-    ${h.csrf_token(request)}
-    ${h.end_form()}
-% endif
-
-% if master.results_executable and master.has_perm('execute_multiple'):
-    <div id="execution-options-dialog" style="display: none;">
-      <br />
-      <p>
-        Please be advised, you are about to execute multiple batches!
-      </p>
-      <br />
-      ${execute_form.render_deform(form_kwargs={'name': 'execute-results'}, buttons=False)|n}
-    </div>
-% endif
-% endif
diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako
index bc64d498..cddaa2c5 100644
--- a/tailbone/templates/batch/inventory/desktop_form.mako
+++ b/tailbone/templates/batch/inventory/desktop_form.mako
@@ -1,301 +1,305 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/base.mako" />
+<%inherit file="/form.mako" />
 
 <%def name="title()">Inventory Form</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))}
+<%def name="object_helpers()">
+  <nav class="panel">
+    <p class="panel-heading">Batch</p>
+    <div class="panel-block buttons">
+      <div style="display: flex; flex-direction: column;">
+
+        <once-button type="is-primary"
+                     icon-left="eye"
+                     tag="a" href="${url('batch.inventory.view', uuid=batch.uuid)}"
+                     text="View Batch">
+        </once-button>
+
+        % if not batch.executed and master.has_perm('edit'):
+            ${h.form(master.get_action_url('toggle_complete', batch), **{'@submit': 'toggleCompleteSubmitting = true'})}
+            ${h.csrf_token(request)}
+            ${h.hidden('complete', value='true')}
+            <b-button type="is-primary"
+                      native-type="submit"
+                      icon-pack="fas"
+                      icon-left="check"
+                      :disabled="toggleCompleteSubmitting">
+              {{ toggleCompleteSubmitting ? "Working, please wait..." : "Mark Complete" }}
+            </b-button>
+            ${h.end_form()}
+        % endif
+
+      </div>
+    </div>
+  </nav>
+</%def>
+
+<%def name="render_form_template()">
+  <script type="text/x-template" id="${form.component}-template">
+    <div class="product-info">
+
+      ${h.form(form.action_url, **{'@submit': 'handleSubmit'})}
+      ${h.csrf_token(request)}
+
+      ${h.hidden('product', **{':value': 'productInfo.uuid'})}
+      ${h.hidden('upc', **{':value': 'productInfo.upc'})}
+      ${h.hidden('brand_name', **{':value': 'productInfo.brand_name'})}
+      ${h.hidden('description', **{':value': 'productInfo.description'})}
+      ${h.hidden('size', **{':value': 'productInfo.size'})}
+      ${h.hidden('case_quantity', **{':value': 'productInfo.case_quantity'})}
+
+      <b-field label="Product UPC" horizontal>
+        <div style="display: flex; flex-direction: column;">
+          <b-input v-model="productUPC"
+                   ref="productUPC"
+                   @input="productChanged"
+                   @keydown.native="productKeydown">
+          </b-input>
+          <div class="has-text-centered block">
+
+            <p v-if="!productInfo.uuid"
+               class="block">
+              please ENTER a scancode
+            </p>
+
+            <p v-if="productInfo.uuid"
+               class="block">
+              {{ productInfo.full_description }}
+            </p>
+
+            <div style="min-height: 150px; margin: 0.5rem 0;">
+              <img v-if="productInfo.uuid"
+                   :src="productInfo.image_url" />
+            </div>
+
+            <div v-if="alreadyPresentInBatch"
+                 class="has-background-danger">
+              product already exists in batch, please confirm count
+            </div>
+
+            <div v-if="forceUnitItem"
+                 class="has-background-danger">
+              pack item scanned, but must count units instead
+            </div>
+
+##                 <div v-if="productNotFound"
+##                      class="has-background-danger">
+##                   please confirm UPC and provide more details
+##                 </div>
+
+          </div>
+        </div>
+      </b-field>
+
+##           <div v-if="productNotFound"
+##                ## class="product-fields"
+##                >
+##
+##             <div class="field-wrapper brand_name">
+##               <label for="brand_name">Brand Name</label>
+##               <div class="field">${h.text('brand_name')}</div>
+##             </div>
+##
+##             <div class="field-wrapper description">
+##               <label for="description">Description</label>
+##               <div class="field">${h.text('description')}</div>
+##             </div>
+##
+##             <div class="field-wrapper size">
+##               <label for="size">Size</label>
+##               <div class="field">${h.text('size')}</div>
+##             </div>
+##
+##             <div class="field-wrapper case_quantity">
+##               <label for="case_quantity">Units in Case</label>
+##               <div class="field">${h.text('case_quantity')}</div>
+##             </div>
+##
+##           </div>
+
+      % if allow_cases:
+          <b-field label="Cases" horizontal>
+            <b-input name="cases"
+                     v-model="productCases"
+                     ref="productCases"
+                     :disabled="!productInfo.uuid">
+            </b-input>
+          </b-field>
+      % endif
+
+      <b-field label="Units" horizontal>
+        <b-input name="units"
+                 v-model="productUnits"
+                 ref="productUnits"
+                 :disabled="!productInfo.uuid">
+        </b-input>
+      </b-field>
+
+      <b-button type="is-primary"
+                native-type="submit"
+                :disabled="submitting">
+        {{ submitting ? "Working, please wait..." : "Submit" }}
+      </b-button>
+
+      ${h.end_form()}
+    </div>
+  </script>
+
   <script type="text/javascript">
 
-    function assert_quantity() {
-        % if allow_cases:
-        var cases = parseFloat($('#cases').val());
-        if (!isNaN(cases)) {
-            if (cases > 999999) {
-                alert("Case amount is invalid!");
-                $('#cases').select().focus();
-                return false;
-            }
-            return true;
-        }
-        % endif
-        var units = parseFloat($('#units').val());
-        if (!isNaN(units)) {
-            if (units > 999999) {
-                alert("Unit amount is invalid!");
-                $('#units').select().focus();
-                return false;
-            }
-            return true;
-        }
-        alert("Please provide case and/or unit quantity");
-        % if allow_cases:
-        $('#cases').select().focus();
-        % else:
-        $('#units').select().focus();
-        % endif
-        return false;
-    }
+    let ${form.vue_component} = {
+        template: '#${form.component}-template',
+        mixins: [SimpleRequestMixin],
 
-    function invalid_product(msg) {
-        $('#product-info p').text(msg);
-        $('#product-info img').hide();
-        $('#upc').focus().select();
-        % if allow_cases:
-        $('.field-wrapper.cases input').prop('disabled', true);
-        % endif
-        $('.field-wrapper.units input').prop('disabled', true);
-        $('.buttons button').button('disable');
-    }
+        mounted() {
+            this.$refs.productUPC.focus()
+        },
 
-    function pretty_quantity(cases, units) {
-        if (cases && units) {
-            return cases + " cases, " + units + " units";
-        } else if (cases) {
-            return cases + " cases";
-        } else if (units) {
-            return units + " units";
-        }
-        return '';
-    }
+        methods: {
 
-    function show_quantity(name, cases, units) {
-        var quantity = pretty_quantity(cases, units);
-        var field = $('.field-wrapper.quantity_' + name);
-        field.find('.field').text(quantity);
-        if (quantity || name == 'ordered') {
-            field.show();
-        } else {
-            field.hide();
-        }
-    }
+            clearProduct() {
+                this.productInfo = {}
+                ## this.productNotFound = false
+                this.alreadyPresentInBatch = false
+                this.forceUnitItem = false
+                this.productCases = null
+                this.productUnits = null
+            },
 
-    $(function() {
+            assertQuantity() {
 
-        $('#upc').keydown(function(event) {
-
-            if (key_allowed(event)) {
-                return true;
-            }
-            if (key_modifies(event)) {
-                $('#product').val('');
-                $('#product-info p').html("please ENTER a scancode");
-                $('#product-info img').hide();
-                $('#product-info .warning').hide();
-                $('.product-fields').hide();
-                // $('.receiving-fields').hide();
                 % if allow_cases:
-                $('.field-wrapper.cases input').prop('disabled', true);
+                    let cases = parseFloat(this.productCases)
+                    if (!isNaN(cases)) {
+                        if (cases > 999999) {
+                            alert("Case amount is invalid!")
+                            this.$refs.productCases.focus()
+                            return false
+                        }
+                        return true
+                    }
                 % endif
-                $('.field-wrapper.units input').prop('disabled', true);
-                $('.buttons button').button('disable');
-                return true;
-            }
 
-            // when user presses ENTER, do product lookup
-            if (event.which == 13) {
-                var upc = $(this).val();
-                var data = {'upc': upc};
-                $.get('${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}', data, function(data) {
+                let units = parseFloat(this.productUnits)
+                if (!isNaN(units)) {
+                    if (units > 999999) {
+                        alert("Unit amount is invalid!")
+                        this.$refs.productUnits.focus()
+                        return false
+                    }
+                    return true
+                }
 
-                    if (data.error) {
-                        alert(data.error);
-                        if (data.redirect) {
-                            $('#inventory-form').mask("Redirecting...");
-                            location.href = data.redirect;
+                alert("Please provide case and/or unit quantity")
+                % if allow_cases:
+                    this.$refs.productCases.focus()
+                % else:
+                    this.$refs.productUnits.focus()
+                % endif
+            },
+
+            handleSubmit(event) {
+                if (!this.assertQuantity()) {
+                    event.preventDefault()
+                    return
+                }
+                this.submitting = true
+            },
+
+            productChanged() {
+                this.clearProduct()
+            },
+
+            productKeydown(event) {
+                if (event.which == 13) { // ENTER
+                    this.productLookup()
+                    event.preventDefault()
+                }
+            },
+
+            productLookup() {
+                let url = '${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}'
+                let params = {
+                    upc: this.productUPC,
+                }
+                this.simpleGET(url, params, response => {
+
+                    if (response.data.product.uuid) {
+
+                        this.productUPC = response.data.product.upc_pretty
+                        this.productInfo = response.data.product
+                        this.forceUnitItem = response.data.force_unit_item
+                        this.alreadyPresentInBatch = response.data.already_present_in_batch
+
+                        if (this.alreadyPresentInBatch) {
+                            this.productCases = response.data.cases
+                            this.productUnits = response.data.units
+                        } else if (this.productInfo.type2) {
+                            this.productUnits = this.productInfo.units
                         }
 
-                    } else if (data.product) {
-                        $('#upc').val(data.product.upc_pretty);
-                        $('#product').val(data.product.uuid);
-                        $('#brand_name').val(data.product.brand_name);
-                        $('#description').val(data.product.description);
-                        $('#size').val(data.product.size);
-                        $('#case_quantity').val(data.product.case_quantity);
-
-                        if (data.force_unit_item) {
-                            $('#product-info .warning.force-unit').show();
-                        }
-
-                        if (data.already_present_in_batch) {
-                            $('#product-info .warning.present').show();
-                            $('#cases').val(data.cases);
-                            $('#units').val(data.units);
-
-                        } else if (data.product.type2) {
-                            $('#units').val(data.product.units);
-                        }
-
-                        $('#product-info p').text(data.product.full_description);
-                        $('#product-info img').attr('src', data.product.image_url).show();
-                        if (! data.product.uuid) {
-                            // $('#product-info .warning.notfound').show();
-                            $('.product-fields').show();
-                        }
-                        $('#product-info .warning.notordered').show();
-                        % if allow_cases:
-                        $('.field-wrapper.cases input').prop('disabled', false);
-                        % endif
-                        $('.field-wrapper.units input').prop('disabled', false);
-                        $('.buttons button').button('enable');
-
-                        if (data.product.type2) {
-                            $('#units').focus().select();
-                        } else {
-                            % if allow_cases and prefer_cases:
-                            if ($('#cases').val()) {
-                                $('#cases').focus().select();
-                            } else if ($('#units').val()) {
-                                $('#units').focus().select();
+                        this.$nextTick(() => {
+                            if (this.productInfo.type2) {
+                                this.$refs.productUnits.focus()
                             } else {
-                                $('#cases').focus().select();
+                                % if allow_cases and prefer_cases:
+                                    if (this.productCases) {
+                                        this.$refs.productCases.focus()
+                                    } else if (this.productUnits) {
+                                        this.$refs.productUnits.focus()
+                                    } else {
+                                        this.$refs.productCases.focus()
+                                    }
+                                % else:
+                                    this.$refs.productUnits.focus()
+                                % endif
                             }
-                            % else:
-                            $('#units').focus().select();
-                            % endif
-                        }
-
-                    // TODO: this is maybe useful if "new products" may be added via inventory batch
-                    // } else if (data.upc) {
-                    //     $('#upc').val(data.upc_pretty);
-                    //     $('#product-info p').text("product not found in our system");
-                    //     $('#product-info img').attr('src', data.image_url).show();
-
-                    //     $('#product').val('');
-                    //     $('#brand_name').val('');
-                    //     $('#description').val('');
-                    //     $('#size').val('');
-                    //     $('#case_quantity').val('');
-
-                    //     $('#product-info .warning.notfound').show();
-                    //     $('.product-fields').show();
-                    //     $('#brand_name').focus();
-                    //     $('.field-wrapper.cases input').prop('disabled', false);
-                    //     $('.field-wrapper.units input').prop('disabled', false);
-                    //     $('.buttons button').button('enable');
+                        })
 
                     } else {
-                        invalid_product('product not found');
+                        ## this.productNotFound = true
+                        alert("Product not found!")
+
+                        // focus/select UPC entry
+                        this.$refs.productUPC.focus()
+                        // nb. must traverse into the <b-input> element
+                        this.$refs.productUPC.$el.firstChild.select()
                     }
-                });
-            }
-            return false;
-        });
 
-        $('#inventory-form').submit(function() {
-            if (! assert_quantity()) {
-                return false;
-            }
-            disable_submit_button(this);
-            $(this).mask("Working...");
-        });
+                }, response => {
+                    if (response.data.error) {
+                        alert(response.data.error)
+                        if (response.data.redirect) {
+                            location.href = response.data.redirect
+                        }
+                    }
+                })
+            },
+        },
+    }
+
+    let ${form.vue_component}Data = {
+        submitting: false,
+
+        productUPC: null,
+        ## productNotFound: false,
+        productInfo: {},
 
-        $('#upc').focus();
         % if allow_cases:
-        $('.field-wrapper.cases input').prop('disabled', true);
+        productCases: null,
         % endif
-        $('.field-wrapper.units input').prop('disabled', true);
-        $('.buttons button').button('disable');
+        productUnits: null,
+
+        alreadyPresentInBatch: false,
+        forceUnitItem: false,
+    }
 
-    });
   </script>
 </%def>
 
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-
-    #product-info {
-        margin-top: 0.5em;
-        text-align: center;
-    }
-
-    #product-info p {
-        margin-left: 0.5em;
-    }
-
-    #product-info .img-wrapper {
-        height: 150px;
-        margin: 0.5em 0;
-    }
-
-    #product-info .warning {
-        background: #f66;
-        display: none;
-    }
-
-  </style>
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.toggleCompleteSubmitting = false
+  </script>
 </%def>
-
-
-<%def name="context_menu_items()">
-  <li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li>
-</%def>
-
-
-<ul id="context-menu">
-  ${self.context_menu_items()}
-</ul>
-
-<div class="form-wrapper">
-  ${h.form(form.action_url, id='inventory-form')}
-  ${h.csrf_token(request)}
-
-  <div class="field-wrapper">
-    <label for="upc">Product UPC</label>
-    <div class="field">
-      ${h.hidden('product')}
-      <div>${h.text('upc', autocomplete='off')}</div>
-      <div id="product-info">
-        <p>please ENTER a scancode</p>
-        <div class="img-wrapper"><img /></div>
-        <div class="warning notfound">please confirm UPC and provide more details</div>
-        <div class="warning present">product already exists in batch, please confirm count</div>
-        <div class="warning force-unit">pack item scanned, but must count units instead</div>
-      </div>
-    </div>
-  </div>
-
-  <div class="product-fields" style="display: none;">
-
-    <div class="field-wrapper brand_name">
-      <label for="brand_name">Brand Name</label>
-      <div class="field">${h.text('brand_name')}</div>
-    </div>
-
-    <div class="field-wrapper description">
-      <label for="description">Description</label>
-      <div class="field">${h.text('description')}</div>
-    </div>
-
-    <div class="field-wrapper size">
-      <label for="size">Size</label>
-      <div class="field">${h.text('size')}</div>
-    </div>
-
-    <div class="field-wrapper case_quantity">
-      <label for="case_quantity">Units in Case</label>
-      <div class="field">${h.text('case_quantity')}</div>
-    </div>
-
-  </div>
-
-  % if allow_cases:
-      <div class="field-wrapper cases">
-        <label for="cases">Cases</label>
-        <div class="field">${h.text('cases', autocomplete='off')}</div>
-      </div>
-  % endif
-
-  <div class="field-wrapper units">
-    <label for="units">Units</label>
-    <div class="field">${h.text('units', autocomplete='off')}</div>
-  </div>
-
-  <div class="buttons">
-    ${h.submit('submit', "Submit")}
-  </div>
-
-  ${h.end_form()}
-</div>
diff --git a/tailbone/templates/batch/newproduct/configure.mako b/tailbone/templates/batch/newproduct/configure.mako
new file mode 100644
index 00000000..e4fa346a
--- /dev/null
+++ b/tailbone/templates/batch/newproduct/configure.mako
@@ -0,0 +1,9 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+  ${self.input_file_templates_section()}
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako
new file mode 100644
index 00000000..5ecabd4d
--- /dev/null
+++ b/tailbone/templates/batch/pos/view.mako
@@ -0,0 +1,9 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/batch/view.mako" />
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/batch/pricing/configure.mako b/tailbone/templates/batch/pricing/configure.mako
new file mode 100644
index 00000000..8b5a90bb
--- /dev/null
+++ b/tailbone/templates/batch/pricing/configure.mako
@@ -0,0 +1,22 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Options</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="rattail.batch.pricing.allow_future"
+                  v-model="simpleSettings['rattail.batch.pricing.allow_future']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow "future" pricing
+      </b-checkbox>
+    </b-field>
+
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako
new file mode 100644
index 00000000..4f91cb02
--- /dev/null
+++ b/tailbone/templates/batch/vendorcatalog/configure.mako
@@ -0,0 +1,47 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+  ${self.input_file_templates_section()}
+
+  <h3 class="block is-size-3">Options</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="rattail.batch.vendor_catalog.allow_future"
+                  v-model="simpleSettings['rattail.batch.vendor_catalog.allow_future']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow "future" cost changes
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Catalog Parsers</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Only the selected parsers will be exposed to users.
+    </p>
+
+    % for Parser in catalog_parsers:
+        <b-field message="${Parser.key}">
+          <b-checkbox name="catalog_parser_${Parser.key}"
+                      v-model="catalogParsers['${Parser.key}']"
+                      native-value="true"
+                      @input="settingsNeedSaved = true">
+            ${Parser.display}
+          </b-checkbox>
+        </b-field>
+    % endfor
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako
index 87d65c54..d9d62bd1 100644
--- a/tailbone/templates/batch/vendorcatalog/create.mako
+++ b/tailbone/templates/batch/vendorcatalog/create.mako
@@ -1,55 +1,39 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    var vendormap = {
-        % for i, parser in enumerate(parsers, 1):
-            '${parser.key}': ${parser.vendormap_value|n}${',' if i < len(parsers) else ''}
-        % endfor
-    };
+    ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n}
 
-    $(function() {
+    ${form.vue_component}Data.vendorName = null
+    ${form.vue_component}Data.vendorNameReplacement = null
 
-        if ($('select[name="parser_key"] option:first').is(':selected')) {
-            $('.vendor_uuid .autocomplete-container').hide();
-        } else {
-            $('.vendor_uuid input[name="vendor_uuid"]').val('');
-            $('.vendor_uuid .autocomplete-display').hide();
-            $('.vendor_uuid .autocomplete-display button').show();
-            $('.vendor_uuid .autocomplete-textbox').val('');
-            $('.vendor_uuid .autocomplete-textbox').show();
-            $('.vendor_uuid .autocomplete-container').show();
-        }
-
-        $('select[name="parser_key"]').on('selectmenuchange', function() {
-            if ($(this).find('option:first').is(':selected')) {
-                $('.vendor_uuid .autocomplete-container').hide();
-            } else {
-                var vendor = vendormap[$(this).val()];
-                if (vendor) {
-                    $('.vendor_uuid input[name="vendor_uuid"]').val(vendor.uuid);
-                    $('.vendor_uuid .autocomplete-textbox').hide();
-                    $('.vendor_uuid .autocomplete-display span:first').text(vendor.name);
-                    $('.vendor_uuid .autocomplete-display button').hide();
-                    $('.vendor_uuid .autocomplete-display').show();
-                    $('.vendor_uuid .autocomplete-container').show();
-                } else {
-                    $('.vendor_uuid input[name="vendor_uuid"]').val('');
-                    $('.vendor_uuid .autocomplete-display').hide();
-                    $('.vendor_uuid .autocomplete-display button').show();
-                    $('.vendor_uuid .autocomplete-textbox').val('');
-                    $('.vendor_uuid .autocomplete-textbox').show();
-                    $('.vendor_uuid .autocomplete-container').show();
-                    $('.vendor_uuid .autocomplete-textbox').focus();
-                }
+    ${form.vue_component}.watch.field_model_parser_key = function(val) {
+        let parser = this.parsers[val]
+        if (parser.vendor_uuid) {
+            if (this.field_model_vendor_uuid != parser.vendor_uuid) {
+                // this.field_model_vendor_uuid = parser.vendor_uuid
+                // this.vendorName = parser.vendor_name
+                this.$refs.vendorAutocomplete.setSelection({
+                    value: parser.vendor_uuid,
+                    label: parser.vendor_name,
+                })
             }
-        });
+        }
+    }
+
+    ${form.vue_component}.methods.vendorLabelChanging = function(label) {
+        this.vendorNameReplacement = label
+    }
+
+    ${form.vue_component}.methods.vendorChanged = function(uuid) {
+        if (uuid) {
+            this.vendorName = this.vendorNameReplacement
+            this.vendorNameReplacement = null
+        }
+    }
 
-    });
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/index.mako b/tailbone/templates/batch/vendorcatalog/index.mako
new file mode 100644
index 00000000..fa6e4a5a
--- /dev/null
+++ b/tailbone/templates/batch/vendorcatalog/index.mako
@@ -0,0 +1,11 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/batch/index.mako" />
+
+<%def name="context_menu_items()">
+  ${parent.context_menu_items()}
+  % if h.route_exists(request, 'vendors') and request.has_perm('vendors.list'):
+      <li>${h.link_to("View Vendors", url('vendors'))}</li>
+  % endif
+</%def>
+
+${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako
new file mode 100644
index 00000000..0128e3b3
--- /dev/null
+++ b/tailbone/templates/batch/vendorcatalog/view_row.mako
@@ -0,0 +1,13 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view_row.mako" />
+
+<%def name="render_form()">
+  <div class="form">
+    <tailbone-form></tailbone-form>
+    <br />
+    ${catalog_entry_diff.render_html()}
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index d1855eb2..7c81ab0e 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -1,108 +1,65 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js') + '?ver={}'.format(tailbone.__version__))}
-  <script type="text/javascript">
-
-    var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'};
-
-    $(function() {
-        % if master.has_worksheet:
-            $('.load-worksheet').click(function() {
-                disable_button(this);
-                location.href = '${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}';
-            });
-        % endif
-        % if master.batch_refreshable(batch) and request.has_perm('{}.refresh'.format(permission_prefix)):
-            $('#refresh-data').click(function() {
-                $(this)
-                    .button('option', 'disabled', true)
-                    .button('option', 'label', "Working, please wait...");
-                location.href = '${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}';
-            });
-        % endif
-    });
-
-  </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if not use_buefy:
   <style type="text/css">
 
-    .grid-wrapper {
-        margin-top: 10px;
+    .modal-card-body label {
+        white-space: nowrap;
     }
 
-    .complete form {
-        display: inline;
+    .markdown p {
+        margin-bottom: 1.5rem;
     }
-    
+
   </style>
-  % endif
 </%def>
 
 <%def name="buttons()">
     <div class="buttons">
       ${self.leading_buttons()}
       ${refresh_button()}
+      ${self.trailing_buttons()}
     </div>
 </%def>
 
 <%def name="leading_buttons()">
   % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      % if use_buefy:
-          <once-button tag="a"
-                       href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}"
-                       text="Edit as Worksheet">
-          </once-button>
-      % else:
-          <button type="button" class="load-worksheet">Edit as Worksheet</button>
-      % endif
+      <once-button type="is-primary"
+                   tag="a" href="${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}"
+                   icon-left="edit"
+                   text="Edit as Worksheet">
+      </once-button>
   % endif
 </%def>
 
 <%def name="refresh_button()">
-  % if master.batch_refreshable(batch) and request.has_perm('{}.refresh'.format(permission_prefix)):
-      % if use_buefy:
-          ## TODO: this should surely use a POST request?
-          <once-button tag="a"
-                       href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}"
-                       text="Refresh Data">
-          </once-button>
-      % else:
-          <button type="button" class="button" id="refresh-data">Refresh Data</button>
-      % endif
+  % if master.batch_refreshable(batch) and master.has_perm('refresh'):
+      ## TODO: this should surely use a POST request?
+      <once-button type="is-primary"
+                   tag="a" href="${url('{}.refresh'.format(route_prefix), uuid=batch.uuid)}"
+                   text="Refresh Data"
+                   icon-left="redo">
+      </once-button>
   % endif
 </%def>
 
-<%def name="execute_submit_button()">
-  <b-button type="is-primary"
-            % if master.has_execution_options(batch):
-            @click="executeBatch"
-            % else:
-            native-type="submit"
-            % endif
-            % if not execute_enabled:
-            disabled
-            % elif not master.has_execution_options(batch):
-            :disabled="executeFormSubmitting"
-            % endif
-            % if why_not_execute:
-            title="${why_not_execute}"
-            % endif
-            >
-    % if master.has_execution_options(batch):
-    ${execute_title}
-    % else:
-    {{ executeFormButtonText }}
-    % endif
-  </b-button>
+<%def name="trailing_buttons()">
+  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
+      <b-button tag="a"
+                href="${master.get_action_url('download_worksheet', batch)}"
+                icon-pack="fas"
+                icon-left="download">
+        Download Worksheet
+      </b-button>
+      <b-button type="is-primary"
+                icon-pack="fas"
+                icon-left="upload"
+                @click="$emit('show-upload')">
+        Upload Worksheet
+      </b-button>
+  % endif
 </%def>
 
 <%def name="object_helpers()">
@@ -111,138 +68,284 @@
 </%def>
 
 <%def name="render_status_breakdown()">
-  % if status_breakdown is not Undefined and status_breakdown is not None:
-      <div class="object-helper">
-        <h3>Row Status Breakdown</h3>
-        <div class="object-helper-content">
-          % if use_buefy:
-              ${status_breakdown_grid.render_buefy_table_element(data_prop='statusBreakdownData', empty_labels=True)|n}
-          % elif status_breakdown:
-              <div class="grid full">
-                <table>
-                  % for i, (status, count) in enumerate(status_breakdown):
-                      <tr class="${'even' if i % 2 == 0 else 'odd'}">
-                        <td>${status}</td>
-                        <td>${count}</td>
-                      </tr>
-                  % endfor
-                </table>
-              </div>
-          % else:
-              <p>Nothing to report yet.</p>
-          % endif
-        </div>
+  <nav class="panel">
+    <p class="panel-heading">Row Status</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        ${status_breakdown_grid}
       </div>
-  % endif
+    </div>
+  </nav>
 </%def>
 
 <%def name="render_execute_helper()">
-  <div class="object-helper">
-    <h3>Batch Execution</h3>
-    <div class="object-helper-content">
+  <nav class="panel">
+    <p class="panel-heading">Execution</p>
+    <div class="panel-block">
+      <div style="display: flex; flex-direction: column; gap: 0.5rem;">
       % if batch.executed:
           <p>
-            Batch was executed
             ${h.pretty_datetime(request.rattail_config, batch.executed)}
             by ${batch.executed_by}
           </p>
       % elif master.handler.executable(batch):
-          % if request.has_perm('{}.execute'.format(permission_prefix)):
-              <p>Batch has not yet been executed.</p>
-              % if use_buefy:
-                  % if master.has_execution_options(batch):
-                      <p>TODO: must implement execution with options</p>
-                  % else:
-                      <execute-form></execute-form>
-                  % endif
-              % else:
-                  ## no buefy, do legacy thing
-                  <button type="button"
-                          % if not execute_enabled:
-                          disabled="disabled"
-                          % endif
-                          % if why_not_execute:
-                          title="${why_not_execute}"
-                          % endif
-                          class="button is-primary"
-                          id="execute-batch">
-                    ${execute_title}
-                  </button>
+          % if master.has_perm('execute'):
+              <b-button type="is-primary"
+                        % if not execute_enabled:
+                        disabled
+                        % if why_not_execute:
+                        title="${why_not_execute}"
+                        % endif
+                        % endif
+                        @click="showExecutionDialog = true"
+                        icon-pack="fas"
+                        icon-left="arrow-circle-right">
+                ${execute_title}
+              </b-button>
+
+              % if execute_enabled:
+                  <b-modal has-modal-card
+                           :active.sync="showExecutionDialog">
+                    <div class="modal-card">
+
+                      <header class="modal-card-head">
+                        <p class="modal-card-title">Execute ${model_title}</p>
+                      </header>
+
+                      <section class="modal-card-body">
+                        <p class="block has-text-weight-bold">
+                          What will happen when this batch is executed?
+                        </p>
+                        <div class="markdown">
+                          ${execution_described|n}
+                        </div>
+                        ${execute_form.render_vue_tag(ref='executeBatchForm')}
+                      </section>
+
+                      <footer class="modal-card-foot">
+                        <b-button @click="showExecutionDialog = false">
+                          Cancel
+                        </b-button>
+                        <once-button type="is-primary"
+                                     @click="submitExecuteBatch()"
+                                     icon-left="arrow-circle-right"
+                                     text="Execute Batch">
+                        </once-button>
+                      </footer>
+
+                    </div>
+                  </b-modal>
               % endif
+
           % else:
               <p>TODO: batch *may* be executed, but not by *you*</p>
           % endif
       % else:
           <p>TODO: batch cannot be executed..?</p>
       % endif
+      </div>
     </div>
-  </div>
-</%def>
-
-<%def name="render_form()">
-  ## TODO: should use self.render_form_buttons()
-  ## ${form.render(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
-  ${form.render(form_id='batch-form', buttons=capture(buttons))|n}
+  </nav>
 </%def>
 
 <%def name="render_this_page()">
   ${parent.render_this_page()}
-  % if not use_buefy:
-      % if master.handler.executable(batch) and request.has_perm('{}.execute'.format(permission_prefix)):
-          <div id="execution-options-dialog" style="display: none;">
-            ${execute_form.render_deform(form_kwargs={'name': 'batch-execution'}, buttons=False)|n}
-          </div>
-      % endif
+
+  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
+      <b-modal has-modal-card
+               :active.sync="showUploadDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Upload Worksheet</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p>
+              This will <span class="has-text-weight-bold">update</span>
+              the batch data with the worksheet file you provide.&nbsp;
+              Please be certain to use the right one!
+            </p>
+            <br />
+            ${upload_worksheet_form.render_vue_tag(ref='uploadForm')}
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="showUploadDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      @click="submitUpload()"
+                      icon-pack="fas"
+                      icon-left="upload"
+                      :disabled="uploadButtonDisabled">
+              {{ uploadButtonText }}
+            </b-button>
+          </footer>
+
+        </div>
+      </b-modal>
+  % endif
+
+</%def>
+
+<%def name="render_form()">
+  <div class="form">
+    <${form.component} @show-upload="showUploadDialog = true">
+    </${form.component}>
+  </div>
+</%def>
+
+<%def name="render_row_grid_tools()">
+  ${parent.render_row_grid_tools()}
+  % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
+      <b-button type="is-danger"
+                @click="deleteResultsInit()"
+                :disabled="!total"
+                icon-pack="fas"
+                icon-left="trash">
+        Delete Results
+      </b-button>
+      <b-modal has-modal-card
+               :active.sync="deleteResultsShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Delete Results</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              This batch has
+              <span class="has-text-weight-bold">${batch.rowcount}</span>
+              total rows.
+            </p>
+            <p class="block">
+              Your current filters have returned
+              <span class="has-text-weight-bold">{{ total }}</span>
+              results.
+            </p>
+            <p class="block">
+              Would you like to
+              <span class="has-text-danger has-text-weight-bold">
+                delete all {{ total }}
+              </span>
+              results?
+            </p>
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="deleteResultsShowDialog = false">
+              Cancel
+            </b-button>
+            <once-button type="is-danger"
+                         tag="a" href="${url('{}.delete_rows'.format(route_prefix), uuid=batch.uuid)}"
+                         icon-left="trash"
+                         text="Delete Results">
+            </once-button>
+          </footer>
+        </div>
+      </b-modal>
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  % if use_buefy and master.handler.executable(batch) and request.has_perm('{}.execute'.format(permission_prefix)):
-      ## TODO: stop using |n filter
-      ${execute_form.render_deform(buttons=capture(execute_submit_button), form_kwargs={'@submit': 'submitExecuteForm'})|n}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
+      ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})}
+  % endif
+  % if master.handler.executable(batch) and master.has_perm('execute'):
+      ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+## DEPRECATED; remains for back-compat
+## nb. this is called by parent template, /form.mako
+<%def name="render_form_template()">
+  ## TODO: should use self.render_form_buttons()
+  ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
+  ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n}
+</%def>
 
-    ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_grid.get_buefy_data()['data'])|n}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n}
+
+    ThisPage.methods.autoFilterStatus = function(row) {
+        this.$refs.rowGrid.setFilters([
+            {key: 'status_code',
+             verb: 'equal',
+             value: row.code},
+        ])
+        document.getElementById('rowGrid').scrollIntoView({
+            behavior: 'smooth',
+        })
+    }
+
+    % if not batch.executed and master.has_perm('edit'):
+        ${form.vue_component}Data.togglingBatchComplete = false
+    % endif
+
+    % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
+
+        ThisPageData.showUploadDialog = false
+        ThisPageData.uploadButtonText = "Upload & Update Batch"
+        ThisPageData.uploadButtonDisabled = false
+
+        ThisPage.methods.submitUpload = function() {
+            let form = this.$refs.uploadForm
+            let value = form.field_model_worksheet_file
+            if (!value) {
+                alert("Please choose a file to upload.")
+                return
+            }
+            this.uploadButtonDisabled = true
+            this.uploadButtonText = "Working, please wait..."
+            form.submit()
+        }
+
+        ${upload_worksheet_form.vue_component}.methods.submit = function() {
+            this.$refs.actualUploadForm.submit()
+        }
+
+    ## end 'external_worksheet'
+    % endif
+
+    % if execute_enabled and master.has_perm('execute'):
+
+        ThisPageData.showExecutionDialog = false
+
+        ThisPage.methods.submitExecuteBatch = function() {
+            this.$refs.executeBatchForm.submit()
+        }
+
+        ${execute_form.vue_component}.methods.submit = function() {
+            this.$refs.actualExecuteForm.submit()
+        }
+
+    % endif
+
+    % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
+
+        ${rows_grid.vue_component}Data.deleteResultsShowDialog = false
+
+        ${rows_grid.vue_component}.methods.deleteResultsInit = function() {
+            this.deleteResultsShowDialog = true
+        }
+
+    % endif
 
   </script>
-
-  % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)):
-      <script type="text/javascript">
-
-        ${execute_form.component_studly}Data.executeFormButtonText = "${execute_title}"
-        ${execute_form.component_studly}Data.executeFormSubmitting = false
-
-        ${execute_form.component_studly}.methods.executeBatch = function() {
-            alert("TODO: implement options dialog for batch execution")
-        }
-
-        ${execute_form.component_studly}.methods.submitExecuteForm = function() {
-            this.executeFormSubmitting = true
-            this.executeFormButtonText = "Executing, please wait..."
-        }
-
-      </script>
-  % endif
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
-  % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)):
-      <script type="text/javascript">
-
-        ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
-
-        Vue.component('${execute_form.component}', ${execute_form.component_studly})
-
-      </script>
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
+      ${upload_worksheet_form.render_vue_finalize()}
+  % endif
+  % if execute_enabled and master.has_perm('execute'):
+      ${execute_form.render_vue_finalize()}
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/worksheet.mako b/tailbone/templates/batch/worksheet.mako
index cf19a0e0..a0dca748 100644
--- a/tailbone/templates/batch/worksheet.mako
+++ b/tailbone/templates/batch/worksheet.mako
@@ -1,26 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/page.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    $(function() {
-
-        $('.worksheet .current-entry input').focus(function(event) {
-            $(this).parents('tr:first').addClass('active');
-        });
-
-        $('.worksheet .current-entry input').blur(function(event) {
-            $(this).parents('tr:first').removeClass('active');
-        });
-
-    });
-  </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako
new file mode 100644
index 00000000..c7f46d21
--- /dev/null
+++ b/tailbone/templates/configure-menus.mako
@@ -0,0 +1,445 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+    .topmenu-dropper {
+        min-width: 0.8rem;
+    }
+    .topmenu-dropper:-moz-drag-over {
+        background-color: blue;
+    }
+  </style>
+</%def>
+
+<%def name="form_content()">
+
+  ## nb. must be root to configure menus!  otherwise some of the
+  ## currently-defined menus may not appear on the page, so saving
+  ## would inadvertently remove them!
+  % if request.is_root:
+
+  ${h.hidden('menus', **{':value': 'JSON.stringify(allMenuData)'})}
+
+  <h3 class="is-size-3">Top-Level Menus</h3>
+  <p class="block">Click on a menu to edit.&nbsp; Drag things around to rearrange.</p>
+
+  <b-field grouped>
+
+    <b-field grouped v-for="key in menuSequence"
+             :key="key">
+      <span class="topmenu-dropper control"
+            @dragover.prevent
+            @dragenter.prevent
+            @drop="dropMenu($event, key)">
+        &nbsp;
+      </span>
+      <b-button :type="editingMenu && editingMenu.key == key ? 'is-primary' : null"
+                class="control"
+                @click="editMenu(key)"
+                :disabled="editingMenu && editingMenu.key != key"
+                :draggable="!editingMenu"
+                @dragstart.native="topMenuStartDrag($event, key)">
+        {{ allMenus[key].title }}
+      </b-button>
+    </b-field>
+
+    <div class="topmenu-dropper control"
+         @dragover.prevent
+         @dragenter.prevent
+         @drop="dropMenu($event, '_last_')">
+      &nbsp;
+    </div>
+    <b-button v-show="!editingMenu"
+              type="is-primary"
+              icon-pack="fas"
+              icon-left="plus"
+              @click="editMenuNew()">
+      Add
+    </b-button>
+
+  </b-field>
+
+  <div v-if="editingMenu"
+       style="max-width: 40%;">
+
+    <b-field grouped>
+    
+      <b-field label="Label">
+        <b-input v-model="editingMenu.title"
+                 ref="editingMenuTitleInput">
+        </b-input>
+      </b-field>
+
+      <b-field label="Actions">
+        <div class="buttons">
+          <b-button icon-pack="fas"
+                    icon-left="redo"
+                    @click="editMenuCancel()">
+            Revert / Cancel
+          </b-button>
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="save"
+                    @click="editMenuSave()">
+            Save
+          </b-button>
+          <b-button type="is-danger"
+                    icon-pack="fas"
+                    icon-left="trash"
+                    @click="editMenuDelete()">
+            Delete
+          </b-button>
+        </div>
+      </b-field>
+
+    </b-field>
+
+    <b-field>
+      <template #label>
+        <span style="margin-right: 2rem;">Menu Items</span>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="plus"
+                  @click="editMenuItemInitDialog()">
+          Add
+        </b-button>
+      </template>
+      <ul class="list">
+        <li v-for="item in editingMenu.items"
+            class="list-item"
+            draggable
+            @dragstart="menuItemStartDrag($event, item)"
+            @dragover.prevent
+            @dragenter.prevent
+            @drop="menuItemDrop($event, item)">
+          <span :class="item.type == 'sep' ? 'has-text-info' : null">
+            {{ item.type == 'sep' ? "-- separator --" : item.title }}
+          </span>
+          <span class="is-pulled-right grid-action">
+            <a href="#" @click.prevent="editMenuItemInitDialog(item)">
+              <i class="fas fa-edit"></i>
+              Edit
+            </a>
+            &nbsp;
+           <a href="#" class="has-text-danger"
+              @click.prevent="editMenuItemDelete(item)">
+              <i class="fas fa-trash"></i>
+              Delete
+            </a>
+            &nbsp;
+          </span>
+        </li>
+      </ul>
+    </b-field>
+
+    <b-modal has-modal-card
+             :active.sync="editMenuItemShowDialog">
+      <div class="modal-card">
+
+        <header class="modal-card-head">
+          <p class="modal-card-title">{{ editingMenuItem.isNew ? "Add" : "Edit" }} Item</p>
+        </header>
+
+        <section class="modal-card-body">
+
+          <b-field label="Item Type">
+            <b-select v-model="editingMenuItem.type">
+              <option value="item">Route Link</option>                      
+              <option value="sep">Separator</option>                      
+            </b-select>
+          </b-field>
+
+          <b-field label="Route"
+                   v-show="editingMenuItem.type == 'item'">
+            <b-select v-model="editingMenuItem.route"
+                      @input="editingMenuItemRouteChanged">
+              <option v-for="route in editMenuIndexRoutes"
+                      :key="route.route"
+                      :value="route.route">
+                {{ route.label }}
+              </option>                      
+            </b-select>
+          </b-field>
+
+          <b-field label="Label"
+                   v-show="editingMenuItem.type == 'item'">
+            <b-input v-model="editingMenuItem.title">
+            </b-input>
+          </b-field>
+
+        </section>
+
+        <footer class="modal-card-foot">
+          <b-button @click="editMenuItemShowDialog = false">
+            Cancel
+          </b-button>
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="save"
+                    :disabled="editMenuItemSaveDisabled"
+                    @click="editMenuSaveItem()">
+            Save
+          </b-button>
+        </footer>
+      </div>
+    </b-modal>
+
+  </div>
+
+  % else:
+      ## not root!
+
+      <b-notification type="is-warning">
+        You must become root to configure menus!
+      </b-notification>
+
+  % endif
+
+</%def>
+
+## TODO: should probably make some global "editable" flag that the
+## base configure template has knowledge of, and just set that to
+## false for this view
+<%def name="purge_button()">
+  % if request.is_root:
+      ${parent.purge_button()}
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n}
+
+    ThisPageData.allMenus = {}
+    % for topitem in menus:
+        ThisPageData.allMenus['${topitem['key']}'] = ${json.dumps(topitem)|n}
+    % endfor
+
+    ThisPageData.editMenuIndexRoutes = ${json.dumps(index_route_options)|n}
+
+    ThisPageData.editingMenu = null
+    ThisPageData.editingMenuItem = {isNew: true}
+    ThisPageData.editingMenuItemIndex = null
+
+    ThisPageData.editMenuItemShowDialog = false
+
+    // nb. this value is sent on form submit
+    ThisPage.computed.allMenuData = function() {
+        let menus = []
+        for (key of this.menuSequence) {
+            menus.push(this.allMenus[key])
+        }
+        return menus
+    }
+
+    ThisPage.methods.editMenu = function(key) {
+        if (this.editingMenu) {
+            return
+        }
+
+        // copy existing (original) menu to be edited
+        let original = this.allMenus[key]
+        this.editingMenu = {
+            key: key,
+            title: original.title,
+            items: [],
+        }
+
+        // and copy each item separately
+        for (let item of original.items) {
+            this.editingMenu.items.push({
+                key: item.key,
+                title: item.title,
+                route: item.route,
+                url: item.url,
+                perm: item.perm,
+                type: item.type,
+            })
+        }
+    }
+
+    ThisPage.methods.editMenuNew = function() {
+
+        // editing brand new menu
+        this.editingMenu = {items: []}
+
+        // focus title input
+        this.$nextTick(() => {
+            this.$refs.editingMenuTitleInput.focus()
+        })
+    }
+
+    ThisPage.methods.editMenuCancel = function(key) {
+        this.editingMenu = null
+    }
+
+    ThisPage.methods.editMenuSave = function() {
+
+        let key = this.editingMenu.key
+        if (key) {
+
+            // update existing (original) menu with user edits
+            this.allMenus[key] = this.editingMenu
+
+        } else {
+
+            // generate makeshift key
+            key = this.editingMenu.title.replace(/\W/g, '')
+
+            // add new menu to data set
+            this.allMenus[key] = this.editingMenu
+            this.menuSequence.push(key)
+        }
+
+        // no longer editing
+        this.editingMenu = null
+        this.settingsNeedSaved = true
+    }
+
+    ThisPage.methods.editMenuDelete = function() {
+
+        if (confirm("Really delete this menu?")) {
+            let key = this.editingMenu.key
+
+            // remove references from primary collections
+            let i = this.menuSequence.indexOf(key)
+            this.menuSequence.splice(i, 1)
+            delete this.allMenus[key]
+
+            // no longer editing
+            this.editingMenu = null
+            this.settingsNeedSaved = true
+        }
+    }
+
+    ## TODO: see also https://learnvue.co/2020/01/how-to-add-drag-and-drop-to-your-vuejs-project/#adding-drag-and-drop-functionality
+
+    ## TODO: see also https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API
+
+    ## TODO: maybe try out https://www.npmjs.com/package/vue-drag-drop
+
+    ThisPage.methods.topMenuStartDrag = function(event, key) {
+        event.dataTransfer.setData('key', key)
+    }
+
+    ThisPage.methods.dropMenu = function(event, target) {
+        let key = event.dataTransfer.getData('key')
+        if (target == key) {
+            return              // same target
+        }
+
+        let i = this.menuSequence.indexOf(key)
+        let j = this.menuSequence.indexOf(target)
+        if (i + 1 == j) {
+            return              // same target
+        }
+
+        if (target == '_last_') {
+            if (this.menuSequence[this.menuSequence.length-1] != key) {
+                this.menuSequence.splice(i, 1)
+                this.menuSequence.push(key)
+                this.settingsNeedSaved = true
+            }
+        } else {
+            this.menuSequence.splice(i, 1)
+            j = this.menuSequence.indexOf(target)
+            this.menuSequence.splice(j, 0, key)
+            this.settingsNeedSaved = true
+        }
+    }
+
+    ThisPage.methods.menuItemStartDrag = function(event, item) {
+        let i = this.editingMenu.items.indexOf(item)
+        event.dataTransfer.setData('itemIndex', i)
+    }
+
+    ThisPage.methods.menuItemDrop = function(event, item) {
+        let oldIndex = event.dataTransfer.getData('itemIndex')
+        let pruned = this.editingMenu.items.splice(oldIndex, 1)
+        let newIndex = this.editingMenu.items.indexOf(item)
+        this.editingMenu.items.splice(newIndex, 0, pruned[0])
+    }
+
+    ThisPage.methods.editMenuItemInitDialog = function(item) {
+
+        if (item === undefined) {
+            this.editingMenuItemIndex = null
+
+            // create new item to edit
+            this.editingMenuItem = {
+                isNew: true,
+                route: null,
+                title: null,
+                perm: null,
+                type: 'item',
+            }
+
+        } else {
+            this.editingMenuItemIndex = this.editingMenu.items.indexOf(item)
+
+            // copy existing (original item to be edited
+            this.editingMenuItem = {
+                key: item.key,
+                title: item.title,
+                route: item.route,
+                url: item.url,
+                perm: item.perm,
+                type: item.type,
+            }
+        }
+
+        this.editMenuItemShowDialog = true
+    }
+
+    ThisPage.methods.editingMenuItemRouteChanged = function(routeName) {
+        for (let route of this.editMenuIndexRoutes) {
+            if (route.route == routeName) {
+                this.editingMenuItem.title = route.label
+                this.editingMenuItem.perm = route.perm
+                break
+            }
+        }
+    }
+
+    ThisPage.computed.editMenuItemSaveDisabled = function() {
+        if (this.editingMenuItem.type == 'item') {
+            if (!this.editingMenuItem.route) {
+                return true
+            }
+            if (!this.editingMenuItem.title) {
+                return true
+            }
+        }
+        return false
+    }
+
+    ThisPage.methods.editMenuSaveItem = function() {
+
+        if (this.editingMenuItem.isNew) {
+            this.editingMenu.items.push(this.editingMenuItem)
+
+        } else {
+            this.editingMenu.items.splice(this.editingMenuItemIndex,
+                                          1,
+                                          this.editingMenuItem)
+        }
+
+        this.editMenuItemShowDialog = false
+    }
+
+    ThisPage.methods.editMenuItemDelete = function(item) {
+
+        if (confirm("Really delete this item?")) {
+
+            // remove item from editing menu
+            let i = this.editingMenu.items.indexOf(item)
+            this.editingMenu.items.splice(i, 1)
+        }
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
new file mode 100644
index 00000000..e6b128fc
--- /dev/null
+++ b/tailbone/templates/configure.mako
@@ -0,0 +1,431 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">Configure ${config_title}</%def>
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+    .label {
+        white-space: nowrap;
+    }
+  </style>
+</%def>
+
+<%def name="save_undo_buttons()">
+  <div class="buttons"
+       v-if="settingsNeedSaved">
+    <b-button type="is-primary"
+              @click="saveSettings"
+              :disabled="savingSettings"
+              icon-pack="fas"
+              icon-left="save">
+      {{ savingSettings ? "Working, please wait..." : "Save All Settings" }}
+    </b-button>
+    <once-button tag="a" href="${request.current_route_url()}"
+                 @click="undoChanges = true"
+                 icon-left="undo"
+                 text="Undo All Changes">
+    </once-button>
+  </div>
+</%def>
+
+<%def name="purge_button()">
+  <b-button type="is-danger"
+            @click="purgeSettingsInit()"
+            icon-pack="fas"
+            icon-left="trash">
+    Remove All Settings
+  </b-button>
+</%def>
+
+<%def name="intro_message()">
+  <p class="block">
+    This page lets you modify the
+    % if config_preferences is not Undefined and config_preferences:
+        preferences
+    % else:
+        configuration
+    % endif
+    for ${config_title}.
+  </p>
+</%def>
+
+<%def name="buttons_row()">
+  <div class="level">
+    <div class="level-left">
+
+      <div class="level-item">
+        ${self.intro_message()}
+      </div>
+
+      <div class="level-item">
+        ${self.save_undo_buttons()}
+      </div>
+    </div>
+
+    <div class="level-right">
+      <div class="level-item">
+        ${self.purge_button()}
+      </div>
+    </div>
+  </div>
+</%def>
+
+<%def name="input_file_template_field(key)">
+    <% tmpl = input_file_templates[key] %>
+    <b-field grouped>
+
+      <b-field label="${tmpl['label']}">
+        <b-select name="${tmpl['setting_mode']}"
+                  v-model="inputFileTemplateSettings['${tmpl['setting_mode']}']"
+                  @input="settingsNeedSaved = true">
+          <option value="default">use default</option>
+          <option value="hosted">use uploaded file</option>
+          <option value="external">use other URL</option>
+        </b-select>
+      </b-field>
+
+      <b-field label="File"
+               v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'"
+               :message="inputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${input_file_option_dirs[tmpl['key']]}' : null">
+        <b-select name="${tmpl['setting_file']}"
+                  v-model="inputFileTemplateSettings['${tmpl['setting_file']}']"
+                  @input="settingsNeedSaved = true">
+          <option value="">-new-</option>
+          <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']"
+                  :key="option"
+                  :value="option">
+            {{ option }}
+          </option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Upload"
+               v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']">
+
+        % if request.use_oruga:
+            <o-field class="file">
+              <o-upload name="${tmpl['setting_file']}.upload"
+                        v-model="inputFileTemplateUploads['${tmpl['key']}']"
+                        v-slot="{ onclick }"
+                        @input="settingsNeedSaved = true">
+                <o-button variant="primary"
+                          @click="onclick">
+                  <o-icon icon="upload" />
+                  <span>Click to upload</span>
+                </o-button>
+                <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']">
+                  {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
+                </span>
+              </o-upload>
+            </o-field>
+        % else:
+            <b-field class="file is-primary"
+                     :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}">
+              <b-upload name="${tmpl['setting_file']}.upload"
+                        v-model="inputFileTemplateUploads['${tmpl['key']}']"
+                        class="file-label"
+                        @input="settingsNeedSaved = true">
+                <span class="file-cta">
+                  <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+                  <span class="file-label">Click to upload</span>
+                </span>
+              </b-upload>
+              <span v-if="inputFileTemplateUploads['${tmpl['key']}']"
+                    class="file-name">
+                {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
+              </span>
+            </b-field>
+        % endif
+
+      </b-field>
+
+      <b-field label="URL" expanded
+               v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'external'">
+        <b-input name="${tmpl['setting_url']}"
+                 v-model="inputFileTemplateSettings['${tmpl['setting_url']}']"
+                 @input="settingsNeedSaved = true">
+        </b-input>
+      </b-field>
+
+    </b-field>
+</%def>
+
+<%def name="input_file_templates_section()">
+  <h3 class="block is-size-3">Input File Templates</h3>
+  <div class="block" style="padding-left: 2rem;">
+    % for key in input_file_templates:
+        ${self.input_file_template_field(key)}
+    % endfor
+  </div>
+</%def>
+
+<%def name="output_file_template_field(key)">
+    <% tmpl = output_file_templates[key] %>
+    <b-field grouped>
+
+      <b-field label="${tmpl['label']}">
+        <b-select name="${tmpl['setting_mode']}"
+                  v-model="outputFileTemplateSettings['${tmpl['setting_mode']}']"
+                  @input="settingsNeedSaved = true">
+          <option value="default">use default</option>
+          <option value="hosted">use uploaded file</option>
+        </b-select>
+      </b-field>
+
+      <b-field label="File"
+               v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'"
+               :message="outputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${output_file_option_dirs[tmpl['key']]}' : null">
+        <b-select name="${tmpl['setting_file']}"
+                  v-model="outputFileTemplateSettings['${tmpl['setting_file']}']"
+                  @input="settingsNeedSaved = true">
+          <option value="">-new-</option>
+          <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']"
+                  :key="option"
+                  :value="option">
+            {{ option }}
+          </option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Upload"
+               v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']">
+
+        % if request.use_oruga:
+            <o-field class="file">
+              <o-upload name="${tmpl['setting_file']}.upload"
+                        v-model="outputFileTemplateUploads['${tmpl['key']}']"
+                        v-slot="{ onclick }"
+                        @input="settingsNeedSaved = true">
+                <o-button variant="primary"
+                          @click="onclick">
+                  <o-icon icon="upload" />
+                  <span>Click to upload</span>
+                </o-button>
+                <span class="file-name" v-if="outputFileTemplateUploads['${tmpl['key']}']">
+                  {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
+                </span>
+              </o-upload>
+            </o-field>
+        % else:
+            <b-field class="file is-primary"
+                     :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}">
+              <b-upload name="${tmpl['setting_file']}.upload"
+                        v-model="outputFileTemplateUploads['${tmpl['key']}']"
+                        class="file-label"
+                        @input="settingsNeedSaved = true">
+                <span class="file-cta">
+                  <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+                  <span class="file-label">Click to upload</span>
+                </span>
+              </b-upload>
+              <span v-if="outputFileTemplateUploads['${tmpl['key']}']"
+                    class="file-name">
+                {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
+              </span>
+            </b-field>
+        % endif
+      </b-field>
+
+    </b-field>
+</%def>
+
+<%def name="output_file_templates_section()">
+  <h3 class="block is-size-3">Output File Templates</h3>
+  <div class="block" style="padding-left: 2rem;">
+    % for key in output_file_templates:
+        ${self.output_file_template_field(key)}
+    % endfor
+  </div>
+</%def>
+
+<%def name="form_content()"></%def>
+
+<%def name="page_content()">
+  ${parent.page_content()}
+
+  <br />
+
+  ${self.buttons_row()}
+
+  <b-modal has-modal-card
+           :active.sync="purgeSettingsShowDialog">
+    <div class="modal-card">
+
+      <header class="modal-card-head">
+        <p class="modal-card-title">Remove All Settings</p>
+      </header>
+
+      <section class="modal-card-body">
+        <p class="block">
+          If you like we can remove all settings for ${config_title}
+          from the DB.
+        </p>
+        <p class="block">
+          Note that the tool normally removes all settings first,
+          every time you click "Save Settings" - here though you can
+          "just remove and not save" the settings.
+        </p>
+        <p class="block">
+          Note also that this will of course 
+          <span class="is-italic">not</span> remove any settings from
+          your config files, so after removing from DB,
+          <span class="is-italic">only</span> your config file
+          settings should be in effect.
+        </p>
+      </section>
+
+      <footer class="modal-card-foot">
+        <b-button @click="purgeSettingsShowDialog = false">
+          Cancel
+        </b-button>
+        ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})}
+        ${h.csrf_token(request)}
+        ${h.hidden('remove_settings', 'true')}
+        <b-button type="is-danger"
+                  native-type="submit"
+                  :disabled="purgingSettings"
+                  icon-pack="fas"
+                  icon-left="trash">
+          {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
+        </b-button>
+        ${h.end_form()}
+      </footer>
+    </div>
+  </b-modal>
+
+  ${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm', **{'@submit': 'saveSettingsFormSubmit'})}
+  ${h.csrf_token(request)}
+  ${self.form_content()}
+  ${h.end_form()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if simple_settings is not Undefined:
+        ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n}
+    % endif
+
+    ThisPageData.purgeSettingsShowDialog = false
+    ThisPageData.purgingSettings = false
+
+    ThisPageData.settingsNeedSaved = false
+    ThisPageData.undoChanges = false
+    ThisPageData.savingSettings = false
+    ThisPageData.validators = []
+
+    ThisPage.methods.purgeSettingsInit = function() {
+        this.purgeSettingsShowDialog = true
+    }
+
+    ThisPage.methods.validateSettings = function() {}
+
+    ThisPage.methods.saveSettings = function() {
+        let msg
+
+        // nb. this is the future
+        for (let validator of this.validators) {
+            msg = validator.call(this)
+            if (msg) {
+                alert(msg)
+                return
+            }
+        }
+
+        // nb. legacy method
+        msg = this.validateSettings()
+        if (msg) {
+            alert(msg)
+            return
+        }
+
+        this.savingSettings = true
+        this.settingsNeedSaved = false
+        this.$refs.saveSettingsForm.submit()
+    }
+
+    // nb. this is here to avoid auto-submitting form when user
+    // presses ENTER while some random input field has focus
+    ThisPage.methods.saveSettingsFormSubmit = function(event) {
+        if (!this.savingSettings) {
+            event.preventDefault()
+        }
+    }
+
+    // cf. https://stackoverflow.com/a/56551646
+    ThisPage.methods.beforeWindowUnload = function(e) {
+        if (this.settingsNeedSaved && !this.undoChanges) {
+            e.preventDefault()
+            e.returnValue = ''
+        }
+    }
+
+    ThisPage.created = function() {
+        window.addEventListener('beforeunload', this.beforeWindowUnload)
+    }
+
+    ##############################
+    ## input file templates
+    ##############################
+
+    % if input_file_template_settings is not Undefined:
+
+        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
+        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
+        ThisPageData.inputFileTemplateUploads = {
+            % for key in input_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateInputFileTemplateSettings = function() {
+            % for tmpl in input_file_templates.values():
+                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings)
+
+    % endif
+
+    ##############################
+    ## output file templates
+    ##############################
+
+    % if output_file_template_settings is not Undefined:
+
+        ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n}
+        ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n}
+        ThisPageData.outputFileTemplateUploads = {
+            % for key in output_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateOutputFileTemplateSettings = function() {
+            % for tmpl in output_file_templates.values():
+                if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.outputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings)
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako
new file mode 100644
index 00000000..1a6dca8b
--- /dev/null
+++ b/tailbone/templates/customers/configure.mako
@@ -0,0 +1,113 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">General</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field grouped>
+
+      <b-field label="Key Field">
+        <b-select name="rattail.customers.key_field"
+                  v-model="simpleSettings['rattail.customers.key_field']"
+                  @input="updateKeyLabel()">
+          <option value="id">id</option>
+          <option value="number">number</option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Key Field Label">
+        <b-input name="rattail.customers.key_label"
+                 v-model="simpleSettings['rattail.customers.key_label']"
+                 @input="settingsNeedSaved = true">
+        </b-input>
+      </b-field>
+
+    </b-field>
+
+    <b-field message="If set, grid links are to Customer tab of Profile view.">
+      <b-checkbox name="rattail.customers.straight_to_profile"
+                  v-model="simpleSettings['rattail.customers.straight_to_profile']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Link directly to Profile when applicable
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="Set this to show the Shoppers field when viewing a Customer record.">
+      <b-checkbox name="rattail.customers.expose_shoppers"
+                  v-model="simpleSettings['rattail.customers.expose_shoppers']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show the Shoppers field
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="Set this to show the People field when viewing a Customer record.">
+      <b-checkbox name="rattail.customers.expose_people"
+                  v-model="simpleSettings['rattail.customers.expose_people']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show the People field
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="If not set, Customer chooser is an autocomplete field.">
+      <b-checkbox name="rattail.customers.choice_uses_dropdown"
+                  v-model="simpleSettings['rattail.customers.choice_uses_dropdown']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Use dropdown (select element) for Customer chooser
+      </b-checkbox>
+    </b-field>
+
+    <b-field label="Clientele Handler"
+             message="Leave blank for default handler.">
+      <b-input name="rattail.clientele.handler"
+               v-model="simpleSettings['rattail.clientele.handler']"
+               @input="settingsNeedSaved = true">
+      </b-input>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">POS</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="rattail.customers.active_in_pos"
+                  v-model="simpleSettings['rattail.customers.active_in_pos']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Expose/track the "Active in POS" flag for customers.
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPage.methods.getLabelForKey = function(key) {
+        switch (key) {
+        case 'id':
+            return "ID"
+        case 'number':
+            return "Number"
+        default:
+            return "Key"
+        }
+    }
+
+    ThisPage.methods.updateKeyLabel = function() {
+        this.simpleSettings['rattail.customers.key_label'] = this.getLabelForKey(
+            this.simpleSettings['rattail.customers.key_field'])
+        this.settingsNeedSaved = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako
new file mode 100644
index 00000000..1cea9d1f
--- /dev/null
+++ b/tailbone/templates/customers/pending/view.mako
@@ -0,0 +1,141 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+## <%namespace file="/util.mako" import="view_profiles_helper" />
+
+<%def name="object_helpers()">
+  ${parent.object_helpers()}
+
+  % if instance.custorder_records:
+      <nav class="panel">
+        <p class="panel-heading">Cross-Reference</p>
+        <div class="panel-block">
+          <div style="display: flex; flex-direction: column;">
+            <p class="block">
+              This ${model_title} is referenced by the following<br />
+              Customer Orders:
+            </p>
+            <ul class="list">
+              % for order in instance.custorder_records:
+                  <li class="list-item">
+                    ${h.link_to(order, url('custorders.view', uuid=order.uuid))}
+                  </li>
+              % endfor
+            </ul>
+          </div>
+        </div>
+      </nav>
+  % endif
+
+  ## % if instance.status_code == enum.PENDING_CUSTOMER_STATUS_PENDING and master.has_any_perm('resolve_person', 'resolve_customer'):
+  % if instance.status_code == enum.PENDING_CUSTOMER_STATUS_PENDING and master.has_perm('resolve_person'):
+      <nav class="panel">
+        <p class="panel-heading">Tools</p>
+        <div class="panel-block">
+          <div style="display: flex; flex-direction: column;">
+            % if master.has_perm('resolve_person'):
+                <div class="buttons">
+                  <b-button type="is-primary"
+                            @click="resolvePersonInit()"
+                            icon-pack="fas"
+                            icon-left="object-ungroup">
+                    Resolve Person
+                  </b-button>
+                </div>
+            % endif
+##             % if master.has_perm('resolve_customer'):
+##                 <div class="buttons">
+##                   <b-button type="is-primary"
+##                             icon-pack="fas"
+##                             icon-left="object-ungroup">
+##                     Resolve Customer
+##                   </b-button>
+##                 </div>
+##             % endif
+          </div>
+        </div>
+      </nav>
+
+      <b-modal has-modal-card
+               :active.sync="resolvePersonShowDialog">
+        <div class="modal-card">
+          ${h.form(url('{}.resolve_person'.format(route_prefix), uuid=instance.uuid), ref='resolvePersonForm')}
+          ${h.csrf_token(request)}
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Resolve Person</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              If this Person already exists, you can declare that by
+              identifying the record below.
+            </p>
+            <p class="block">
+              The app will take care of updating any Customer Orders
+              etc.  as needed once you declare the match.
+            </p>
+            <b-field grouped>
+              <b-field label="Pending">
+                <span>${instance.display_name}</span>
+              </b-field>
+              <b-field label="Actual Person" expanded>
+                <tailbone-autocomplete name="person_uuid"
+                                       v-model="resolvePersonUUID"
+                                       ref="resolvePersonAutocomplete"
+                                       service-url="${url('people.autocomplete')}">
+                </tailbone-autocomplete>
+              </b-field>
+            </b-field>
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="resolvePersonShowDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      :disabled="resolvePersonSubmitDisabled"
+                      @click="resolvePersonSubmit()"
+                      icon-pack="fas"
+                      icon-left="object-ungroup">
+              {{ resolvePersonSubmitting ? "Working, please wait..." : "I declare these are the same" }}
+            </b-button>
+          </footer>
+          ${h.end_form()}
+        </div>
+      </b-modal>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.resolvePersonShowDialog = false
+    ThisPageData.resolvePersonUUID = null
+    ThisPageData.resolvePersonSubmitting = false
+
+    ThisPage.computed.resolvePersonSubmitDisabled = function() {
+        if (this.resolvePersonSubmitting) {
+            return true
+        }
+        if (!this.resolvePersonUUID) {
+            return true
+        }
+        return false
+    }
+
+    ThisPage.methods.resolvePersonInit = function() {
+        this.resolvePersonUUID = null
+        this.resolvePersonShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.resolvePersonAutocomplete.focus()
+        })
+    }
+
+    ThisPage.methods.resolvePersonSubmit = function() {
+        this.resolvePersonSubmitting = true
+        this.$refs.resolvePersonForm.submit()
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako
index 6c9de1ce..490e4757 100644
--- a/tailbone/templates/customers/view.mako
+++ b/tailbone/templates/customers/view.mako
@@ -2,28 +2,37 @@
 <%inherit file="/master/view.mako" />
 <%namespace file="/util.mako" import="view_profiles_helper" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if master.people_detachable and request.has_perm('{}.detach_person'.format(permission_prefix)):
-      <script type="text/javascript">
-
-        $(function() {
-            $('.people .grid .actions a.detach').click(function() {
-                if (! confirm("Are you sure you wish to detach this Person from the Customer?")) {
-                    return false;
-                }
-            });
-        });
-
-      </script>
-  % endif
-</%def>
-
 <%def name="object_helpers()">
   ${parent.object_helpers()}
-  % if show_profiles_helper and instance.people:
-      ${view_profiles_helper(instance.people)}
+  % if show_profiles_helper and show_profiles_people:
+      ${view_profiles_helper(show_profiles_people)}
   % endif
 </%def>
 
-${parent.body()}
+<%def name="render_form()">
+  <div class="form">
+    <tailbone-form @detach-person="detachPerson">
+    </tailbone-form>
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if expose_shoppers:
+    ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n}
+    % endif
+    % if expose_people:
+    ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n}
+    % endif
+
+    ThisPage.methods.detachPerson = function(url) {
+        ## TODO: this should require POST! but for now we just redirect..
+        if (confirm("Are you sure you want to detach this person from this customer account?")) {
+            location.href = url
+        }
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako
new file mode 100644
index 00000000..16d26d21
--- /dev/null
+++ b/tailbone/templates/custorders/configure.mako
@@ -0,0 +1,181 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Customer Handling</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If not set, only a Person is required.">
+      <b-checkbox name="rattail.custorders.new_order_requires_customer"
+                  v-model="simpleSettings['rattail.custorders.new_order_requires_customer']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Require a Customer account
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="If not set, default contact info is always assumed.">
+      <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_choice"
+                  v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow user to choose contact info
+      </b-checkbox>
+    </b-field>
+
+    <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']"
+         style="padding-left: 2rem;">
+
+      <b-field message="Only applies if user is allowed to choose contact info.">
+        <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create"
+                    v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
+                    native-value="true"
+                    @input="settingsNeedSaved = true">
+          Allow user to enter new contact info
+        </b-checkbox>
+      </b-field>
+
+      <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
+           style="padding-left: 2rem;">
+
+        <p class="block">
+          If you allow users to enter new contact info, the default action
+          when the order is submitted, is to send email with details of
+          the new contact info.&nbsp; Settings for these are at:
+        </p>
+
+        <ul class="list">
+          <li class="list-item">
+            ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))}
+          </li>
+          <li class="list-item">
+            ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))}
+          </li>
+        </ul>
+
+      </div>
+    </div>
+  </div>
+
+  <h3 class="block is-size-3">Product Handling</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="rattail.custorders.allow_case_orders"
+                  v-model="simpleSettings['rattail.custorders.allow_case_orders']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow "case" orders
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.custorders.allow_unit_orders"
+                  v-model="simpleSettings['rattail.custorders.allow_unit_orders']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow "unit" orders
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.custorders.product_price_may_be_questionable"
+                  v-model="simpleSettings['rattail.custorders.product_price_may_be_questionable']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow prices to be flagged as "questionable"
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.custorders.allow_item_discounts"
+                  v-model="simpleSettings['rattail.custorders.allow_item_discounts']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow per-item discounts
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.custorders.allow_item_discounts_if_on_sale"
+                  v-model="simpleSettings['rattail.custorders.allow_item_discounts_if_on_sale']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true"
+                  :disabled="!simpleSettings['rattail.custorders.allow_item_discounts']">
+        Allow discount even if item is on sale
+      </b-checkbox>
+    </b-field>
+
+    <div class="level-left block">
+      <div class="level-item">Default item discount</div>
+      <div class="level-item">
+        <b-input name="rattail.custorders.default_item_discount"
+                 v-model="simpleSettings['rattail.custorders.default_item_discount']"
+                 @input="settingsNeedSaved = true"
+                 style="width: 5rem;"
+                 :disabled="!simpleSettings['rattail.custorders.allow_item_discounts']">
+        </b-input>
+      </div>
+      <div class="level-item">%</div>
+    </div>
+
+    <b-field>
+      <b-checkbox name="rattail.custorders.allow_past_item_reorder"
+                  v-model="simpleSettings['rattail.custorders.allow_past_item_reorder']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow re-order via past item lookup
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Unknown Products</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If set, user can enter details of an arbitrary new &quot;pending&quot; product.">
+      <b-checkbox name="rattail.custorders.allow_unknown_product"
+                  v-model="simpleSettings['rattail.custorders.allow_unknown_product']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow creating orders for "unknown" products
+      </b-checkbox>
+    </b-field>
+
+    <div v-if="simpleSettings['rattail.custorders.allow_unknown_product']">
+
+      <p class="block">
+        Require these fields for new product:
+      </p>
+
+      <div class="block"
+           style="margin-left: 2rem;">
+        % for field in pending_product_fields:
+            <b-field>
+              <b-checkbox name="rattail.custorders.unknown_product.fields.${field}.required"
+                          v-model="simpleSettings['rattail.custorders.unknown_product.fields.${field}.required']"
+                          native-value="true"
+                          @input="settingsNeedSaved = true">
+                ${field}
+              </b-checkbox>
+            </b-field>
+        % endfor
+      </div>
+
+      <b-field message="If set, user is always prompted to confirm price when adding new product.">
+        <b-checkbox name="rattail.custorders.unknown_product.always_confirm_price"
+                    v-model="simpleSettings['rattail.custorders.unknown_product.always_confirm_price']"
+                    native-value="true"
+                    @input="settingsNeedSaved = true">
+          Require price confirmation
+        </b-checkbox>
+      </b-field>
+
+    </div>
+
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 24245b7a..382a121f 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -1,85 +1,128 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/create.mako" />
+<%namespace name="product_lookup" file="/products/lookup.mako" />
 
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if use_buefy:
-      <style type="text/css">
-        .this-page-content {
-            flex-grow: 1;
-        }
-      </style>
-  % endif
+  <style type="text/css">
+    .this-page-content {
+        flex-grow: 1;
+    }
+  </style>
 </%def>
 
 <%def name="page_content()">
   <br />
-  % if use_buefy:
-      <customer-order-creator></customer-order-creator>
-  % else:
-      <p>Sorry, but this page is not supported by your current theme configuration.</p>
-  % endif
+  <customer-order-creator></customer-order-creator>
 </%def>
 
 <%def name="order_form_buttons()">
-  <div class="buttons">
-    <b-button type="is-primary"
-              @click="submitOrder()"
-              icon-pack="fas"
-              icon-left="fas fa-upload">
-      Submit this Order
-    </b-button>
-    <b-button @click="startOverEntirely()"
-              icon-pack="fas"
-              icon-left="fas fa-redo">
-      Start Over Entirely
-    </b-button>
-    <b-button @click="cancelOrder()"
-              type="is-danger"
-              icon-pack="fas"
-              icon-left="fas fa-trash">
-      Cancel this Order
-    </b-button>
+  <div class="level">
+    <div class="level-left">
+    </div>
+    <div class="level-right">
+      <div class="level-item">
+        <div class="buttons">
+          <b-button type="is-primary"
+                    @click="submitOrder()"
+                    :disabled="submittingOrder"
+                    icon-pack="fas"
+                    icon-left="upload">
+            {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }}
+          </b-button>
+          <b-button @click="startOverEntirely()"
+                    icon-pack="fas"
+                    icon-left="redo">
+            Start Over Entirely
+          </b-button>
+          <b-button @click="cancelOrder()"
+                    type="is-danger"
+                    icon-pack="fas"
+                    icon-left="trash">
+            Cancel this Order
+          </b-button>
+        </div>
+      </div>
+    </div>
   </div>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${product_lookup.tailbone_product_lookup_template()}
   <script type="text/x-template" id="customer-order-creator-template">
     <div>
 
       ${self.order_form_buttons()}
 
-      <b-collapse class="panel" :class="customerPanelType"
-                  :open.sync="customerPanelOpen">
+      <${b}-collapse class="panel"
+                     :class="customerPanelType"
+                     % if request.use_oruga:
+                         v-model:open="customerPanelOpen"
+                     % else:
+                         :open.sync="customerPanelOpen"
+                     % endif
+                     >
 
-        <div slot="trigger"
-             slot-scope="props"
-             class="panel-heading"
-             role="button">
-          <b-icon pack="fas"
-            ## TODO: this icon toggling should work, according to
-            ## Buefy docs, but i could not ever get it to work.
-            ## what am i missing?
-            ## https://buefy.org/documentation/collapse/
-            ## :icon="props.open ? 'caret-down' : 'caret-right'">
-            ## (for now we just always show caret-right instead)
-            icon="caret-right">
-          </b-icon>
-          <strong v-html="customerPanelHeader"></strong>
-        </div>
+        <template #trigger="props">
+          <div class="panel-heading"
+               role="button"
+               style="cursor: pointer;">
+
+            ## TODO: for some reason buefy will "reuse" the icon
+            ## element in such a way that its display does not
+            ## refresh.  so to work around that, we use different
+            ## structure for the two icons, so buefy is forced to
+            ## re-draw
+
+            <b-icon v-if="props.open"
+                    pack="fas"
+                    icon="caret-down">
+            </b-icon>
+
+            <span v-if="!props.open">
+              <b-icon pack="fas"
+                      icon="caret-right">
+              </b-icon>
+            </span>
+
+            &nbsp;
+            <strong v-html="customerPanelHeader"></strong>
+          </div>
+        </template>
 
         <div class="panel-block">
           <div style="width: 100%;">
 
             <div style="display: flex; flex-direction: row;">
               <div style="flex-grow: 1; margin-right: 1rem;">
-                <b-notification :type="customerStatusType"
-                                position="is-bottom-right"
-                                :closable="false">
-                  {{ customerStatusText }}
-                </b-notification>
+                % if request.use_oruga:
+                    ## TODO: for some reason o-notification variant is not
+                    ## being updated properly, so for now the workaround is
+                    ## to maintain a separate component for each variant
+                    ## i tried to reproduce the problem in a simple page
+                    ## but was unable; this is really a hack but it works..
+                    <o-notification v-if="customerStatusType == null"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </o-notification>
+                    <o-notification v-if="customerStatusType == 'is-warning'"
+                                    variant="warning"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </o-notification>
+                    <o-notification v-if="customerStatusType == 'is-danger'"
+                                    variant="danger"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </o-notification>
+                % else:
+                    <b-notification :type="customerStatusType"
+                                    position="is-bottom-right"
+                                    :closable="false">
+                      {{ customerStatusText }}
+                    </b-notification>
+                % endif
               </div>
               <!-- <div class="buttons"> -->
               <!--   <b-button @click="startOverCustomer()" -->
@@ -92,86 +135,1125 @@
 
             <br />
             <div class="field">
-              <b-radio v-model="customerIsKnown"
+              <b-radio v-model="contactIsKnown"
                        :native-value="true">
                 Customer is already in the system.
               </b-radio>
             </div>
 
-            <div v-show="customerIsKnown">
-              <b-field label="Customer Name" horizontal>
-                <tailbone-autocomplete
-                   ref="customerAutocomplete"
-                   v-model="customerUUID"
-                   :initial-label="customerDisplay"
-                   serviceUrl="${url('customers.autocomplete')}"
-                   @input="customerChanged">
-                </tailbone-autocomplete>
-              </b-field>
-              <b-field label="Phone Number" horizontal>
-                <b-input v-model="phoneNumberEntry"
-                         @input="phoneNumberChanged"
-                         @keydown.native="phoneNumberKeyDown">
-                </b-input>
-                <b-button v-if="!phoneNumberSaved"
-                          type="is-primary"
-                          icon-pack="fas"
-                          icon-left="fas fa-save"
-                          @click="setCustomerData()">
-                  Please save when finished editing
-                </b-button>
-                <!-- <tailbone-autocomplete -->
-                <!--    serviceUrl="${url('customers.autocomplete.phone')}"> -->
-                <!-- </tailbone-autocomplete> -->
-              </b-field>
+            <div v-show="contactIsKnown"
+                 style="padding-left: 10rem; display: flex;">
+
+              <div :style="{'flex-grow': contactNotes.length ? 0 : 1}">
+
+                <b-field label="Customer">
+                  <div style="display: flex; gap: 1rem; width: 100%;">
+                    <tailbone-autocomplete ref="contactAutocomplete"
+                                           v-model="contactUUID"
+                                           :style="{'flex-grow': contactUUID ? '0' : '1'}"
+                                           expanded
+                                           placeholder="Enter name or phone number"
+                                           % if new_order_requires_customer:
+                                           serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}"
+                                           % else:
+                                           serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}"
+                                           % endif
+                                           % if request.use_oruga:
+                                               :assigned-label="contactDisplay"
+                                               @update:model-value="contactChanged"
+                                           % else:
+                                               :initial-label="contactDisplay"
+                                               @input="contactChanged"
+                                           % endif
+                                           >
+                    </tailbone-autocomplete>
+                    <b-button v-if="contactUUID && contactProfileURL"
+                              type="is-primary"
+                              tag="a" target="_blank"
+                              :href="contactProfileURL"
+                              icon-pack="fas"
+                              icon-left="external-link-alt">
+                      View Profile
+                    </b-button>
+                    <b-button v-if="contactUUID"
+                              @click="refreshContact"
+                              icon-pack="fas"
+                              icon-left="redo"
+                              :disabled="refreshingContact">
+                      {{ refreshingContact ? "Refreshig" : "Refresh" }}
+                    </b-button>
+                  </div>
+                </b-field>
+
+                <b-field grouped v-show="contactUUID"
+                         style="margin-top: 2rem;">
+
+                  <b-field label="Phone Number"
+                           style="margin-right: 3rem;">
+                    <div class="level">
+                      <div class="level-left">
+                        <div class="level-item">
+                          <div v-if="orderPhoneNumber">
+                            <p :class="addOtherPhoneNumber ? 'has-text-success': null">
+                              {{ orderPhoneNumber }}
+                            </p>
+                            <p v-if="addOtherPhoneNumber"
+                               class="is-size-7 is-italic has-text-success">
+                              will be added to customer record
+                            </p>
+                          </div>
+                          <p v-if="!orderPhoneNumber"
+                                class="has-text-danger">
+                            (no valid phone number on file)
+                          </p>
+                        </div>
+                        % if allow_contact_info_choice:
+                            <div class="level-item"
+                                 % if not allow_contact_info_create:
+                                 v-if="contactPhones.length &gt; 1"
+                                 % endif
+                                 >
+                              <b-button type="is-primary"
+                                        @click="editPhoneNumberInit()"
+                                        icon-pack="fas"
+                                        icon-left="edit">
+                                Edit
+                              </b-button>
+
+                              <${b}-modal has-modal-card
+                                          % if request.use_oruga:
+                                              v-model:active="editPhoneNumberShowDialog"
+                                          % else:
+                                              :active.sync="editPhoneNumberShowDialog"
+                                          % endif
+                                          >
+                                <div class="modal-card">
+
+                                  <header class="modal-card-head">
+                                    <p class="modal-card-title">Edit Phone Number</p>
+                                  </header>
+
+                                  <section class="modal-card-body">
+
+                                    <b-field v-for="phone in contactPhones"
+                                             :key="phone.uuid">
+                                      <b-radio v-model="existingPhoneUUID"
+                                               :native-value="phone.uuid">
+                                        {{ phone.type }} {{ phone.number }}
+                                        <span v-if="phone.preferred"
+                                              class="is-italic">
+                                          (preferred)
+                                        </span>
+                                      </b-radio>
+                                    </b-field>
+
+                                    % if allow_contact_info_create:
+                                        <b-field>
+                                          <b-radio v-model="existingPhoneUUID"
+                                                   :native-value="null">
+                                            other
+                                          </b-radio>
+                                        </b-field>
+
+                                        <b-field v-if="!existingPhoneUUID"
+                                                 grouped>
+                                          <b-input v-model="editPhoneNumberOther">
+                                          </b-input>
+                                          <b-checkbox v-model="editPhoneNumberAddOther">
+                                            add this phone number to customer record
+                                          </b-checkbox>
+                                        </b-field>
+                                    % endif
+
+                                  </section>
+
+                                  <footer class="modal-card-foot">
+                                    <b-button type="is-primary"
+                                              icon-pack="fas"
+                                              icon-left="save"
+                                              :disabled="editPhoneNumberSaveDisabled"
+                                              @click="editPhoneNumberSave()">
+                                      {{ editPhoneNumberSaveText }}
+                                    </b-button>
+                                    <b-button @click="editPhoneNumberShowDialog = false">
+                                      Cancel
+                                    </b-button>
+                                  </footer>
+                                </div>
+                              </${b}-modal>
+
+                            </div>
+                        % endif
+                      </div>
+                    </div>
+                  </b-field>
+
+                  <b-field label="Email Address">
+                    <div class="level">
+                      <div class="level-left">
+                        <div class="level-item">
+                          <div v-if="orderEmailAddress">
+                            <p :class="addOtherEmailAddress ? 'has-text-success' : null">
+                              {{ orderEmailAddress }}
+                            </p>
+                            <p v-if="addOtherEmailAddress"
+                               class="is-size-7 is-italic has-text-success">
+                              will be added to customer record
+                            </p>
+                          </div>
+                          <span v-if="!orderEmailAddress"
+                                class="has-text-danger">
+                            (no valid email address on file)
+                          </span>
+                        </div>
+                        % if allow_contact_info_choice:
+                            <div class="level-item"
+                                 % if not allow_contact_info_create:
+                                 v-if="contactEmails.length &gt; 1"
+                                 % endif
+                                 >
+                              <b-button type="is-primary"
+                                        @click="editEmailAddressInit()"
+                                        icon-pack="fas"
+                                        icon-left="edit">
+                                Edit
+                              </b-button>
+                              <${b}-modal has-modal-card
+                                          % if request.use_oruga:
+                                              v-model:active.sync="editEmailAddressShowDialog"
+                                          % else:
+                                              :active.sync="editEmailAddressShowDialog"
+                                          % endif
+                                          >
+                                <div class="modal-card">
+
+                                  <header class="modal-card-head">
+                                    <p class="modal-card-title">Edit Email Address</p>
+                                  </header>
+
+                                  <section class="modal-card-body">
+
+                                    <b-field v-for="email in contactEmails"
+                                             :key="email.uuid">
+                                      <b-radio v-model="existingEmailUUID"
+                                               :native-value="email.uuid">
+                                        {{ email.type }} {{ email.address }}
+                                        <span v-if="email.preferred"
+                                              class="is-italic">
+                                          (preferred)
+                                        </span>
+                                      </b-radio>
+                                    </b-field>
+
+                                    % if allow_contact_info_create:
+                                        <b-field>
+                                          <b-radio v-model="existingEmailUUID"
+                                                   :native-value="null">
+                                            other
+                                          </b-radio>
+                                        </b-field>
+
+                                        <b-field v-if="!existingEmailUUID"
+                                                 grouped>
+                                          <b-input v-model="editEmailAddressOther">
+                                          </b-input>
+                                          <b-checkbox v-model="editEmailAddressAddOther">
+                                            add this email address to customer record
+                                          </b-checkbox>
+                                        </b-field>
+                                    % endif
+
+                                  </section>
+
+                                  <footer class="modal-card-foot">
+                                    <b-button type="is-primary"
+                                              icon-pack="fas"
+                                              icon-left="save"
+                                              :disabled="editEmailAddressSaveDisabled"
+                                              @click="editEmailAddressSave()">
+                                      {{ editEmailAddressSaveText }}
+                                    </b-button>
+                                    <b-button @click="editEmailAddressShowDialog = false">
+                                      Cancel
+                                    </b-button>
+                                  </footer>
+                                </div>
+                              </${b}-modal>
+                            </div>
+                        % endif
+                      </div>
+                    </div>
+                  </b-field>
+
+                </b-field>
+              </div>
+
+              <div v-show="contactNotes.length"
+                   style="margin-left: 1rem;">
+                <b-notification v-for="note in contactNotes"
+                                :key="note"
+                                type="is-warning"
+                                :closable="false">
+                  {{ note }}
+                </b-notification>
+              </div>
             </div>
 
             <br />
             <div class="field">
-              <b-radio v-model="customerIsKnown" disabled
+              <b-radio v-model="contactIsKnown"
                        :native-value="false">
                 Customer is not yet in the system.
               </b-radio>
             </div>
 
-            <div v-if="!customerIsKnown">
-              <b-field label="Customer Name" horizontal>
-                <b-input v-model="customerName"></b-input>
-              </b-field>
-              <b-field label="Phone Number" horizontal>
-                <b-input v-model="phoneNumber"></b-input>
-              </b-field>
+            <div v-if="!contactIsKnown"
+                 style="padding-left: 10rem; display: flex;">
+              <div>
+                <b-field grouped>
+                  <b-field label="First Name">
+                    <span class="has-text-success">
+                      {{ newCustomerFirstName }}
+                    </span>
+                  </b-field>
+                  <b-field label="Last Name">
+                    <span class="has-text-success">
+                      {{ newCustomerLastName }}
+                    </span>
+                  </b-field>
+                </b-field>
+                <b-field grouped>
+                  <b-field label="Phone Number">
+                    <span class="has-text-success">
+                      {{ newCustomerPhone }}
+                    </span>
+                  </b-field>
+                  <b-field label="Email Address">
+                    <span class="has-text-success">
+                      {{ newCustomerEmail }}
+                    </span>
+                  </b-field>
+                </b-field>
+              </div>
+
+              <div>
+                <b-button type="is-primary"
+                          @click="editNewCustomerInit()"
+                          icon-pack="fas"
+                          icon-left="edit">
+                  Edit New Customer
+                </b-button>
+              </div>
+
+              <div style="margin-left: 1rem;">
+                <b-notification type="is-warning"
+                                :closable="false">
+                  <p>Duplicate records can be difficult to clean up!</p>
+                  <p>Please be sure the customer is not already in the system.</p>
+                </b-notification>
+              </div>
+
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editNewCustomerShowDialog"
+                          % else:
+                              :active.sync="editNewCustomerShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Edit New Customer</p>
+                  </header>
+
+                  <section class="modal-card-body">
+                    <b-field grouped>
+                      <b-field label="First Name">
+                        <b-input v-model.trim="editNewCustomerFirstName"
+                                 ref="editNewCustomerInput">
+                        </b-input>
+                      </b-field>
+                      <b-field label="Last Name">
+                        <b-input v-model.trim="editNewCustomerLastName">
+                        </b-input>
+                      </b-field>
+                    </b-field>
+                    <b-field grouped>
+                      <b-field label="Phone Number">
+                        <b-input v-model.trim="editNewCustomerPhone"></b-input>
+                      </b-field>
+                      <b-field label="Email Address">
+                        <b-input v-model.trim="editNewCustomerEmail"></b-input>
+                      </b-field>
+                    </b-field>
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button type="is-primary"
+                              icon-pack="fas"
+                              icon-left="save"
+                              :disabled="editNewCustomerSaveDisabled"
+                              @click="editNewCustomerSave()">
+                      {{ editNewCustomerSaveText }}
+                    </b-button>
+                    <b-button @click="editNewCustomerShowDialog = false">
+                      Cancel
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+
             </div>
 
           </div>
         </div> <!-- panel-block -->
-      </b-collapse>
+      </${b}-collapse>
 
-      <b-collapse class="panel"
+      <${b}-collapse class="panel"
                   open>
 
-        <div slot="trigger"
-             slot-scope="props"
-             class="panel-heading"
-             role="button">
-          <b-icon pack="fas"
-            ## TODO: this icon toggling should work, according to
-            ## Buefy docs, but i could not ever get it to work.
-            ## what am i missing?
-            ## https://buefy.org/documentation/collapse/
-            ## :icon="props.open ? 'caret-down' : 'caret-right'">
-            ## (for now we just always show caret-right instead)
-            icon="caret-right">
-          </b-icon>
-          <strong>Items</strong>
-        </div>
+        <template #trigger="props">
+          <div class="panel-heading"
+               role="button"
+               style="cursor: pointer;">
+
+            ## TODO: for some reason buefy will "reuse" the icon
+            ## element in such a way that its display does not
+            ## refresh.  so to work around that, we use different
+            ## structure for the two icons, so buefy is forced to
+            ## re-draw
+
+            <b-icon v-if="props.open"
+                    pack="fas"
+                    icon="caret-down">
+            </b-icon>
+
+            <span v-if="!props.open">
+              <b-icon pack="fas"
+                      icon="caret-right">
+              </b-icon>
+            </span>
+
+            &nbsp;
+            <strong v-html="itemsPanelHeader"></strong>
+          </div>
+        </template>
 
         <div class="panel-block">
           <div>
-            TODO: items go here
+            <div class="buttons">
+              <b-button type="is-primary"
+                        icon-pack="fas"
+                        icon-left="plus"
+                        @click="showAddItemDialog()">
+                Add Item
+              </b-button>
+              % if allow_past_item_reorder:
+              <b-button v-if="contactUUID"
+                        icon-pack="fas"
+                        icon-left="plus"
+                        @click="showAddPastItem()">
+                Add Past Item
+              </b-button>
+              % endif
+            </div>
+
+            <${b}-modal
+              % if request.use_oruga:
+                  v-model:active="showingItemDialog"
+              % else:
+                  :active.sync="showingItemDialog"
+              % endif
+              >
+              <div class="card">
+                <div class="card-content">
+
+                  <${b}-tabs :animated="false"
+                             % if request.use_oruga:
+                                 v-model="itemDialogTab"
+                                 type="toggle"
+                             % else:
+                                 v-model="itemDialogTabIndex"
+                                 type="is-boxed is-toggle"
+                             % endif
+                             >
+
+                    <${b}-tab-item label="Product"
+                                   value="product">
+
+                      <div class="field">
+                        <b-radio v-model="productIsKnown"
+                                 :native-value="true">
+                          Product is already in the system.
+                        </b-radio>
+                      </div>
+
+                      <div v-show="productIsKnown"
+                           style="padding-left: 3rem; display: flex; gap: 1rem;">
+
+                        <div style="flex-grow: 1;">
+                          <b-field label="Product">
+                            <tailbone-product-lookup ref="productLookup"
+                                                     :product="selectedProduct"
+                                                     @selected="productLookupSelected"
+                                                     autocomplete-url="${url(f'{route_prefix}.product_autocomplete')}">
+                            </tailbone-product-lookup>
+                          </b-field>
+
+                          <div v-if="productUUID">
+
+                            <b-field grouped>
+                              <b-field :label="productKeyLabel">
+                                <span>{{ productKey }}</span>
+                              </b-field>
+
+                              <b-field label="Unit Size">
+                                <span>{{ productSize || '' }}</span>
+                              </b-field>
+
+                              <b-field label="Case Size">
+                                <span>{{ productCaseQuantity }}</span>
+                              </b-field>
+
+                              <b-field label="Reg. Price"
+                                       v-if="productSalePriceDisplay">
+                                <span>{{ productUnitRegularPriceDisplay }}</span>
+                              </b-field>
+
+                              <b-field label="Unit Price"
+                                       v-if="!productSalePriceDisplay">
+                                <span
+                                  % if product_price_may_be_questionable:
+                                  :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"
+                                  % endif
+                                  >
+                                  {{ productUnitPriceDisplay }}
+                                </span>
+                              </b-field>
+                              <!-- <b-field label="Last Changed"> -->
+                              <!--   <span>2021-01-01</span> -->
+                              <!-- </b-field> -->
+
+                              <b-field label="Sale Price"
+                                       v-if="productSalePriceDisplay">
+                                <span class="has-background-warning">
+                                  {{ productSalePriceDisplay }}
+                                </span>
+                              </b-field>
+
+                              <b-field label="Sale Ends"
+                                       v-if="productSaleEndsDisplay">
+                                <span class="has-background-warning">
+                                  {{ productSaleEndsDisplay }}
+                                </span>
+                              </b-field>
+
+                            </b-field>
+
+                            % if product_price_may_be_questionable:
+                                <b-checkbox v-model="productPriceNeedsConfirmation"
+                                            type="is-warning"
+                                            size="is-small">
+                                  This price is questionable and should be confirmed
+                                  by someone before order proceeds.
+                                </b-checkbox>
+                            % endif
+                          </div>
+                        </div>
+
+                        <img v-if="productUUID"
+                             :src="productImageURL"
+                             style="max-height: 150px; max-width: 150px; "/>
+
+                      </div>
+
+                      <br />
+                      <div class="field">
+                        <b-radio v-model="productIsKnown"
+                                 % if not allow_unknown_product:
+                                     disabled
+                                 % endif
+                                 :native-value="false">
+                          Product is not yet in the system.
+                        </b-radio>
+                      </div>
+
+                      <div v-show="!productIsKnown"
+                           style="padding-left: 5rem;">
+
+                        <b-field grouped>
+
+                          <b-field label="Brand"
+                                   % if 'brand_name' in pending_product_required_fields:
+                                   :type="pendingProduct.brand_name ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.brand_name">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Description"
+                                   % if 'description' in pending_product_required_fields:
+                                   :type="pendingProduct.description ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.description">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Unit Size"
+                                   % if 'size' in pending_product_required_fields:
+                                   :type="pendingProduct.size ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.size">
+                            </b-input>
+                          </b-field>
+
+                        </b-field>
+
+                        <b-field grouped>
+
+                          <b-field :label="productKeyLabel"
+                                   % if 'key' in pending_product_required_fields:
+                                   :type="pendingProduct[productKeyField] ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct[productKeyField]">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Department"
+                                   % if 'department_uuid' in pending_product_required_fields:
+                                   :type="pendingProduct.department_uuid ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-select v-model="pendingProduct.department_uuid">
+                              <option :value="null">(not known)</option>
+                              <option v-for="option in departmentOptions"
+                                      :key="option.value"
+                                      :value="option.value">
+                                {{ option.label }}
+                              </option>
+                            </b-select>
+                          </b-field>
+
+                        </b-field>
+
+                        <b-field grouped>
+
+                          <b-field label="Vendor"
+                                   % if 'vendor_name' in pending_product_required_fields:
+                                   :type="pendingProduct.vendor_name ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.vendor_name">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Vendor Item Code"
+                                   % if 'vendor_item_code' in pending_product_required_fields:
+                                   :type="pendingProduct.vendor_item_code ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.vendor_item_code">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Case Size"
+                                   % if 'case_size' in pending_product_required_fields:
+                                   :type="pendingProduct.case_size ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.case_size"
+                                     type="number" step="0.01"
+                                     style="width: 7rem;">
+                            </b-input>
+                          </b-field>
+
+                        </b-field>
+
+                        <b-field grouped>
+
+                          <b-field label="Unit Cost"
+                                   % if 'unit_cost' in pending_product_required_fields:
+                                   :type="pendingProduct.unit_cost ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.unit_cost"
+                                     type="number" step="0.01"
+                                     style="width: 10rem;">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Unit Reg. Price"
+                                   % if 'regular_price_amount' in pending_product_required_fields:
+                                   :type="pendingProduct.regular_price_amount ? null : 'is-danger'"
+                                   % endif
+                                   >
+                            <b-input v-model="pendingProduct.regular_price_amount"
+                                     type="number" step="0.01">
+                            </b-input>
+                          </b-field>
+
+                          <b-field label="Gross Margin">
+                            <span class="control">
+                              {{ pendingProductGrossMargin }}
+                            </span>
+                          </b-field>
+
+                        </b-field>
+
+                        <b-field label="Notes">
+                          <b-input v-model="pendingProduct.notes"
+                                   type="textarea"
+                                   expanded />
+                        </b-field>
+
+                      </div>
+                    </${b}-tab-item>
+                    <${b}-tab-item label="Quantity"
+                                   value="quantity">
+
+                      <div style="display: flex; gap: 1rem; white-space: nowrap;">
+
+                        <div style="flex-grow: 1;">
+                          <b-field grouped>
+                            <b-field label="Product" horizontal>
+                              <span :class="productIsKnown ? null : 'has-text-success'"
+                                    ## nb. hack to force refresh for vue3
+                                    :key="refreshProductDescription">
+                                {{ productIsKnown ? productDisplay : (pendingProduct.brand_name || '') + ' ' + (pendingProduct.description || '') + ' ' + (pendingProduct.size || '') }}
+                              </span>
+                            </b-field>
+                          </b-field>
+
+                          <b-field grouped>
+
+                            <b-field label="Unit Size">
+                              <span :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productIsKnown ? productSize : pendingProduct.size }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Reg. Price"
+                                     v-if="productSalePriceDisplay">
+                              <span>
+                                {{ productUnitRegularPriceDisplay }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Unit Price"
+                                     v-if="!productSalePriceDisplay">
+                              <span :class="productIsKnown ? null : 'has-text-success'"
+                                    % if product_price_may_be_questionable:
+                                        :class="productPriceNeedsConfirmation ? 'has-background-warning' : ''"
+                                    % endif
+                                    >
+                                {{ productIsKnown ? productUnitPriceDisplay : (pendingProduct.regular_price_amount ? '$' + pendingProduct.regular_price_amount : '') }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Sale Price"
+                                     v-if="productSalePriceDisplay">
+                              <span class="has-background-warning"
+                                    :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productSalePriceDisplay }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Sale Ends"
+                                     v-if="productSaleEndsDisplay">
+                              <span class="has-background-warning"
+                                    :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productSaleEndsDisplay }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Case Size">
+                              <span :class="productIsKnown ? null : 'has-text-success'">
+                                {{ productIsKnown ? productCaseQuantity : pendingProduct.case_size }}
+                              </span>
+                            </b-field>
+
+                            <b-field label="Case Price">
+                              <span
+                                    % if product_price_may_be_questionable:
+                                        :class="{'has-text-success': !productIsKnown, 'has-background-warning': productPriceNeedsConfirmation || productSalePriceDisplay}"
+                                    % else:
+                                        :class="{'has-text-success': !productIsKnown, 'has-background-warning': !!productSalePriceDisplay}"
+                                    % endif
+                                    >
+                                {{ getCasePriceDisplay() }}
+                              </span>
+                            </b-field>
+
+                          </b-field>
+
+                          <b-field grouped>
+
+                            <b-field label="Quantity" horizontal>
+                              <numeric-input v-model="productQuantity"
+                                             @input="refreshTotalPrice += 1"
+                                             style="width: 5rem;">
+                              </numeric-input>
+                            </b-field>
+
+                            <b-select v-model="productUOM"
+                                      @input="refreshTotalPrice += 1">
+                              <option v-for="choice in productUnitChoices"
+                                      :key="choice.key"
+                                      :value="choice.key"
+                                      v-html="choice.value">
+                              </option>
+                            </b-select>
+
+                          </b-field>
+
+                          <div style="display: flex; gap: 1rem;">
+                            % if allow_item_discounts:
+                                <b-field label="Discount" horizontal>
+                                  <div class="level">
+                                    <div class="level-item">
+                                      <numeric-input v-model="productDiscountPercent"
+                                                     @input="refreshTotalPrice += 1"
+                                                     style="width: 5rem;"
+                                                     :disabled="!allowItemDiscount">
+                                      </numeric-input>
+                                    </div>
+                                    <div class="level-item">
+                                      <span>&nbsp;%</span>
+                                    </div>
+                                  </div>
+                                </b-field>
+                            % endif
+                            <b-field label="Total Price" horizontal expanded
+                                     :key="refreshTotalPrice">
+                              <span :class="productSalePriceDisplay ? 'has-background-warning': null">
+                                {{ getItemTotalPriceDisplay() }}
+                              </span>
+                            </b-field>
+                          </div>
+
+                          <!-- <b-field grouped> -->
+                          <!-- </b-field> -->
+                        </div>
+
+                        <!-- <div class="is-pulled-right has-text-centered"> -->
+                          <img :src="productImageURL"
+                               style="max-height: 150px; max-width: 150px; "/>
+                        <!-- </div> -->
+
+                      </div>
+
+                    </${b}-tab-item>
+                  </${b}-tabs>
+
+                  <div class="buttons">
+                    <b-button @click="showingItemDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="itemDialogSave()"
+                              :disabled="itemDialogSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }}
+                    </b-button>
+                  </div>
+
+                </div>
+              </div>
+            </${b}-modal>
+
+            % if unknown_product_confirm_price:
+                <${b}-modal has-modal-card
+                            % if request.use_oruga:
+                                v-model:active="confirmPriceShowDialog"
+                            % else:
+                                :active.sync="confirmPriceShowDialog"
+                            % endif
+                            >
+                  <div class="modal-card">
+
+                    <header class="modal-card-head">
+                      <p class="modal-card-title">Confirm Price</p>
+                    </header>
+
+                    <section class="modal-card-body">
+                      <p class="block">
+                        Please confirm the price info before proceeding.
+                      </p>
+
+                      <div style="white-space: nowrap;">
+
+                        <b-field label="Unit Cost" horizontal>
+                          <span>{{ pendingProduct.unit_cost }}</span>
+                        </b-field>
+
+                        <b-field label="Unit Reg. Price" horizontal>
+                          <span>{{ pendingProduct.regular_price_amount }}</span>
+                        </b-field>
+
+                        <b-field label="Gross Margin" horizontal>
+                          <span>{{ pendingProductGrossMargin }}</span>
+                        </b-field>
+
+                      </div>
+                    </section>
+
+                    <footer class="modal-card-foot">
+                      <b-button type="is-primary"
+                                icon-pack="fas"
+                                icon-left="check"
+                                @click="confirmPriceSave()">
+                        Confirm
+                      </b-button>
+                      <b-button @click="confirmPriceCancel()">
+                        Cancel
+                      </b-button>
+                    </footer>
+                  </div>
+                </${b}-modal>
+            % endif
+
+            % if allow_past_item_reorder:
+            <${b}-modal
+              % if request.use_oruga:
+                  v-model:active="pastItemsShowDialog"
+              % else:
+                  :active.sync="pastItemsShowDialog"
+              % endif
+              >
+              <div class="card">
+                <div class="card-content">
+
+                  <${b}-table :data="pastItems"
+                              icon-pack="fas"
+                              :loading="pastItemsLoading"
+                              % if request.use_oruga:
+                                  v-model:selected="pastItemsSelected"
+                              % else:
+                                  :selected.sync="pastItemsSelected"
+                              % endif
+                              sortable
+                              paginated
+                              per-page="5"
+                              :debounce-search="1000">
+
+                    <${b}-table-column :label="productKeyLabel"
+                                    field="key"
+                                    v-slot="props"
+                                    sortable>
+                      {{ props.row.key }}
+                    </${b}-table-column>
+
+                    <${b}-table-column label="Brand"
+                                    field="brand_name"
+                                    v-slot="props"
+                                    sortable
+                                    searchable>
+                      {{ props.row.brand_name }}
+                    </${b}-table-column>
+
+                    <${b}-table-column label="Description"
+                                    field="description"
+                                    v-slot="props"
+                                    sortable
+                                    searchable>
+                      {{ props.row.description }}
+                      {{ props.row.size }}
+                    </${b}-table-column>
+
+                    <${b}-table-column label="Unit Price"
+                                    field="unit_price"
+                                    v-slot="props"
+                                    sortable>
+                      {{ props.row.unit_price_display }}
+                    </${b}-table-column>
+
+                    <${b}-table-column label="Sale Price"
+                                    field="sale_price"
+                                    v-slot="props"
+                                    sortable>
+                      <span class="has-background-warning">
+                        {{ props.row.sale_price_display }}
+                      </span>
+                    </${b}-table-column>
+
+                    <${b}-table-column label="Sale Ends"
+                                    field="sale_ends"
+                                    v-slot="props"
+                                    sortable>
+                      <span class="has-background-warning">
+                        {{ props.row.sale_ends_display }}
+                      </span>
+                    </${b}-table-column>
+
+                    <${b}-table-column label="Department"
+                                    field="department_name"
+                                    v-slot="props"
+                                    sortable
+                                    searchable>
+                      {{ props.row.department_name }}
+                    </${b}-table-column>
+
+                    <${b}-table-column label="Vendor"
+                                    field="vendor_name"
+                                    v-slot="props"
+                                    sortable
+                                    searchable>
+                      {{ props.row.vendor_name }}
+                    </${b}-table-column>
+
+                    <template #empty>
+                      <div class="content has-text-grey has-text-centered">
+                        <p>
+                          <b-icon
+                            pack="fas"
+                            icon="sad-tear"
+                            size="is-large">
+                          </b-icon>
+                        </p>
+                        <p>Nothing here.</p>
+                      </div>
+                    </template>
+                  </${b}-table>
+
+                  <div class="buttons">
+                    <b-button @click="pastItemsShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              icon-pack="fas"
+                              icon-left="plus"
+                              @click="pastItemsAddSelected()"
+                              :disabled="!pastItemsSelected">
+                      Add Selected Item
+                    </b-button>
+                  </div>
+
+                </div>
+              </div>
+            </${b}-modal>
+            % endif
+
+            <${b}-table v-if="items.length"
+                     :data="items"
+                     :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'">
+
+              <${b}-table-column :label="productKeyLabel"
+                              v-slot="props">
+                {{ props.row.product_key }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Brand"
+                              v-slot="props">
+                {{ props.row.product_brand }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Description"
+                              v-slot="props">
+                {{ props.row.product_description }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Size"
+                              v-slot="props">
+                {{ props.row.product_size }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Department"
+                              v-slot="props">
+                {{ props.row.department_display }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Quantity"
+                              v-slot="props">
+                <span v-html="props.row.order_quantity_display"></span>
+              </${b}-table-column>
+
+              <${b}-table-column label="Unit Price"
+                              v-slot="props">
+                <span
+                  % if product_price_may_be_questionable:
+                  :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''"
+                  % else:
+                  :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null"
+                  % endif
+                  >
+                  {{ props.row.unit_price_display }}
+                </span>
+              </${b}-table-column>
+
+              % if allow_item_discounts:
+                  <${b}-table-column label="Discount"
+                                  v-slot="props">
+                    {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }}
+                  </${b}-table-column>
+              % endif
+
+              <${b}-table-column label="Total"
+                              v-slot="props">
+                <span
+                  % if product_price_may_be_questionable:
+                  :class="props.row.price_needs_confirmation ? 'has-background-warning' : ''"
+                  % else:
+                  :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null"
+                  % endif
+                  >
+                  {{ props.row.total_price_display }}
+                </span>
+              </${b}-table-column>
+
+              <${b}-table-column label="Vendor"
+                              v-slot="props">
+                {{ props.row.vendor_display }}
+              </${b}-table-column>
+
+              <${b}-table-column field="actions"
+                              label="Actions"
+                              v-slot="props">
+                <a href="#"
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
+                   @click.prevent="showEditItemDialog(props.row)">
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="edit" />
+                        <span>Edit</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-edit"></i>
+                      Edit
+                  % endif
+                </a>
+                &nbsp;
+
+                <a href="#"
+                   % if request.use_oruga:
+                       class="has-text-danger"
+                   % else:
+                       class="grid-action has-text-danger"
+                   % endif
+                   @click.prevent="deleteItem(props.index)">
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="trash" />
+                        <span>Delete</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-trash"></i>
+                      Delete
+                  % endif
+                </a>
+                &nbsp;
+              </${b}-table-column>
+
+            </${b}-table>
           </div>
         </div>
-      </b-collapse>
+      </${b}-collapse>
 
       ${self.order_form_buttons()}
 
@@ -182,47 +1264,149 @@
 
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+  <script>
 
     const CustomerOrderCreator = {
         template: '#customer-order-creator-template',
+        mixins: [SimpleRequestMixin],
         data() {
+
+            let defaultUnitChoices = ${json.dumps(default_uom_choices)|n}
+            let defaultUOM = ${json.dumps(default_uom)|n}
+
             return {
                 batchAction: null,
+                batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n},
 
-                customerPanelOpen: true,
-                customerIsKnown: true,
-                customerUUID: ${json.dumps(batch.customer_uuid)|n},
-                customerDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n},
+                customerPanelOpen: false,
+                contactIsKnown: ${json.dumps(contact_is_known)|n},
+                % if new_order_requires_customer:
+                contactUUID: ${json.dumps(batch.customer_uuid)|n},
+                % else:
+                contactUUID: ${json.dumps(batch.person_uuid)|n},
+                % endif
+                contactDisplay: ${json.dumps(contact_display)|n},
                 customerEntry: null,
-                phoneNumberEntry: ${json.dumps(batch.phone_number)|n},
-                phoneNumberSaved: true,
-                customerName: null,
-                phoneNumber: null,
+                contactProfileURL: ${json.dumps(contact_profile_url)|n},
+                refreshingContact: false,
 
-                ## TODO: should find a better way to handle CSRF token
-                csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+                orderPhoneNumber: ${json.dumps(batch.phone_number)|n},
+                contactPhones: ${json.dumps(contact_phones)|n},
+                addOtherPhoneNumber: ${json.dumps(add_phone_number)|n},
+
+                orderEmailAddress: ${json.dumps(batch.email_address)|n},
+                contactEmails: ${json.dumps(contact_emails)|n},
+                addOtherEmailAddress: ${json.dumps(add_email_address)|n},
+
+                % if allow_contact_info_choice:
+
+                    editPhoneNumberShowDialog: false,
+                    editPhoneNumberOther: null,
+                    editPhoneNumberAddOther: false,
+                    existingPhoneUUID: null,
+                    editPhoneNumberSaving: false,
+
+                    editEmailAddressShowDialog: false,
+                    editEmailAddressOther: null,
+                    editEmailAddressAddOther: false,
+                    existingEmailUUID: null,
+                    editEmailAddressOther: null,
+                    editEmailAddressSaving: false,
+
+                % endif
+
+                newCustomerFirstName: ${json.dumps(new_customer_first_name)|n},
+                newCustomerLastName: ${json.dumps(new_customer_last_name)|n},
+                newCustomerPhone: ${json.dumps(new_customer_phone)|n},
+                newCustomerEmail: ${json.dumps(new_customer_email)|n},
+                contactNotes: ${json.dumps(contact_notes)|n},
+
+                editNewCustomerShowDialog: false,
+                editNewCustomerFirstName: null,
+                editNewCustomerLastName: null,
+                editNewCustomerPhone: null,
+                editNewCustomerEmail: null,
+                editNewCustomerSaving: false,
+
+                items: ${json.dumps(order_items)|n},
+                editingItem: null,
+                showingItemDialog: false,
+                itemDialogSaving: false,
+                % if request.use_oruga:
+                    itemDialogTab: 'product',
+                % else:
+                    itemDialogTabIndex: 0,
+                % endif
+                % if allow_past_item_reorder:
+                pastItemsShowDialog: false,
+                pastItemsLoading: false,
+                pastItems: [],
+                pastItemsSelected: null,
+                % endif
+                productIsKnown: true,
+                selectedProduct: null,
+                productUUID: null,
+                productDisplay: null,
+                productKey: null,
+                productKeyField: ${json.dumps(product_key_field)|n},
+                productKeyLabel: ${json.dumps(product_key_label)|n},
+                productSize: null,
+                productCaseQuantity: null,
+                productUnitPrice: null,
+                productUnitPriceDisplay: null,
+                productUnitRegularPriceDisplay: null,
+                productCasePrice: null,
+                productCasePriceDisplay: null,
+                productSalePrice: null,
+                productSalePriceDisplay: null,
+                productSaleEndsDisplay: null,
+                productURL: null,
+                productImageURL: null,
+                productQuantity: null,
+                defaultUnitChoices: defaultUnitChoices,
+                productUnitChoices: defaultUnitChoices,
+                defaultUOM: defaultUOM,
+                productUOM: defaultUOM,
+                productCaseSize: null,
+
+                % if product_price_may_be_questionable:
+                productPriceNeedsConfirmation: false,
+                % endif
+
+                % if allow_item_discounts:
+                    productDiscountPercent: ${json.dumps(default_item_discount)|n},
+                    allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n},
+                % endif
+
+                pendingProduct: {},
+                pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n},
+                departmentOptions: ${json.dumps(department_options)|n},
+                % if unknown_product_confirm_price:
+                    confirmPriceShowDialog: false,
+                % endif
+
+                // nb. hack to force refresh for vue3
+                refreshProductDescription: 1,
+                refreshTotalPrice: 1,
+
+                submittingOrder: false,
             }
         },
         computed: {
             customerPanelHeader() {
                 let text = "Customer"
 
-                if (this.customerIsKnown) {
-                    if (this.customerUUID) {
-                        if (this.$refs.customerAutocomplete) {
-                            text = "Customer: " + this.$refs.customerAutocomplete.getDisplayText()
+                if (this.contactIsKnown) {
+                    if (this.contactUUID) {
+                        if (this.$refs.contactAutocomplete) {
+                            text = "Customer: " + this.$refs.contactAutocomplete.getDisplayText()
                         } else {
-                            text = "Customer: " + this.customerDisplay
+                            text = "Customer: " + this.contactDisplay
                         }
                     }
                 } else {
-                    if (this.customerName) {
-                        text = "Customer: " + this.customerName
+                    if (this.contactDisplay) {
+                        text = "Customer: " + this.contactDisplay
                     }
                 }
 
@@ -235,9 +1419,7 @@
             customerHeaderClass() {
                 if (!this.customerPanelOpen) {
                     if (this.customerStatusType == 'is-danger') {
-                        return 'has-text-danger'
-                    } else if (this.customerStatusType == 'is-warning') {
-                        return 'has-text-warning'
+                        return 'has-text-white'
                     }
                 }
             },
@@ -254,34 +1436,40 @@
             },
             customerStatusTypeAndText() {
                 let phoneNumber = null
-                if (this.customerIsKnown) {
-                    if (!this.customerUUID) {
+                if (this.contactIsKnown) {
+                    if (!this.contactUUID) {
                         return {
                             type: 'is-danger',
                             text: "Please identify the customer.",
                         }
                     }
-                    if (!this.phoneNumberEntry) {
+                    if (!this.orderPhoneNumber) {
                         return {
                             type: 'is-warning',
                             text: "Please provide a phone number for the customer.",
                         }
                     }
-                    phoneNumber = this.phoneNumberEntry
+                    if (this.contactNotes.length) {
+                        return {
+                            type: 'is-warning',
+                            text: "Please review notes below.",
+                        }
+                    }
+                    phoneNumber = this.orderPhoneNumber
                 } else { // customer is not known
-                    if (!this.customerName) {
+                    if (!this.contactDisplay) {
                         return {
                             type: 'is-danger',
                             text: "Please identify the customer.",
                         }
                     }
-                    if (!this.phoneNumber) {
+                    if (!this.newCustomerPhone) {
                         return {
                             type: 'is-warning',
                             text: "Please provide a phone number for the customer.",
                         }
                     }
-                    phoneNumber = this.phoneNumber
+                    phoneNumber = this.newCustomerPhone
                 }
 
                 let phoneDigits = phoneNumber.replace(/\D/g, '')
@@ -292,7 +1480,7 @@
                     }
                 }
 
-                if (!this.customerIsKnown) {
+                if (!this.contactIsKnown) {
                     return {
                         type: 'is-warning',
                         text: "Will create a new customer record.",
@@ -301,7 +1489,162 @@
 
                 return {
                     type: null,
-                    text: "Everything seems to be okay here.",
+                    text: "Customer info looks okay.",
+                }
+            },
+
+            % if allow_contact_info_choice:
+
+                editPhoneNumberSaveDisabled() {
+                    if (this.editPhoneNumberSaving) {
+                        return true
+                    }
+                    if (!this.existingPhoneUUID && !this.editPhoneNumberOther) {
+                        return true
+                    }
+                    return false
+                },
+
+                editPhoneNumberSaveText() {
+                    if (this.editPhoneNumberSaving) {
+                        return "Working, please wait..."
+                    }
+                    return "Save"
+                },
+
+                editEmailAddressSaveDisabled() {
+                    if (this.editEmailAddressSaving) {
+                        return true
+                    }
+                    if (!this.existingEmailUUID && !this.editEmailAddressOther) {
+                        return true
+                    }
+                    return false
+                },
+
+                editEmailAddressSaveText() {
+                    if (this.editEmailAddressSaving) {
+                        return "Working, please wait..."
+                    }
+                    return "Save"
+                },
+
+            % endif
+
+            editNewCustomerSaveDisabled() {
+                if (this.editNewCustomerSaving) {
+                    return true
+                }
+                if (!(this.editNewCustomerFirstName && this.editNewCustomerLastName)) {
+                    return true
+                }
+                if (!(this.editNewCustomerPhone || this.editNewCustomerEmail)) {
+                    return true
+                }
+                return false
+            },
+
+            editNewCustomerSaveText() {
+                if (this.editNewCustomerSaving) {
+                    return "Working, please wait..."
+                }
+                return "Save"
+            },
+
+            itemsPanelHeader() {
+                let text = "Items"
+
+                if (this.items.length) {
+                    text = "Items: " + this.items.length.toString() + " for " + this.batchTotalPriceDisplay
+                }
+
+                return text
+            },
+
+            % if allow_item_discounts:
+
+                allowItemDiscount() {
+                    if (!this.allowDiscountsIfOnSale) {
+                        if (this.productSalePriceDisplay) {
+                            return false
+                        }
+                    }
+                    return true
+                },
+
+            % endif
+
+            pendingProductGrossMargin() {
+                let cost = this.pendingProduct.unit_cost
+                let price = this.pendingProduct.regular_price_amount
+                if (cost && price) {
+                    let margin = (price - cost) / price
+                    return (100 * margin).toFixed(2).toString() + " %"
+                }
+            },
+
+            itemDialogSaveDisabled() {
+
+                if (this.itemDialogSaving) {
+                    return true
+                }
+
+                if (this.productIsKnown) {
+                    if (!this.productUUID) {
+                        return true
+                    }
+
+                } else {
+                    for (let field of this.pendingProductRequiredFields) {
+                        if (!this.pendingProduct[field]) {
+                            return true
+                        }
+                    }
+                }
+
+                if (!this.productUOM) {
+                    return true
+                }
+
+                return false
+            },
+        },
+        mounted() {
+            if (this.customerStatusType) {
+                this.customerPanelOpen = true
+            }
+        },
+        watch: {
+
+            contactIsKnown: function(val) {
+
+                // when user clicks "contact is known" then we want to
+                // set focus to the autocomplete component
+                if (val) {
+                    this.$nextTick(() => {
+                        this.$refs.contactAutocomplete.focus()
+                    })
+
+                // if user has already specified a proper contact,
+                // i.e.  `contactUUID` is not null, *and* user has
+                // clicked the "contact is not yet in the system"
+                // button, i.e. `val` is false, then we want to *clear
+                // out* the existing contact selection.  this is
+                // primarily to avoid any ambiguity.
+                } else if (this.contactUUID) {
+                    this.$refs.contactAutocomplete.clearSelection()
+                }
+            },
+
+            productIsKnown(newval, oldval) {
+                // TODO: seems like this should be better somehow?
+                // e.g. maybe we should not be clearing *everything*
+                // in case user accidentally clicks, and then clicks
+                // "is known" again?  and if we *should* clear all,
+                // why does that require 2 steps?
+                if (!newval) {
+                    this.selectedProduct = null
+                    this.clearProduct()
                 }
             },
         },
@@ -326,8 +1669,8 @@
             //             return
             //         }
             //     }
-            //     this.customerIsKnown = true
-            //     this.customerUUID = null
+            //     this.contactIsKnown = true
+            //     this.contactUUID = null
             //     // this.customerEntry = null
             //     this.phoneNumberEntry = null
             //     this.customerName = null
@@ -356,71 +1699,708 @@
                 })
             },
 
-            submitBatchData(params, callback) {
+            submitBatchData(params, success, failure) {
                 let url = ${json.dumps(request.current_route_url())|n}
                 
-                let headers = {
-                    ## TODO: should find a better way to handle CSRF token
-                    'X-CSRF-TOKEN': this.csrftoken,
-                }
-
-                ## TODO: should find a better way to handle CSRF token
-                this.$http.post(url, params, {headers: headers}).then((response) => {
-                    if (callback) {
-                        callback(response)
+                this.simplePOST(url, params, response => {
+                    if (success) {
+                        success(response)
+                    }
+                }, response => {
+                    if (failure) {
+                        failure(response)
                     }
-                })
-            },
-
-            setCustomerData() {
-                let params = {
-                    action: 'set_customer_data',
-                    customer_uuid: this.customerUUID,
-                    phone_number: this.phoneNumberEntry,
-                }
-                let that = this
-                this.submitBatchData(params, function(response) {
-                    that.phoneNumberSaved = true
                 })
             },
 
             submitOrder() {
-                alert("okay then!")
+                this.submittingOrder = true
+
+                let params = {
+                    action: 'submit_new_order',
+                }
+
+                this.submitBatchData(params, response => {
+                    if (response.data.next_url) {
+                        location.href = response.data.next_url
+                    } else {
+                        location.reload()
+                    }
+                }, response => {
+                    this.submittingOrder = false
+                })
             },
 
-            customerChanged(uuid) {
+            contactChanged(uuid, callback) {
+
+                % if allow_past_item_reorder:
+                // clear out the past items cache
+                this.pastItemsSelected = null
+                this.pastItems = []
+                % endif
+
+                let params
                 if (!uuid) {
-                    this.phoneNumberEntry = null
-                    this.setCustomerData()
-                } else {
-                    let params = {
-                        action: 'get_customer_info',
-                        uuid: this.customerUUID,
+                    params = {
+                        action: 'unassign_contact',
                     }
-                    let that = this
-                    this.submitBatchData(params, function(response) {
-                        that.phoneNumberEntry = response.data.phone_number
-                        that.setCustomerData()
+                } else {
+                    params = {
+                        action: 'assign_contact',
+                        uuid: this.contactUUID,
+                    }
+                }
+                this.submitBatchData(params, response => {
+                    % if new_order_requires_customer:
+                    this.contactUUID = response.data.customer_uuid
+                    % else:
+                    this.contactUUID = response.data.person_uuid
+                    % endif
+                    this.contactDisplay = response.data.contact_display
+                    this.orderPhoneNumber = response.data.phone_number
+                    this.orderEmailAddress = response.data.email_address
+                    this.addOtherPhoneNumber = response.data.add_phone_number
+                    this.addOtherEmailAddress = response.data.add_email_address
+                    this.contactProfileURL = response.data.contact_profile_url
+                    this.contactPhones = response.data.contact_phones
+                    this.contactEmails = response.data.contact_emails
+                    this.contactNotes = response.data.contact_notes
+                    if (callback) {
+                        callback()
+                    }
+                })
+            },
+
+            refreshContact() {
+                this.refreshingContact = true
+                this.contactChanged(this.contactUUID, () => {
+                    this.refreshingContact = false
+                    this.$buefy.toast.open({
+                        message: "Contact info has been refreshed.",
+                        type: 'is-success',
+                        duration: 3000, // 3 seconds
+                    })
+                })
+            },
+
+            % if allow_contact_info_choice:
+
+                editPhoneNumberInit() {
+                    this.existingPhoneUUID = null
+                    let normalOrderPhone = (this.orderPhoneNumber || '').replace(/\D/g, '')
+                    for (let phone of this.contactPhones) {
+                        let normal = phone.number.replace(/\D/g, '')
+                        if (normal == normalOrderPhone) {
+                            this.existingPhoneUUID = phone.uuid
+                            break
+                        }
+                    }
+                    this.editPhoneNumberOther = this.existingPhoneUUID ? null : this.orderPhoneNumber
+                    this.editPhoneNumberAddOther = this.addOtherPhoneNumber
+                    this.editPhoneNumberShowDialog = true
+                },
+
+                editPhoneNumberSave() {
+                    this.editPhoneNumberSaving = true
+
+                    let params = {
+                        action: 'update_phone_number',
+                        phone_number: null,
+                    }
+
+                    if (this.existingPhoneUUID) {
+                        for (let phone of this.contactPhones) {
+                            if (phone.uuid == this.existingPhoneUUID) {
+                                params.phone_number = phone.number
+                                break
+                            }
+                        }
+                    }
+
+                    if (params.phone_number) {
+                        params.add_phone_number = false
+                    } else {
+                        params.phone_number = this.editPhoneNumberOther
+                        params.add_phone_number = this.editPhoneNumberAddOther
+                    }
+
+                    this.submitBatchData(params, response => {
+                        if (response.data.success) {
+                            this.orderPhoneNumber = response.data.phone_number
+                            this.addOtherPhoneNumber = response.data.add_phone_number
+                            this.editPhoneNumberShowDialog = false
+                        } else {
+                            this.$buefy.toast.open({
+                                message: "Save failed: " + response.data.error,
+                                type: 'is-danger',
+                                duration: 2000, // 2 seconds
+                            })
+                        }
+                        this.editPhoneNumberSaving = false
+                    })
+
+                },
+
+                editEmailAddressInit() {
+                    this.existingEmailUUID = null
+                    let normalOrderEmail = (this.orderEmailAddress || '').toLowerCase()
+                    for (let email of this.contactEmails) {
+                        let normal = email.address.toLowerCase()
+                        if (normal == normalOrderEmail) {
+                            this.existingEmailUUID = email.uuid
+                            break
+                        }
+                    }
+                    this.editEmailAddressOther = this.existingEmailUUID ? null : this.orderEmailAddress
+                    this.editEmailAddressAddOther = this.addOtherEmailAddress
+                    this.editEmailAddressShowDialog = true
+                },
+
+                editEmailAddressSave() {
+                    this.editEmailAddressSaving = true
+
+                    let params = {
+                        action: 'update_email_address',
+                        email_address: null,
+                    }
+
+                    if (this.existingEmailUUID) {
+                        for (let email of this.contactEmails) {
+                            if (email.uuid == this.existingEmailUUID) {
+                                params.email_address = email.address
+                                break
+                            }
+                        }
+                    }
+
+                    if (params.email_address) {
+                        params.add_email_address = false
+                    } else {
+                        params.email_address = this.editEmailAddressOther
+                        params.add_email_address = this.editEmailAddressAddOther
+                    }
+
+                    this.submitBatchData(params, response => {
+                        if (response.data.success) {
+                            this.orderEmailAddress = response.data.email_address
+                            this.addOtherEmailAddress = response.data.add_email_address
+                            this.editEmailAddressShowDialog = false
+                        } else {
+                            this.$buefy.toast.open({
+                                message: "Save failed: " + response.data.error,
+                                type: 'is-danger',
+                                duration: 2000, // 2 seconds
+                            })
+                        }
+                        this.editEmailAddressSaving = false
+                    })
+                },
+
+            % endif
+
+            editNewCustomerInit() {
+                this.editNewCustomerFirstName = this.newCustomerFirstName
+                this.editNewCustomerLastName = this.newCustomerLastName
+                this.editNewCustomerPhone = this.newCustomerPhone
+                this.editNewCustomerEmail = this.newCustomerEmail
+                this.editNewCustomerShowDialog = true
+                this.$nextTick(() => {
+                    this.$refs.editNewCustomerInput.focus()
+                })
+            },
+
+            editNewCustomerSave() {
+                this.editNewCustomerSaving = true
+
+                let params = {
+                    action: 'update_pending_customer',
+                    first_name: this.editNewCustomerFirstName,
+                    last_name: this.editNewCustomerLastName,
+                    phone_number: this.editNewCustomerPhone,
+                    email_address: this.editNewCustomerEmail,
+                }
+
+                this.submitBatchData(params, response => {
+                    if (response.data.success) {
+                        this.contactDisplay = response.data.new_customer_name
+                        this.newCustomerFirstName = response.data.new_customer_first_name
+                        this.newCustomerLastName = response.data.new_customer_last_name
+                        this.newCustomerPhone = response.data.phone_number
+                        this.orderPhoneNumber = response.data.phone_number
+                        this.newCustomerEmail = response.data.email_address
+                        this.orderEmailAddress = response.data.email_address
+                        this.editNewCustomerShowDialog = false
+                    } else {
+                        this.$buefy.toast.open({
+                            message: "Save failed: " + (response.data.error || "(unknown error)"),
+                            type: 'is-danger',
+                            duration: 2000, // 2 seconds
+                        })
+                    }
+                    this.editNewCustomerSaving = false
+                })
+
+            },
+
+            getCasePriceDisplay() {
+                if (this.productIsKnown) {
+                    return this.productCasePriceDisplay
+                }
+
+                let casePrice = this.getItemCasePrice()
+                if (casePrice) {
+                    return "$" + casePrice
+                }
+            },
+
+            getItemUnitPrice() {
+                if (this.productIsKnown) {
+                    return this.productSalePrice || this.productUnitPrice
+                }
+                return this.pendingProduct.regular_price_amount
+            },
+
+            getItemCasePrice() {
+                if (this.productIsKnown) {
+                    return this.productCasePrice
+                }
+
+                if (this.pendingProduct.regular_price_amount) {
+                    if (this.pendingProduct.case_size) {
+                        let casePrice = this.pendingProduct.regular_price_amount * this.pendingProduct.case_size
+                        casePrice = casePrice.toFixed(2)
+                        return casePrice
+                    }
+                }
+            },
+
+            getItemTotalPriceDisplay() {
+                let basePrice = null
+                if (this.productUOM == '${enum.UNIT_OF_MEASURE_CASE}') {
+                    basePrice = this.getItemCasePrice()
+                } else {
+                    basePrice = this.getItemUnitPrice()
+                }
+
+                if (basePrice) {
+                    let totalPrice = basePrice * this.productQuantity
+                    if (totalPrice) {
+                        % if allow_item_discounts:
+                            if (this.productDiscountPercent) {
+                                totalPrice *= (100 - this.productDiscountPercent) / 100
+                            }
+                        % endif
+                        totalPrice = totalPrice.toFixed(2)
+                        return "$" + totalPrice
+                    }
+                }
+            },
+
+            productLookupSelected(selected) {
+                // TODO: this still is a hack somehow, am sure of it.
+                // need to clean this up at some point
+                this.selectedProduct = selected
+                this.clearProduct()
+                this.productChanged(selected)
+            },
+
+            copyPendingProductAttrs(from, to) {
+                to.upc = from.upc
+                to.item_id = from.item_id
+                to.scancode = from.scancode
+                to.brand_name = from.brand_name
+                to.description = from.description
+                to.size = from.size
+                to.department_uuid = from.department_uuid
+                to.regular_price_amount = from.regular_price_amount
+                to.vendor_name = from.vendor_name
+                to.vendor_item_code = from.vendor_item_code
+                to.unit_cost = from.unit_cost
+                to.case_size = from.case_size
+                to.notes = from.notes
+            },
+
+            showAddItemDialog() {
+                this.customerPanelOpen = false
+                this.editingItem = null
+                this.productIsKnown = true
+                this.selectedProduct = null
+                this.productUUID = null
+                this.productDisplay = null
+                this.productKey = null
+                this.productSize = null
+                this.productCaseQuantity = null
+                this.productUnitPrice = null
+                this.productUnitPriceDisplay = null
+                this.productUnitRegularPriceDisplay = null
+                this.productCasePrice = null
+                this.productCasePriceDisplay = null
+                this.productSalePrice = null
+                this.productSalePriceDisplay = null
+                this.productSaleEndsDisplay = null
+                this.productImageURL = '${request.static_url('tailbone:static/img/product.png')}'
+
+                this.pendingProduct = {}
+
+                this.productQuantity = 1
+                this.productUnitChoices = this.defaultUnitChoices
+                this.productUOM = this.defaultUOM
+
+                % if product_price_may_be_questionable:
+                this.productPriceNeedsConfirmation = false
+                % endif
+
+                % if allow_item_discounts:
+                    this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
+                % endif
+
+                % if request.use_oruga:
+                    this.itemDialogTab = 'product'
+                % else:
+                    this.itemDialogTabIndex = 0
+                % endif
+                this.showingItemDialog = true
+                this.$nextTick(() => {
+                    this.$refs.productLookup.focus()
+                })
+            },
+
+            % if allow_past_item_reorder:
+
+            showAddPastItem() {
+                this.pastItemsSelected = null
+
+                if (!this.pastItems.length) {
+                    this.pastItemsLoading = true
+                    let params = {
+                        action: 'get_past_items',
+                    }
+                    this.submitBatchData(params, response => {
+                        this.pastItems = response.data.past_items
+                        this.pastItemsLoading = false
                     })
                 }
+
+                this.pastItemsShowDialog = true
             },
 
-            phoneNumberChanged(value) {
-                this.phoneNumberSaved = false
+            pastItemsAddSelected() {
+                this.pastItemsShowDialog = false
+
+                let selected = this.pastItemsSelected
+                this.editingItem = null
+                this.productIsKnown = true
+                this.productUUID = selected.uuid
+                this.productDisplay = selected.full_description
+                this.productKey = selected.key
+                this.productSize = selected.size
+                this.productCaseQuantity = selected.case_quantity
+                this.productUnitPrice = selected.unit_price
+                this.productUnitPriceDisplay = selected.unit_price_display
+                this.productUnitRegularPriceDisplay = selected.unit_price_display
+                this.productCasePrice = selected.case_price
+                this.productCasePriceDisplay = selected.case_price_display
+                this.productSalePrice = selected.sale_price
+                this.productSalePriceDisplay = selected.sale_price_display
+                this.productSaleEndsDisplay = selected.sale_ends_display
+                this.productImageURL = selected.image_url
+                this.productURL = selected.url
+                this.productQuantity = 1
+                this.productUnitChoices = selected.uom_choices
+                // TODO: seems like the default should not be so generic?
+                this.productUOM = this.defaultUOM
+
+                % if product_price_may_be_questionable:
+                this.productPriceNeedsConfirmation = false
+                % endif
+
+                // nb. hack to force refresh for vue3
+                this.refreshProductDescription += 1
+                this.refreshTotalPrice += 1
+
+                % if request.use_oruga:
+                    this.itemDialogTab = 'quantity'
+                % else:
+                    this.itemDialogTabIndex = 1
+                % endif
+                this.showingItemDialog = true
             },
 
-            phoneNumberKeyDown(event) {
-                if (event.which == 13) { // Enter
-                    this.setCustomerData()
+            % endif
+
+            showEditItemDialog(row) {
+                this.editingItem = row
+
+                this.productIsKnown = !!row.product_uuid
+                this.productUUID = row.product_uuid
+
+                if (row.product_uuid) {
+                    this.selectedProduct = {
+                        uuid: row.product_uuid,
+                        full_description: row.product_full_description,
+                        url: row.product_url,
+                    }
+                } else {
+                    this.selectedProduct = null
                 }
+
+                // nb. must construct new object before updating data
+                // (otherwise vue does not notice the changes?)
+                let pending = {}
+                if (row.pending_product) {
+                    this.copyPendingProductAttrs(row.pending_product, pending)
+                }
+                this.pendingProduct = pending
+
+                this.productDisplay = row.product_full_description
+                this.productKey = row.product_key
+                this.productSize = row.product_size
+                this.productCaseQuantity = row.case_quantity
+                this.productURL = row.product_url
+                this.productUnitPrice = row.unit_price
+                this.productUnitPriceDisplay = row.unit_price_display
+                this.productUnitRegularPriceDisplay = row.unit_regular_price_display
+                this.productCasePrice = row.case_price
+                this.productCasePriceDisplay = row.case_price_display
+                this.productSalePrice = row.sale_price
+                this.productSalePriceDisplay = row.unit_sale_price_display
+                this.productSaleEndsDisplay = row.sale_ends_display
+                this.productImageURL = row.product_image_url || '${request.static_url('tailbone:static/img/product.png')}'
+
+                % if product_price_may_be_questionable:
+                this.productPriceNeedsConfirmation = row.price_needs_confirmation
+                % endif
+
+                this.productQuantity = row.order_quantity
+                this.productUnitChoices = row.order_uom_choices
+                this.productUOM = row.order_uom
+
+                % if allow_item_discounts:
+                    this.productDiscountPercent = row.discount_percent
+                % endif
+
+                // nb. hack to force refresh for vue3
+                this.refreshProductDescription += 1
+                this.refreshTotalPrice += 1
+
+                % if request.use_oruga:
+                    this.itemDialogTab = 'quantity'
+                % else:
+                    this.itemDialogTabIndex = 1
+                % endif
+                this.showingItemDialog = true
+            },
+
+            deleteItem(index) {
+                if (!confirm("Are you sure you want to delete this item?")) {
+                    return
+                }
+
+                let params = {
+                    action: 'delete_item',
+                    uuid: this.items[index].uuid,
+                }
+                this.submitBatchData(params, response => {
+                    if (response.data.error) {
+                        this.$buefy.toast.open({
+                            message: "Delete failed:  " + response.data.error,
+                            type: 'is-warning',
+                            duration: 2000, // 2 seconds
+                        })
+                    } else {
+                        this.items.splice(index, 1)
+                        this.batchTotalPriceDisplay = response.data.batch.total_price_display
+                    }
+                })
+            },
+
+            clearProduct() {
+                this.productUUID = null
+                this.productDisplay = null
+                this.productKey = null
+                this.productSize = null
+                this.productCaseQuantity = null
+                this.productUnitPrice = null
+                this.productUnitPriceDisplay = null
+                this.productUnitRegularPriceDisplay = null
+                this.productCasePrice = null
+                this.productCasePriceDisplay = null
+                this.productSalePrice = null
+                this.productSalePriceDisplay = null
+                this.productSaleEndsDisplay = null
+                this.productURL = null
+                this.productImageURL = null
+                this.productUnitChoices = this.defaultUnitChoices
+
+                % if allow_item_discounts:
+                this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
+                % endif
+
+                % if product_price_may_be_questionable:
+                this.productPriceNeedsConfirmation = false
+                % endif
+            },
+
+            setProductUnitChoices(choices) {
+                this.productUnitChoices = choices
+
+                let found = false
+                for (let uom of choices) {
+                    if (this.productUOM == uom.key) {
+                        found = true
+                        break
+                    }
+                }
+                if (!found) {
+                    this.productUOM = choices[0].key
+                }
+            },
+
+            productChanged(product) {
+                if (product) {
+                    let params = {
+                        action: 'get_product_info',
+                        uuid: product.uuid,
+                    }
+                    // nb. it is possible for the handler to "swap"
+                    // the product selection, i.e. user chooses a "per
+                    // LB" item but the handler only allows selling by
+                    // the "case" item.  so we do not assume the uuid
+                    // received above is the correct one, but just use
+                    // whatever came back from handler
+                    this.submitBatchData(params, response => {
+                        this.selectedProduct = response.data
+
+                        this.productUUID = response.data.uuid
+                        this.productKey = response.data.key
+                        this.productDisplay = response.data.full_description
+                        this.productSize = response.data.size
+                        this.productCaseQuantity = response.data.case_quantity
+                        this.productUnitPrice = response.data.unit_price
+                        this.productUnitPriceDisplay = response.data.unit_price_display
+                        this.productUnitRegularPriceDisplay = response.data.unit_price_display
+                        this.productCasePrice = response.data.case_price
+                        this.productCasePriceDisplay = response.data.case_price_display
+                        this.productSalePrice = response.data.sale_price
+                        this.productSalePriceDisplay = response.data.sale_price_display
+                        this.productSaleEndsDisplay = response.data.sale_ends_display
+
+                        % if allow_item_discounts:
+                            this.productDiscountPercent = this.allowItemDiscount ? response.data.default_item_discount : null
+                        % endif
+
+                        this.productURL = response.data.url
+                        this.productImageURL = response.data.image_url
+                        this.setProductUnitChoices(response.data.uom_choices)
+
+                        % if product_price_may_be_questionable:
+                        this.productPriceNeedsConfirmation = false
+                        % endif
+
+                        % if request.use_oruga:
+                            this.itemDialogTab = 'quantity'
+                        % else:
+                            this.itemDialogTabIndex = 1
+                        % endif
+
+                        // nb. hack to force refresh for vue3
+                        this.refreshProductDescription += 1
+                        this.refreshTotalPrice += 1
+
+                    }, response => {
+                        this.clearProduct()
+                    })
+                } else {
+                    this.clearProduct()
+                }
+            },
+
+            itemDialogAttemptSave() {
+                this.itemDialogSaving = true
+
+                let params = {
+                    product_is_known: this.productIsKnown,
+                    % if product_price_may_be_questionable:
+                        price_needs_confirmation: this.productPriceNeedsConfirmation,
+                    % endif
+                    order_quantity: this.productQuantity,
+                    order_uom: this.productUOM,
+                }
+
+                % if allow_item_discounts:
+                    params.discount_percent = this.productDiscountPercent
+                % endif
+
+                if (this.productIsKnown) {
+                    params.product_uuid = this.productUUID
+                } else {
+                    params.pending_product = this.pendingProduct
+                }
+
+                if (this.editingItem) {
+                    params.action = 'update_item'
+                    params.uuid = this.editingItem.uuid
+                } else {
+                    params.action = 'add_item'
+                }
+
+                this.submitBatchData(params, response => {
+
+                    if (params.action == 'add_item') {
+                        this.items.push(response.data.row)
+
+                    } else { // update_item
+                        // must update each value separately, instead of
+                        // overwriting the item record, or else display will
+                        // not update properly
+                        for (let [key, value] of Object.entries(response.data.row)) {
+                            this.editingItem[key] = value
+                        }
+                    }
+
+                    // also update the batch total price
+                    this.batchTotalPriceDisplay = response.data.batch.total_price_display
+
+                    this.itemDialogSaving = false
+                    this.showingItemDialog = false
+                }, response => {
+                    this.itemDialogSaving = false
+                })
+            },
+
+            itemDialogSave() {
+
+                % if unknown_product_confirm_price:
+                    if (!this.productIsKnown && !this.editingItem) {
+                        this.showingItemDialog = false
+                        this.confirmPriceShowDialog = true
+                        return
+                    }
+                % endif
+
+                this.itemDialogAttemptSave()
+            },
+
+            confirmPriceCancel() {
+                this.confirmPriceShowDialog = false
+                this.showingItemDialog = true
+            },
+
+            confirmPriceSave() {
+                this.confirmPriceShowDialog = false
+                this.showingItemDialog = true
+                this.itemDialogAttemptSave()
             },
         },
     }
 
     Vue.component('customer-order-creator', CustomerOrderCreator)
+    <% request.register_component('customer-order-creator', 'CustomerOrderCreator') %>
 
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${product_lookup.tailbone_product_lookup_component()}
+</%def>
diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
new file mode 100644
index 00000000..4cc92bbf
--- /dev/null
+++ b/tailbone/templates/custorders/items/view.mako
@@ -0,0 +1,450 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="render_form()">
+  <div class="form">
+    <${form.component} ref="mainForm"
+                       % if master.has_perm('confirm_price'):
+                       @confirm-price="showConfirmPrice"
+                       % endif
+                       % if master.has_perm('change_status'):
+                       @change-status="showChangeStatus"
+                       @mark-received="markReceivedInit"
+                       % endif
+                       % if master.has_perm('add_note'):
+                       @add-note="showAddNote"
+                       % endif
+                       >
+    </${form.component}>
+  </div>
+</%def>
+
+<%def name="page_content()">
+  ${parent.page_content()}
+
+  % if master.has_perm('confirm_price'):
+      <b-modal has-modal-card
+               :active.sync="confirmPriceShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Confirm Price</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p>
+              Please provide a note</span>:
+            </p>
+            <b-input v-model="confirmPriceNote"
+                     ref="confirmPriceNoteField"
+                     type="textarea" rows="2">
+            </b-input>
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button type="is-primary"
+                      @click="confirmPriceSave()"
+                      :disabled="confirmPriceSaveDisabled"
+                      icon-pack="fas"
+                      icon-left="check">
+              {{ confirmPriceSubmitText }}
+            </b-button>
+            <b-button @click="confirmPriceShowDialog = false">
+              Cancel
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+      ${h.form(master.get_action_url('confirm_price', instance), ref='confirmPriceForm')}
+      ${h.csrf_token(request)}
+      ${h.hidden('note', **{':value': 'confirmPriceNote'})}
+      ${h.end_form()}
+  % endif
+
+  % if master.has_perm('change_status'):
+
+      ## TODO ##
+      <% contact = instance.order.person %>
+      <% email_address = rattail_app.get_contact_email_address(contact) %>
+
+      <b-modal has-modal-card
+               :active.sync="markReceivedShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Mark Received</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              new status will be:&nbsp;
+              <span class="has-text-weight-bold">
+                % if email_address:
+                    ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]}
+                % else:
+                    ${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]}
+                % endif
+              </span>
+            </p>
+            % if email_address:
+                <p class="block">
+                  This customer has an email address on file, which
+                  means that we will automatically send them an email
+                  notification, and advance the Order Product status to
+                  "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_CONTACTED]}".
+                </p>
+            % else:
+                <p class="block">
+                  This customer does *not* have an email address on
+                  file, which means that we will *not* automatically
+                  send them an email notification, so the Order
+                  Product status will become
+                  "${enum.CUSTORDER_ITEM_STATUS[enum.CUSTORDER_ITEM_STATUS_RECEIVED]}".
+                </p>
+            % endif
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button type="is-primary"
+                      @click="markReceivedSubmit()"
+                      :disabled="markReceivedSubmitting"
+                      icon-pack="fas"
+                      icon-left="check">
+              {{ markReceivedSubmitting ? "Working, please wait..." : "Mark Received" }}
+            </b-button>
+            <b-button @click="markReceivedShowDialog = false">
+              Cancel
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+      ${h.form(url(f'{route_prefix}.mark_received'), ref='markReceivedForm')}
+      ${h.csrf_token(request)}
+      ${h.hidden('order_item_uuids', value=instance.uuid)}
+      ${h.end_form()}
+
+      <b-modal :active.sync="showChangeStatusDialog">
+        <div class="card">
+          <div class="card-content">
+            <div class="level">
+              <div class="level-left">
+
+                <div class="level-item">
+                  Current status is:&nbsp;
+                </div>
+
+                <div class="level-item has-text-weight-bold">
+                  {{ orderItemStatuses[oldStatusCode] }}
+                </div>
+
+                <div class="level-item"
+                     style="margin-left: 5rem;">
+                  New status will be:
+                </div>
+
+                <b-field class="level-item"
+                         :type="newStatusCode ? null : 'is-danger'">
+                  <b-select v-model="newStatusCode">
+                    <option v-for="item in orderItemStatusOptions"
+                            :key="item.key"
+                            :value="item.key">
+                      {{ item.label }}
+                    </option>
+                  </b-select>
+                </b-field>
+
+              </div>
+            </div>
+
+            <div v-if="changeStatusGridData.length">
+
+              <p class="block">
+                Please indicate any other item(s) to which the new
+                status should be applied:
+              </p>
+
+              <b-table :data="changeStatusGridData"
+                       checkable 
+                       :checked-rows.sync="changeStatusCheckedRows"
+                       narrowed 
+                       class="is-size-7">
+                <b-table-column field="product_key" label="${rattail_app.get_product_key_label()}"
+                                v-slot="props">
+                  {{ props.row.product_key }}
+                </b-table-column>
+                <b-table-column field="brand_name" label="Brand"
+                                v-slot="props">
+                  <span v-html="props.row.brand_name"></span>
+                </b-table-column>
+                <b-table-column field="product_description" label="Description"
+                                v-slot="props">
+                  <span v-html="props.row.product_description"></span>
+                </b-table-column>
+                <b-table-column field="product_size" label="Size"
+                                v-slot="props">
+                  <span v-html="props.row.product_size"></span>
+                </b-table-column>
+                <b-table-column field="product_case_quantity" label="cPack"
+                                v-slot="props">
+                  <span v-html="props.row.product_case_quantity"></span>
+                </b-table-column>
+                <b-table-column field="department_name" label="Department"
+                                v-slot="props">
+                  <span v-html="props.row.department_name"></span>
+                </b-table-column>
+                <b-table-column field="order_quantity" label="oQty"
+                                v-slot="props">
+                  <span v-html="props.row.order_quantity"></span>
+                </b-table-column>
+                <b-table-column field="order_uom" label="UOM"
+                                v-slot="props">
+                  <span v-html="props.row.order_uom"></span>
+                </b-table-column>
+                <b-table-column field="total_price" label="Total $"
+                                v-slot="props">
+                  <span v-html="props.row.total_price"></span>
+                </b-table-column>
+                <b-table-column field="status_code" label="Status"
+                                v-slot="props">
+                  <span v-html="props.row.status_code"></span>
+                </b-table-column>
+                <b-table-column field="flagged" label="Flagged"
+                                v-slot="props">
+                  {{ props.row.flagged ? "FLAG" : "" }}
+                </b-table-column>
+              </b-table>
+
+              <br />
+            </div>
+
+            <p>
+              Please provide a note<span v-if="changeStatusGridData.length">
+                (will be applied to all selected items)</span>:
+            </p>
+            <b-input v-model="newStatusNote"
+                     type="textarea" rows="2">
+            </b-input>
+
+            <br />
+
+            <div class="buttons">
+              <b-button type="is-primary"
+                        :disabled="changeStatusSaveDisabled"
+                        icon-pack="fas"
+                        icon-left="save"
+                        @click="statusChangeSave()">
+                {{ changeStatusSubmitText }}
+              </b-button>
+              <b-button @click="cancelStatusChange">
+                Cancel
+              </b-button>
+            </div>
+
+          </div>
+        </div>
+      </b-modal>
+      ${h.form(master.get_action_url('change_status', instance), ref='changeStatusForm')}
+      ${h.csrf_token(request)}
+      ${h.hidden('new_status_code', **{'v-model': 'newStatusCode'})}
+      ${h.hidden('uuids', **{':value': 'changeStatusCheckedRows.map((row) => {return row.uuid}).join()'})}
+      ${h.hidden('note', **{':value': 'newStatusNote'})}
+      ${h.end_form()}
+  % endif
+
+  % if master.has_perm('add_note'):
+      <b-modal has-modal-card
+               :active.sync="showAddNoteDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Add Note</p>
+          </header>
+
+          <section class="modal-card-body">
+            <b-field>
+              <b-input type="textarea" rows="8"
+                       v-model="newNoteText"
+                       ref="newNoteTextArea">
+              </b-input>
+            </b-field>
+            <b-field>
+              <b-checkbox v-model="newNoteApplyAll">
+                Apply to all items on this order
+              </b-checkbox>
+            </b-field>
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button type="is-primary"
+                      @click="addNoteSave()"
+                      :disabled="addNoteSaveDisabled"
+                      icon-pack="fas"
+                      icon-left="save">
+              {{ addNoteSubmitting ? "Working, please wait..." : "Save Note" }}
+            </b-button>
+            <b-button @click="showAddNoteDialog = false">
+              Cancel
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n}
+
+    % if master.has_perm('confirm_price'):
+
+        ThisPageData.confirmPriceShowDialog = false
+        ThisPageData.confirmPriceNote = null
+        ThisPageData.confirmPriceSubmitting = false
+
+        ThisPage.computed.confirmPriceSaveDisabled = function() {
+            if (this.confirmPriceSubmitting) {
+                return true
+            }
+            return false
+        }
+
+        ThisPage.computed.confirmPriceSubmitText = function() {
+            if (this.confirmPriceSubmitting) {
+                return "Working, please wait..."
+            }
+            return "Confirm Price"
+        }
+
+        ThisPage.methods.showConfirmPrice = function() {
+            this.confirmPriceNote = null
+            this.confirmPriceShowDialog = true
+            this.$nextTick(() => {
+                this.$refs.confirmPriceNoteField.focus()
+            })
+        }
+
+        ThisPage.methods.confirmPriceSave = function() {
+            this.confirmPriceSubmitting = true
+            this.$refs.confirmPriceForm.submit()
+        }
+
+    % endif
+
+    % if master.has_perm('change_status'):
+
+        ThisPageData.markReceivedShowDialog = false
+        ThisPageData.markReceivedSubmitting = false
+
+        ThisPage.methods.markReceivedInit = function() {
+            this.markReceivedShowDialog = true
+        }
+
+        ThisPage.methods.markReceivedSubmit = function() {
+            this.markReceivedSubmitting = true
+            this.$refs.markReceivedForm.submit()
+        }
+
+        ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n}
+        ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n}
+
+        ThisPageData.oldStatusCode = ${instance.status_code}
+
+        ThisPageData.showChangeStatusDialog = false
+        ThisPageData.newStatusCode = null
+        ThisPageData.changeStatusGridData = ${json.dumps(other_order_items_data)|n}
+        ThisPageData.changeStatusCheckedRows = []
+        ThisPageData.newStatusNote = null
+        ThisPageData.changeStatusSubmitText = "Update Status"
+        ThisPageData.changeStatusSubmitting = false
+
+        ThisPage.computed.changeStatusSaveDisabled = function() {
+            if (!this.newStatusCode) {
+                return true
+            }
+            if (this.changeStatusSubmitting) {
+                return true
+            }
+            return false
+        }
+
+        ThisPage.methods.showChangeStatus = function() {
+            this.newStatusCode = null
+            // clear out any checked rows
+            this.changeStatusCheckedRows.length = 0
+            this.newStatusNote = null
+            this.showChangeStatusDialog = true
+        }
+
+        ThisPage.methods.cancelStatusChange = function() {
+            this.showChangeStatusDialog = false
+        }
+
+        ThisPage.methods.statusChangeSave = function() {
+            if (this.newStatusCode == this.oldStatusCode) {
+                alert("You chose the same status it already had...")
+                return
+            }
+
+            this.changeStatusSubmitting = true
+            this.changeStatusSubmitText = "Working, please wait..."
+            this.$refs.changeStatusForm.submit()
+        }
+
+        ${form.vue_component}Data.changeFlaggedSubmitting = false
+
+        ${form.vue_component}.methods.changeFlaggedSubmit = function() {
+            this.changeFlaggedSubmitting = true
+        }
+
+    % endif
+
+    % if master.has_perm('add_note'):
+
+        ThisPageData.showAddNoteDialog = false
+        ThisPageData.newNoteText = null
+        ThisPageData.newNoteApplyAll = false
+        ThisPageData.addNoteSubmitting = false
+
+        ThisPage.computed.addNoteSaveDisabled = function() {
+            if (!this.newNoteText) {
+                return true
+            }
+            if (this.addNoteSubmitting) {
+                return true
+            }
+            return false
+        }
+
+        ThisPage.methods.showAddNote = function() {
+            this.newNoteText = null
+            this.newNoteApplyAll = false
+            this.showAddNoteDialog = true
+            this.$nextTick(() => {
+                this.$refs.newNoteTextArea.focus()
+            })
+        }
+
+        ThisPage.methods.addNoteSave = function() {
+            this.addNoteSubmitting = true
+
+            let url = '${url('{}.add_note'.format(route_prefix), uuid=instance.uuid)}'
+            let params = {
+                note: this.newNoteText,
+                apply_all: this.newNoteApplyAll,
+            }
+
+            this.simplePOST(url, params, response => {
+                this.$refs.mainForm.eventsData = response.data.events
+                this.showAddNoteDialog = false
+                this.addNoteSubmitting = false
+            }, response => {
+                this.addNoteSubmitting = false
+            })
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/custorders/view.mako b/tailbone/templates/custorders/view.mako
new file mode 100644
index 00000000..e2af7bf4
--- /dev/null
+++ b/tailbone/templates/custorders/view.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+${parent.body()}
diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako
index cea3c969..86f5c121 100644
--- a/tailbone/templates/datasync/changes/index.mako
+++ b/tailbone/templates/datasync/changes/index.mako
@@ -5,46 +5,30 @@
   ${parent.grid_tools()}
 
   % if request.has_perm('datasync.restart'):
-      % if use_buefy:
       ${h.form(url('datasync.restart'), name='restart-datasync', class_='control', **{'@submit': 'submitRestartDatasyncForm'})}
-      % else:
-      ${h.form(url('datasync.restart'), name='restart-datasync', class_='autodisable')}
-      % endif
       ${h.csrf_token(request)}
-      % if use_buefy:
       <b-button native-type="submit"
                 :disabled="restartDatasyncFormSubmitting">
         {{ restartDatasyncFormButtonText }}
       </b-button>
-      % else:
-      ${h.submit('submit', "Restart DataSync", data_working_label="Restarting DataSync", class_='button')}
-      % endif
       ${h.end_form()}
   % endif
 
   % if allow_filemon_restart and request.has_perm('filemon.restart'):
-      % if use_buefy:
       ${h.form(url('filemon.restart'), name='restart-filemon', class_='control', **{'@submit': 'submitRestartFilemonForm'})}
-      % else:
-      ${h.form(url('filemon.restart'), name='restart-filemon', class_='autodisable')}
-      % endif
       ${h.csrf_token(request)}
-      % if use_buefy:
       <b-button native-type="submit"
                 :disabled="restartFilemonFormSubmitting">
         {{ restartFilemonFormButtonText }}
       </b-button>
-      % else:
-      ${h.submit('submit', "Restart FileMon", data_working_label="Restarting FileMon", class_='button')}
-      % endif
       ${h.end_form()}
   % endif
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if request.has_perm('datasync.restart'):
         TailboneGridData.restartDatasyncFormSubmitting = false
@@ -66,6 +50,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
new file mode 100644
index 00000000..2e444fb5
--- /dev/null
+++ b/tailbone/templates/datasync/configure.mako
@@ -0,0 +1,989 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style>
+    .invisible-watcher {
+        display: none;
+    }
+  </style>
+</%def>
+
+<%def name="buttons_row()">
+  <div class="level">
+    <div class="level-left">
+
+      <div class="level-item">
+        <p class="block">
+          This tool lets you modify the DataSync configuration.&nbsp;
+          Before using it,
+          <a href="#" class="has-background-warning"
+             @click.prevent="showConfigFilesNote = !showConfigFilesNote">
+            please see these notes.
+          </a>
+        </p>
+      </div>
+
+      <div class="level-item">
+        ${self.save_undo_buttons()}
+      </div>
+    </div>
+
+    <div class="level-right">
+
+      <div class="level-item">
+        ${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})}
+        ${h.csrf_token(request)}
+        <b-button type="is-primary"
+                  native-type="submit"
+                  @click="restartDatasync"
+                  :disabled="restartingDatasync"
+                  icon-pack="fas"
+                  icon-left="redo">
+          {{ restartDatasyncFormButtonText }}
+        </b-button>
+        ${h.end_form()}
+      </div>
+
+      <div class="level-item">
+        ${self.purge_button()}
+      </div>
+    </div>
+  </div>
+</%def>
+
+<%def name="form_content()">
+  ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})}
+
+  <b-notification type="is-warning"
+                  % if request.use_oruga:
+                  v-model:active="showConfigFilesNote"
+                  % else:
+                  :active.sync="showConfigFilesNote"
+                  % endif
+                  >
+    ## TODO: should link to some ratman page here, yes?
+    <p class="block">
+      This tool works by modifying settings in the DB.&nbsp; It
+      does <span class="is-italic">not</span> modify any config
+      files.&nbsp; If you intend to manage datasync watcher/consumer
+      config via files only then you should be sure to UNCHECK the
+      "Use these Settings.." checkbox near the top of page.
+    </p>
+    <p class="block">
+      If you have managed config via files thus far, and want to
+      start using this tool to manage via DB settings instead,
+      that&apos;s fine - but after saving the settings via this tool
+      you should probably remove all
+      <span class="is-family-code">[rattail.datasync]</span> entries
+      from your config file (and restart apps) so as to avoid
+      confusion.
+    </p>
+  </b-notification>
+
+  <b-field>
+    <b-checkbox name="rattail.datasync.use_profile_settings"
+                v-model="simpleSettings['rattail.datasync.use_profile_settings']"
+                native-value="true"
+                @input="settingsNeedSaved = true">
+      Use these Settings to configure watchers and consumers
+    </b-checkbox>
+  </b-field>
+
+  <div class="level">
+    <div class="level-left">
+      <div class="level-item">
+        <h3 class="is-size-3">Watcher Profiles</h3>
+      </div>
+    </div>
+    <div class="level-right">
+      <div class="level-item"
+           v-show="simpleSettings['rattail.datasync.use_profile_settings']">
+        <b-button type="is-primary"
+                  @click="newProfile()"
+                  icon-pack="fas"
+                  icon-left="plus">
+          New Profile
+        </b-button>
+      </div>
+      <div class="level-item">
+        <b-button @click="toggleDisabledProfiles()">
+          {{ showDisabledProfiles ? "Hide" : "Show" }} Disabled
+        </b-button>
+      </div>
+    </div>
+  </div>
+
+  <${b}-table :data="profilesData"
+              :row-class="getWatcherRowClass">
+      <${b}-table-column field="key"
+                      label="Watcher Key"
+                      v-slot="props">
+        {{ props.row.key }}
+      </${b}-table-column>
+      <${b}-table-column field="watcher_spec"
+                      label="Watcher Spec"
+                      v-slot="props">
+        {{ props.row.watcher_spec }}
+      </${b}-table-column>
+      <${b}-table-column field="watcher_dbkey"
+                      label="DB Key"
+                      v-slot="props">
+        {{ props.row.watcher_dbkey }}
+      </${b}-table-column>
+      <${b}-table-column field="watcher_delay"
+                      label="Loop Delay"
+                      v-slot="props">
+        {{ props.row.watcher_delay }} sec
+      </${b}-table-column>
+      <${b}-table-column field="watcher_retry_attempts"
+                      label="Attempts / Delay"
+                      v-slot="props">
+        {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec
+      </${b}-table-column>
+      <${b}-table-column field="watcher_default_runas"
+                      label="Default Runas"
+                      v-slot="props">
+        {{ props.row.watcher_default_runas }}
+      </${b}-table-column>
+      <${b}-table-column label="Consumers"
+                      v-slot="props">
+        {{ consumerShortList(props.row) }}
+      </${b}-table-column>
+##         <${b}-table-column field="notes" label="Notes">
+##           TODO
+##           ## {{ props.row.notes }}
+##         </${b}-table-column>
+      <${b}-table-column field="enabled"
+                      label="Enabled"
+                      v-slot="props">
+        {{ props.row.enabled ? "Yes" : "No" }}
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
+                      v-slot="props"
+                      v-if="simpleSettings['rattail.datasync.use_profile_settings']">
+        <a href="#"
+           class="grid-action"
+           @click.prevent="editProfile(props.row)">
+          % if request.use_oruga:
+              <span class="icon-text">
+                <o-icon icon="edit" />
+                <span>Edit</span>
+              </span>
+          % else:
+              <i class="fas fa-edit"></i>
+              Edit
+          % endif
+        </a>
+        &nbsp;
+        <a href="#"
+           class="grid-action has-text-danger"
+           @click.prevent="deleteProfile(props.row)">
+          % if request.use_oruga:
+              <span class="icon-text">
+                <o-icon icon="trash" />
+                <span>Delete</span>
+              </span>
+          % else:
+              <i class="fas fa-trash"></i>
+              Delete
+          % endif
+        </a>
+      </${b}-table-column>
+      <template #empty>
+        <section class="section">
+          <div class="content has-text-grey has-text-centered">
+            <p>
+              <b-icon
+                 pack="fas"
+                 icon="sad-tear"
+                 size="is-large">
+              </b-icon>
+            </p>
+            <p>Nothing here.</p>
+          </div>
+        </section>
+      </template>
+  </${b}-table>
+
+  <b-modal :active.sync="editProfileShowDialog">
+    <div class="card">
+      <div class="card-content">
+
+        <b-field grouped>
+          
+          <b-field label="Watcher Key"
+                   :type="editingProfileKey ? null : 'is-danger'">
+            <b-input v-model="editingProfileKey"
+                     ref="watcherKeyInput">
+            </b-input>
+          </b-field>
+
+          <b-field label="Default Runas User">
+            <b-input v-model="editingProfileWatcherDefaultRunas">
+            </b-input>
+          </b-field>
+
+        </b-field>
+
+        <b-field grouped expanded>
+
+          <b-field label="Watcher Spec" 
+                   :type="editingProfileWatcherSpec ? null : 'is-danger'"
+                   expanded>
+            <b-input v-model="editingProfileWatcherSpec" expanded>
+            </b-input>
+          </b-field>
+
+          <b-field label="DB Key">
+            <b-input v-model="editingProfileWatcherDBKey">
+            </b-input>
+          </b-field>
+
+        </b-field>
+
+        <b-field grouped>
+
+          <b-field label="Loop Delay (seconds)">
+            <b-input v-model="editingProfileWatcherDelay">
+            </b-input>
+          </b-field>
+
+          <b-field label="Attempts">
+            <b-input v-model="editingProfileWatcherRetryAttempts">
+            </b-input>
+          </b-field>
+
+          <b-field label="Retry Delay (seconds)">
+            <b-input v-model="editingProfileWatcherRetryDelay">
+            </b-input>
+          </b-field>
+
+          <b-field :label="`Kwargs (${'$'}{editingProfilePendingWatcherKwargs.length})`">
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="edit"
+                      :disabled="editingWatcherKwarg"
+                      @click="editingWatcherKwargs = !editingWatcherKwargs">
+              {{ editingWatcherKwargs ? "Stop Editing" : "Edit Kwargs" }}
+            </b-button>
+          </b-field>
+
+        </b-field>
+
+        <div v-show="editingWatcherKwargs"
+             style="display: flex; justify-content: end;">
+
+          <b-button type="is-primary"
+                    v-show="!editingWatcherKwarg"
+                    icon-pack="fas"
+                    icon-left="plus"
+                    @click="newWatcherKwarg()">
+            New Watcher Kwarg
+          </b-button>
+
+          <div v-show="editingWatcherKwarg">
+
+            <b-field grouped>
+
+              <b-field label="Key"
+                       :type="editingWatcherKwargKey ? null : 'is-danger'">
+                <b-input v-model="editingWatcherKwargKey"
+                         ref="watcherKwargKey">
+                </b-input>
+              </b-field>
+
+              <b-field label="Value"
+                       :type="editingWatcherKwargValue ? null : 'is-danger'">
+                <b-input v-model="editingWatcherKwargValue">
+                </b-input>
+              </b-field>
+
+            </b-field>
+
+            <b-field grouped>
+
+              <b-button @click="editingWatcherKwarg = null"
+                        class="control"
+                        >
+                Cancel
+              </b-button>
+
+              <b-button type="is-primary"
+                        @click="updateWatcherKwarg()"
+                        class="control">
+                Update Kwarg
+              </b-button>
+
+            </b-field>
+
+          </div>
+
+
+          <${b}-table :data="editingProfilePendingWatcherKwargs"
+                   style="margin-left: 1rem;">
+            <${b}-table-column field="key"
+                            label="Key"
+                            v-slot="props">
+              {{ props.row.key }}
+            </${b}-table-column>
+            <${b}-table-column field="value"
+                            label="Value"
+                            v-slot="props">
+              {{ props.row.value }}
+            </${b}-table-column>
+            <${b}-table-column label="Actions"
+                            v-slot="props">
+              <a href="#"
+                 @click.prevent="editProfileWatcherKwarg(props.row)">
+                % if request.use_oruga:
+                    <span class="icon-text">
+                      <o-icon icon="edit" />
+                      <span>Edit</span>
+                    </span>
+                % else:
+                    <i class="fas fa-edit"></i>
+                    Edit
+                % endif
+              </a>
+              &nbsp;
+              <a href="#"
+                 class="has-text-danger"
+                 @click.prevent="deleteProfileWatcherKwarg(props.row)">
+                % if request.use_oruga:
+                    <span class="icon-text">
+                      <o-icon icon="trash" />
+                      <span>Delete</span>
+                    </span>
+                % else:
+                    <i class="fas fa-trash"></i>
+                    Delete
+                % endif
+              </a>
+            </${b}-table-column>
+            <template #empty>
+              <section class="section">
+                <div class="content has-text-grey has-text-centered">
+                  <p>
+                    <b-icon
+                      pack="fas"
+                      icon="sad-tear"
+                      size="is-large">
+                    </b-icon>
+                  </p>
+                  <p>Nothing here.</p>
+                </div>
+              </section>
+            </template>
+          </${b}-table>
+
+        </div>
+
+        <div v-show="!editingWatcherKwargs"
+             style="display: flex;">
+
+          <div style="width: 40%;">
+
+            <b-field label="Watcher consumes its own changes"
+                     v-if="!editingProfilePendingConsumers.length">
+              <b-checkbox v-model="editingProfileWatcherConsumesSelf">
+                {{ editingProfileWatcherConsumesSelf ? "Yes" : "No" }}
+              </b-checkbox>
+            </b-field>
+
+            <${b}-table :data="editingProfilePendingConsumers"
+                     v-if="!editingProfileWatcherConsumesSelf"
+                     :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
+              <${b}-table-column field="key"
+                              label="Consumer"
+                              v-slot="props">
+                {{ props.row.key }}
+              </${b}-table-column>
+              <${b}-table-column style="white-space: nowrap;"
+                              v-slot="props">
+                {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }}
+              </${b}-table-column>
+              <${b}-table-column label="Actions"
+                              v-slot="props">
+                <a href="#"
+                   class="grid-action"
+                   @click.prevent="editProfileConsumer(props.row)">
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="edit" />
+                        <span>Edit</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-edit"></i>
+                      Edit
+                  % endif
+                </a>
+                &nbsp;
+                <a href="#"
+                   class="grid-action has-text-danger"
+                   @click.prevent="deleteProfileConsumer(props.row)">
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="trash" />
+                        <span>Delete</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-trash"></i>
+                      Delete
+                  % endif
+                </a>
+              </${b}-table-column>
+              <template #empty>
+                <section class="section">
+                  <div class="content has-text-grey has-text-centered">
+                    <p>
+                      <b-icon
+                        pack="fas"
+                        icon="sad-tear"
+                        size="is-large">
+                      </b-icon>
+                    </p>
+                    <p>Nothing here.</p>
+                  </div>
+                </section>
+              </template>
+            </${b}-table>
+
+          </div>
+
+          <div v-show="!editingConsumer && !editingProfileWatcherConsumesSelf"
+               style="padding-left: 1rem;">
+            <b-button type="is-primary"
+                      @click="newConsumer()"
+                      icon-pack="fas"
+                      icon-left="plus">
+              New Consumer
+            </b-button>
+          </div>
+
+          <div v-show="editingConsumer"
+               style="flex-grow: 1; padding-left: 1rem; padding-right: 1rem;">
+            
+            <b-field grouped>
+
+              <b-field label="Consumer Key"
+                       :type="editingConsumerKey ? null : 'is-danger'">
+                <b-input v-model="editingConsumerKey"
+                         ref="consumerKeyInput">
+                </b-input>
+              </b-field>
+
+              <b-field label="Runas User">
+                <b-input v-model="editingConsumerRunas">
+                </b-input>
+              </b-field>
+
+            </b-field>
+
+            <b-field grouped>
+
+              <b-field label="Consumer Spec" 
+                       expanded
+                       :type="editingConsumerSpec ? null : 'is-danger'"
+                       >
+                <b-input v-model="editingConsumerSpec">
+                </b-input>
+              </b-field>
+
+              <b-field label="DB Key">
+                <b-input v-model="editingConsumerDBKey">
+                </b-input>
+              </b-field>
+
+            </b-field>
+
+            <b-field grouped>
+
+              <b-field label="Loop Delay">
+                <b-input v-model="editingConsumerDelay"
+                         style="width: 8rem;">
+                </b-input>
+              </b-field>
+
+              <b-field label="Attempts">
+                <b-input v-model="editingConsumerRetryAttempts"
+                         style="width: 8rem;">
+                </b-input>
+              </b-field>
+
+              <b-field label="Retry Delay">
+                <b-input v-model="editingConsumerRetryDelay"
+                         style="width: 8rem;">
+                </b-input>
+              </b-field>
+
+            </b-field>
+
+            <b-field grouped>
+
+              <b-button @click="editingConsumer = null"
+                        class="control">
+                Cancel
+              </b-button>
+
+              <b-button type="is-primary"
+                        @click="updateConsumer()"
+                        :disabled="updateConsumerDisabled"
+                        class="control">
+                Update Consumer
+              </b-button>
+
+              <b-field label="Enabled" horizontal
+                       style="margin-left: 2rem;">
+                <b-checkbox v-model="editingConsumerEnabled">
+                  {{ editingConsumerEnabled ? "Yes" : "No" }}
+                </b-checkbox>
+              </b-field>
+
+            </b-field>
+          </div>
+        </div>
+
+        <br />
+        <b-field grouped>
+
+          <b-button @click="editProfileShowDialog = false"
+                    class="control">
+            Cancel
+          </b-button>
+
+          <b-button type="is-primary"
+                    class="control"
+                    @click="updateProfile()"
+                    :disabled="updateProfileDisabled">
+            Update Profile
+          </b-button>
+
+          <b-field label="Enabled" horizontal
+                   style="margin-left: 2rem;">
+            <b-checkbox v-model="editingProfileEnabled">
+              {{ editingProfileEnabled ? "Yes" : "No" }}
+            </b-checkbox>
+          </b-field>
+
+        </b-field>
+
+      </div>
+    </div>
+  </b-modal>
+
+  <br />
+
+  <h3 class="is-size-3">Misc.</h3>
+
+  <b-field label="Supervisor Process Name"
+           message="This should be the complete name, including group - e.g. poser:poser_datasync"
+           expanded>
+    <b-input name="rattail.datasync.supervisor_process_name"
+             v-model="simpleSettings['rattail.datasync.supervisor_process_name']"
+             @input="settingsNeedSaved = true"
+             expanded>
+    </b-input>
+  </b-field>
+
+  <b-field label="Consumer Batch Size"
+           message="Max number of changes to be consumed at once."
+           expanded>
+    <numeric-input name="rattail.datasync.batch_size_limit"
+                   v-model="simpleSettings['rattail.datasync.batch_size_limit']"
+                   @input="settingsNeedSaved = true" />
+  </b-field>
+
+  <h3 class="is-size-3">Legacy</h3>
+  <b-field label="Restart Command"
+           message="This will run as '${system_user}' system user - please configure sudoers as needed.  Typical command is like:  sudo supervisorctl restart poser:poser_datasync"
+           expanded>
+    <b-input name="tailbone.datasync.restart"
+             v-model="simpleSettings['tailbone.datasync.restart']"
+             @input="settingsNeedSaved = true"
+             expanded>
+    </b-input>
+  </b-field>
+
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.showConfigFilesNote = false
+    ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
+    ThisPageData.showDisabledProfiles = false
+
+    ThisPageData.editProfileShowDialog = false
+    ThisPageData.editingProfile = null
+    ThisPageData.editingProfileKey = null
+    ThisPageData.editingProfileWatcherSpec = null
+    ThisPageData.editingProfileWatcherDBKey = null
+    ThisPageData.editingProfileWatcherDelay = 1
+    ThisPageData.editingProfileWatcherRetryAttempts = 1
+    ThisPageData.editingProfileWatcherRetryDelay = 1
+    ThisPageData.editingProfileWatcherDefaultRunas = null
+    ThisPageData.editingProfileWatcherConsumesSelf = false
+    ThisPageData.editingProfilePendingConsumers = []
+    ThisPageData.editingProfileEnabled = true
+
+    ThisPageData.editingConsumer = null
+    ThisPageData.editingConsumerKey = null
+    ThisPageData.editingConsumerSpec = null
+    ThisPageData.editingConsumerDBKey = null
+    ThisPageData.editingConsumerDelay = 1
+    ThisPageData.editingConsumerRetryAttempts = 1
+    ThisPageData.editingConsumerRetryDelay = 1
+    ThisPageData.editingConsumerRunas = null
+    ThisPageData.editingConsumerEnabled = true
+
+    ThisPage.computed.updateConsumerDisabled = function() {
+        if (!this.editingConsumerKey) {
+            return true
+        }
+        if (!this.editingConsumerSpec) {
+            return true
+        }
+        return false
+    }
+
+    ThisPage.computed.updateProfileDisabled = function() {
+        if (this.editingConsumer) {
+            return true
+        }
+        if (!this.editingProfileKey) {
+            return true
+        }
+        if (!this.editingProfileWatcherSpec) {
+            return true
+        }
+        return false
+    }
+
+    ThisPage.methods.toggleDisabledProfiles = function() {
+        this.showDisabledProfiles = !this.showDisabledProfiles
+    }
+
+    ThisPage.methods.getWatcherRowClass = function(row, i) {
+        if (!row.enabled) {
+            if (!this.showDisabledProfiles) {
+                return 'invisible-watcher'
+            }
+            return 'has-background-warning'
+        }
+    }
+
+    ThisPage.methods.consumerShortList = function(row) {
+        let keys = []
+        if (row.watcher_consumes_self) {
+            keys.push('self (watcher)')
+        } else {
+            for (let consumer of row.consumers_data) {
+                if (consumer.enabled) {
+                    keys.push(consumer.key)
+                }
+            }
+        }
+        return keys.join(', ')
+    }
+
+    ThisPage.methods.newProfile = function() {
+        this.editingProfile = {watcher_kwargs_data: []}
+        this.editingConsumer = null
+        this.editingWatcherKwargs = false
+
+        this.editingProfileKey = null
+        this.editingProfileWatcherSpec = null
+        this.editingProfileWatcherDBKey = null
+        this.editingProfileWatcherDelay = 1
+        this.editingProfileWatcherRetryAttempts = 1
+        this.editingProfileWatcherRetryDelay = 1
+        this.editingProfileWatcherDefaultRunas = null
+        this.editingProfileWatcherConsumesSelf = false
+        this.editingProfileEnabled = true
+        this.editingProfilePendingConsumers = []
+        this.editingProfilePendingWatcherKwargs = []
+
+        this.editProfileShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.watcherKeyInput.focus()
+        })
+    }
+
+    ThisPage.methods.editProfile = function(row) {
+        this.editingProfile = row
+        this.editingConsumer = null
+        this.editingWatcherKwargs = false
+
+        this.editingProfileKey = row.key
+        this.editingProfileWatcherSpec = row.watcher_spec
+        this.editingProfileWatcherDBKey = row.watcher_dbkey
+        this.editingProfileWatcherDelay = row.watcher_delay
+        this.editingProfileWatcherRetryAttempts = row.watcher_retry_attempts
+        this.editingProfileWatcherRetryDelay = row.watcher_retry_delay
+        this.editingProfileWatcherDefaultRunas = row.watcher_default_runas
+        this.editingProfileWatcherConsumesSelf = row.watcher_consumes_self
+        this.editingProfileEnabled = row.enabled
+
+        this.editingProfilePendingWatcherKwargs = []
+        for (let kwarg of row.watcher_kwargs_data) {
+            let pending = {
+                original_key: kwarg.key,
+                key: kwarg.key,
+                value: kwarg.value,
+            }
+            this.editingProfilePendingWatcherKwargs.push(pending)
+        }
+
+        this.editingProfilePendingConsumers = []
+        for (let consumer of row.consumers_data) {
+            const pending = {
+                ...consumer,
+                original_key: consumer.key,
+            }
+            this.editingProfilePendingConsumers.push(pending)
+        }
+
+        this.editProfileShowDialog = true
+    }
+
+    ThisPageData.editingWatcherKwargs = false
+    ThisPageData.editingProfilePendingWatcherKwargs = []
+    ThisPageData.editingWatcherKwarg = null
+    ThisPageData.editingWatcherKwargKey = null
+    ThisPageData.editingWatcherKwargValue = null
+
+    ThisPage.methods.newWatcherKwarg = function() {
+        this.editingWatcherKwargKey = null
+        this.editingWatcherKwargValue = null
+        this.editingWatcherKwarg = {key: null, value: null}
+        this.$nextTick(() => {
+            this.$refs.watcherKwargKey.focus()
+        })
+    }
+
+    ThisPage.methods.editProfileWatcherKwarg = function(row) {
+        this.editingWatcherKwargKey = row.key
+        this.editingWatcherKwargValue = row.value
+        this.editingWatcherKwarg = row
+    }
+
+    ThisPage.methods.updateWatcherKwarg = function() {
+        let pending = this.editingWatcherKwarg
+        let isNew = !pending.key
+
+        pending.key = this.editingWatcherKwargKey
+        pending.value = this.editingWatcherKwargValue
+
+        if (isNew) {
+            this.editingProfilePendingWatcherKwargs.push(pending)
+        }
+
+        this.editingWatcherKwarg = null
+    }
+
+    ThisPage.methods.deleteProfileWatcherKwarg = function(row) {
+        let i = this.editingProfilePendingWatcherKwargs.indexOf(row)
+        this.editingProfilePendingWatcherKwargs.splice(i, 1)
+    }
+
+    ThisPage.methods.findConsumer = function(profileConsumers, key) {
+        for (const consumer of profileConsumers) {
+            if (consumer.key == key) {
+                return consumer
+            }
+        }
+    }
+
+    ThisPage.methods.updateProfile = function() {
+        const row = this.editingProfile
+
+        const newRow = !row.key
+        let originalProfile = null
+        if (newRow) {
+            row.consumers_data = []
+            this.profilesData.push(row)
+        } else {
+            originalProfile = this.findProfile(row)
+        }
+
+        row.key = this.editingProfileKey
+        row.watcher_spec = this.editingProfileWatcherSpec
+        row.watcher_dbkey = this.editingProfileWatcherDBKey
+        row.watcher_delay = this.editingProfileWatcherDelay
+        row.watcher_retry_attempts = this.editingProfileWatcherRetryAttempts
+        row.watcher_retry_delay = this.editingProfileWatcherRetryDelay
+        row.watcher_default_runas = this.editingProfileWatcherDefaultRunas
+        row.watcher_consumes_self = this.editingProfileWatcherConsumesSelf
+        row.enabled = this.editingProfileEnabled
+
+        // track which keys still belong (persistent)
+        let persistentWatcherKwargs = []
+
+        // transfer pending data to profile watcher kwargs
+        for (let pending of this.editingProfilePendingWatcherKwargs) {
+            persistentWatcherKwargs.push(pending.key)
+            if (pending.original_key) {
+                let kwarg = this.findOriginalWatcherKwarg(pending.original_key)
+                kwarg.key = pending.key
+                kwarg.value = pending.value
+            } else {
+                row.watcher_kwargs_data.push(pending)
+            }
+        }
+
+        // remove any kwargs not being persisted
+        let removeWatcherKwargs = []
+        for (let kwarg of row.watcher_kwargs_data) {
+            let i = persistentWatcherKwargs.indexOf(kwarg.key)
+            if (i < 0) {
+                removeWatcherKwargs.push(kwarg)
+            }
+        }
+        for (let kwarg of removeWatcherKwargs) {
+            let i = row.watcher_kwargs_data.indexOf(kwarg)
+            row.watcher_kwargs_data.splice(i, 1)
+        }
+
+        // track which keys still belong (persistent)
+        let persistentConsumers = []
+
+        // transfer pending data to profile consumers
+        for (let pending of this.editingProfilePendingConsumers) {
+            persistentConsumers.push(pending.key)
+            if (pending.original_key) {
+                const consumer = this.findConsumer(originalProfile.consumers_data,
+                                                   pending.original_key)
+                consumer.key = pending.key
+                consumer.consumer_spec = pending.consumer_spec
+                consumer.consumer_dbkey = pending.consumer_dbkey
+                consumer.consumer_delay = pending.consumer_delay
+                consumer.consumer_retry_attempts = pending.consumer_retry_attempts
+                consumer.consumer_retry_delay = pending.consumer_retry_delay
+                consumer.consumer_runas = pending.consumer_runas
+                consumer.enabled = pending.enabled
+            } else {
+                row.consumers_data.push(pending)
+            }
+        }
+
+        // remove any consumers not being persisted
+        let removeConsumers = []
+        for (let consumer of row.consumers_data) {
+            let i = persistentConsumers.indexOf(consumer.key)
+            if (i < 0) {
+                removeConsumers.push(consumer)
+            }
+        }
+        for (let consumer of removeConsumers) {
+            let i = row.consumers_data.indexOf(consumer)
+            row.consumers_data.splice(i, 1)
+        }
+
+        if (!newRow) {
+
+            // nb. must explicitly update the original data row;
+            // otherwise (with vue3) it will remain stale and
+            // submitting the form will keep same settings!
+            // TODO: this probably means i am doing something
+            // sloppy, but at least this hack fixes for now.
+            const profile = this.findProfile(row)
+            for (const key of Object.keys(row)) {
+                profile[key] = row[key]
+            }
+        }
+
+        this.settingsNeedSaved = true
+        this.editProfileShowDialog = false
+    }
+
+    ThisPage.methods.findProfile = function(row) {
+        for (const profile of this.profilesData) {
+            if (profile.key == row.key) {
+                return profile
+            }
+        }
+    }
+
+    ThisPage.methods.deleteProfile = function(row) {
+        if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) {
+            let i = this.profilesData.indexOf(row)
+            this.profilesData.splice(i, 1)
+            this.settingsNeedSaved = true
+        }
+    }
+
+    ThisPage.methods.newConsumer = function() {
+        this.editingConsumerKey = null
+        this.editingConsumerSpec = null
+        this.editingConsumerDBKey = null
+        this.editingConsumerDelay = 1
+        this.editingConsumerRetryAttempts = 1
+        this.editingConsumerRetryDelay = 1
+        this.editingConsumerRunas = null
+        this.editingConsumerEnabled = true
+        this.editingConsumer = {}
+        this.$nextTick(() => {
+            this.$refs.consumerKeyInput.focus()
+        })
+    }
+
+    ThisPage.methods.editProfileConsumer = function(row) {
+        this.editingConsumerKey = row.key
+        this.editingConsumerSpec = row.consumer_spec
+        this.editingConsumerDBKey = row.consumer_dbkey
+        this.editingConsumerDelay = row.consumer_delay
+        this.editingConsumerRetryAttempts = row.consumer_retry_attempts
+        this.editingConsumerRetryDelay = row.consumer_retry_delay
+        this.editingConsumerRunas = row.consumer_runas
+        this.editingConsumerEnabled = row.enabled
+        this.editingConsumer = row
+    }
+
+    ThisPage.methods.updateConsumer = function() {
+        const pending = this.findConsumer(
+            this.editingProfilePendingConsumers,
+            this.editingConsumer.key)
+        const isNew = !pending.key
+
+        pending.key = this.editingConsumerKey
+        pending.consumer_spec = this.editingConsumerSpec
+        pending.consumer_dbkey = this.editingConsumerDBKey
+        pending.consumer_delay = this.editingConsumerDelay
+        pending.consumer_retry_attempts = this.editingConsumerRetryAttempts
+        pending.consumer_retry_delay = this.editingConsumerRetryDelay
+        pending.consumer_runas = this.editingConsumerRunas
+        pending.enabled = this.editingConsumerEnabled
+
+        if (isNew) {
+            this.editingProfilePendingConsumers.push(pending)
+        }
+        this.editingConsumer = null
+    }
+
+    ThisPage.methods.deleteProfileConsumer = function(row) {
+        if (confirm("Are you sure you want to delete the '" + row.key + "' consumer?")) {
+            let i = this.editingProfilePendingConsumers.indexOf(row)
+            this.editingProfilePendingConsumers.splice(i, 1)
+        }
+    }
+
+    % if request.has_perm('datasync.restart'):
+        ThisPageData.restartingDatasync = false
+        ThisPageData.restartDatasyncFormButtonText = "Restart Datasync"
+        ThisPage.methods.restartDatasync = function(e) {
+            if (this.settingsNeedSaved) {
+                alert("You have unsaved changes.  Please save or undo them first.")
+                e.preventDefault()
+            }
+        }
+        ThisPage.methods.submitRestartDatasyncForm = function() {
+            this.restartingDatasync = true
+            this.restartDatasyncFormButtonText = "Restarting Datasync..."
+        }
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako
new file mode 100644
index 00000000..e14686f8
--- /dev/null
+++ b/tailbone/templates/datasync/status.mako
@@ -0,0 +1,174 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">${index_title}</%def>
+
+<%def name="content_title()"></%def>
+
+<%def name="page_content()">
+  % if expose_websockets and not supervisor_error:
+      <b-notification type="is-warning"
+                      :active="websocketBroken"
+                      :closable="false">
+        Server connection was broken - please refresh page to see accurate status!
+      </b-notification>
+  % endif
+  <b-field label="Supervisor Status">
+    <div style="display: flex;">
+
+      % if supervisor_error:
+          <pre class="has-background-warning">${supervisor_error}</pre>
+      % else:
+          <pre :class="(processInfo && processInfo.statename == 'RUNNING') ? 'has-background-success' : 'has-background-warning'">{{ processDescription }}</pre>
+      % endif
+
+      <div style="margin-left: 1rem;">
+        % if request.has_perm('datasync.restart'):
+            ${h.form(url('datasync.restart'), **{'@submit': 'restartProcess'})}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      native-type="submit"
+                      icon-pack="fas"
+                      icon-left="redo"
+                      :disabled="restartingProcess">
+              {{ restartingProcess ? "Working, please wait..." : "Restart Process" }}
+            </b-button>
+            ${h.end_form()}
+        % endif
+      </div>
+
+    </div>
+  </b-field>
+
+  <h3 class="is-size-3">Watcher Status</h3>
+
+    <${b}-table :data="watchers">
+      <${b}-table-column field="key"
+                      label="Watcher"
+                      v-slot="props">
+         {{ props.row.key }}
+      </${b}-table-column>
+      <${b}-table-column field="spec"
+                      label="Spec"
+                      v-slot="props">
+         {{ props.row.spec }}
+      </${b}-table-column>
+      <${b}-table-column field="dbkey"
+                      label="DB Key"
+                      v-slot="props">
+         {{ props.row.dbkey }}
+      </${b}-table-column>
+      <${b}-table-column field="delay"
+                      label="Delay"
+                      v-slot="props">
+         {{ props.row.delay }} second(s)
+      </${b}-table-column>
+      <${b}-table-column field="lastrun"
+                      label="Last Watched"
+                      v-slot="props">
+         <span v-html="props.row.lastrun"></span>
+      </${b}-table-column>
+      <${b}-table-column field="status"
+                      label="Status"
+                      v-slot="props">
+        <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
+          {{ props.row.status }}
+        </span>
+      </${b}-table-column>
+    </${b}-table>
+
+  <h3 class="is-size-3">Consumer Status</h3>
+
+    <${b}-table :data="consumers">
+      <${b}-table-column field="key"
+                      label="Consumer"
+                      v-slot="props">
+         {{ props.row.key }}
+      </${b}-table-column>
+      <${b}-table-column field="spec"
+                      label="Spec"
+                      v-slot="props">
+         {{ props.row.spec }}
+      </${b}-table-column>
+      <${b}-table-column field="dbkey"
+                      label="DB Key"
+                      v-slot="props">
+         {{ props.row.dbkey }}
+      </${b}-table-column>
+      <${b}-table-column field="delay"
+                      label="Delay"
+                      v-slot="props">
+         {{ props.row.delay }} second(s)
+      </${b}-table-column>
+      <${b}-table-column field="changes"
+                      label="Pending Changes"
+                      v-slot="props">
+         {{ props.row.changes }}
+      </${b}-table-column>
+      <${b}-table-column field="status"
+                      label="Status"
+                      v-slot="props">
+        <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
+          {{ props.row.status }}
+        </span>
+      </${b}-table-column>
+    </${b}-table>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.processInfo = ${json.dumps(process_info)|n}
+
+    ThisPage.computed.processDescription = function() {
+        let info = this.processInfo
+        if (info) {
+            return `${'$'}{info.group}:${'$'}{info.name}    ${'$'}{info.statename}    ${'$'}{info.description}`
+        } else {
+            return "NO PROCESS INFO AVAILABLE"
+        }
+    }
+
+    ThisPageData.restartingProcess = false
+    ThisPageData.watchers = ${json.dumps(watcher_data)|n}
+    ThisPageData.consumers = ${json.dumps(consumer_data)|n}
+
+    ThisPage.methods.restartProcess = function() {
+        this.restartingProcess = true
+    }
+
+    % if expose_websockets and not supervisor_error:
+
+        ThisPageData.ws = null
+        ThisPageData.websocketBroken = false
+
+        ThisPage.mounted = function() {
+
+            ## TODO: should be a cleaner way to get this url?
+            let url = '${url('ws.datasync.status')}'
+            url = url.replace(/^http(s?):/, 'ws$1:')
+
+            this.ws = new WebSocket(url)
+            let that = this
+
+            this.ws.onclose = (event) => {
+                // websocket closing means 1 of 2 things:
+                // - user navigated away from page intentionally
+                // - server connection was broken somehow
+                // only one of those is "bad" and we only want to
+                // display warning in 2nd case.  so we simply use a
+                // brief delay to "rule out" the 1st scenario
+                setTimeout(() => { that.websocketBroken = true },
+                           3000)
+            }
+
+            this.ws.onmessage = (event) => {
+                that.processInfo = JSON.parse(event.data)
+            }
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt
index 1533cc2b..7a15c7f0 100644
--- a/tailbone/templates/deform/autocomplete_jquery.pt
+++ b/tailbone/templates/deform/autocomplete_jquery.pt
@@ -3,114 +3,20 @@
                  oid oid|field.oid;
                  field_display field_display;
                  style style|field.widget.style;
-                 url url|field.widget.service_url;
-                 use_buefy use_buefy|0;"
+                 url url|field.widget.service_url;"
      tal:omit-tag="">
 
-  <div tal:condition="not use_buefy"
-       id="${oid}-container"
-       class="autocomplete-container">
-  <input type="hidden"
-         name="${name}"
-         id="${oid}"
-         value="${cstruct}" />
-
-  <input type="text"
-         name="${oid}-textbox"
-         id="${oid}-textbox"
-         value="${field_display}"
-         class="autocomplete-textbox"
-         style="display: none;" />
-
-  <div id="${oid}-display"
-       class="autocomplete-display"
-       style="display: none;">
-
-    <span>${field_display or ''}</span>
-    <button type="button" id="${oid}-change" class="autocomplete-change">Change</button>
-
-  </div>
-
-  <script type="text/javascript">
-      deform.addCallback(
-        '${oid}',
-        function (oid) {
-
-            $('#' + oid + '-textbox').autocomplete(${options});
-
-            $('#' + oid + '-textbox').on('autocompleteselect', function (event, ui) {
-                $('#' + oid).val(ui.item.value);
-                $('#' + oid + '-display span:first').text(ui.item.label);
-                $('#' + oid + '-textbox').hide();
-                $('#' + oid + '-display').show();
-                $('#' + oid + '-textbox').trigger('autocompletevalueselected',
-                                                  [ui.item.value, ui.item.label]);
-                return false;
-            });
-
-            $('#' + oid + '-change').click(function() {
-                $('#' + oid).val('');
-                $('#' + oid + '-display').hide();
-                with ($('#' + oid + '-textbox')) {
-                    val('');
-                    show();
-                    focus();
-                }
-                $('#' + oid + '-textbox').trigger('autocompletevaluecleared');
-            });
-
-        }
-      );
-  </script>
-
-  <script tal:condition="cleared_callback" type="text/javascript">
-      deform.addCallback(
-        '${oid}',
-        function (oid) {
-            $('#' + oid + '-textbox').on('autocompletevaluecleared', function() {
-                ${cleared_callback}();
-            });
-        }
-      );
-  </script>
-
-  <script tal:condition="selected_callback" type="text/javascript">
-      deform.addCallback(
-        '${oid}',
-        function (oid) {
-            $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) {
-                ${selected_callback}(uuid, label);
-            });
-        }
-      );
-  </script>
-
-  <script tal:condition="cstruct" type="text/javascript">
-      deform.addCallback(
-        '${oid}',
-        function (oid) {
-            $('#' + oid + '-display').show();
-        }
-      );
-  </script>
-
-  <script tal:condition="not cstruct" type="text/javascript">
-      deform.addCallback(
-        '${oid}',
-        function (oid) {
-            $('#' + oid + '-textbox').show();
-        }
-      );
-  </script>
-  </div>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + name;"
+  <div tal:define="vmodel vmodel|'field_model_' + name;"
        tal:omit-tag="">
     <tailbone-autocomplete name="${name}"
+                           ref="${ref}"
                            service-url="${url}"
                            v-model="${vmodel}"
-                           initial-label="${field_display}">
+                           initial-label="${field_display}"
+                           tal:attributes=":assigned-label assigned_label or 'null';
+                                           @input input_handler|input_callback|'';
+                                           @new-label new_label_callback|'';">
+
     </tailbone-autocomplete>
   </div>
 
diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt
index 05e06d50..b30d1d63 100644
--- a/tailbone/templates/deform/cases_units.pt
+++ b/tailbone/templates/deform/cases_units.pt
@@ -1,29 +1,31 @@
 <!--! -*- mode: html; -*- -->
 <div tal:define="oid oid|field.oid;
+                 name name|field.name;
                  css_class css_class|field.widget.css_class;
                  style style|field.widget.style;"
      i18n:domain="deform"
      tal:omit-tag="">
-  ${field.start_mapping()}
-  <div>
-    <input type="text" name="cases" value="${cases}"
-           tal:attributes="style style;
-                           class string: form-control ${css_class or ''};
-                           cases_attributes|field.widget.cases_attributes|{};"
-           placeholder="cases"
-           autocomplete="off"
-           id="${oid}-cases"/>
-    Cases
+
+  <div tal:define="vmodel vmodel|'field_model_' + name;"
+       tal:omit-tag="">
+
+    ${field.start_mapping()}
+
+    <b-field label="Cases">
+      <b-input name="cases" autocomplete="off"
+               tal:attributes="v-model string: ${vmodel + '.cases'};
+                               cases_attributes|field.widget.cases_attributes|{};">
+      </b-input>
+    </b-field>
+
+    <b-field label="Units">
+      <b-input name="units" autocomplete="off"
+               tal:attributes="v-model string: ${vmodel + '.units'};
+                               units_attributes|field.widget.units_attributes|{};">
+      </b-input>
+    </b-field>
+
+    ${field.end_mapping()}
   </div>
-  <div>
-    <input type="text" name="units" value="${units}"
-           tal:attributes="class string: form-control ${css_class or ''};
-                           style style;
-                           units_attributes|field.widget.units_attributes|{};"
-           placeholder="units"
-           autocomplete="off"
-           id="${oid}-units"/>
-    Units
-  </div>
-  ${field.end_mapping()}
+
 </div>
diff --git a/tailbone/templates/deform/checkbox.pt b/tailbone/templates/deform/checkbox.pt
index b00ced03..408fa1cb 100644
--- a/tailbone/templates/deform/checkbox.pt
+++ b/tailbone/templates/deform/checkbox.pt
@@ -2,21 +2,10 @@
                  true_val true_val|field.widget.true_val;
                  css_class css_class|field.widget.css_class;
                  style style|field.widget.style;
-                 oid oid|field.oid;
-                 use_buefy use_buefy|0;"
+                 oid oid|field.oid;"
      tal:omit-tag="">
 
-  <div tal:condition="not use_buefy" class="checkbox">
-    <input type="checkbox"
-           name="${name}" value="${true_val}"
-           id="${oid}"
-           tal:attributes="checked cstruct == true_val;
-                           class css_class;
-                           style style;" />
-  </div>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + name;">
+  <div tal:define="vmodel vmodel|'field_model_' + name;">
     <b-checkbox name="${name}"
                 v-model="${vmodel}"
                 native-value="${true_val}">
diff --git a/tailbone/templates/deform/checkbox_dynamic.pt b/tailbone/templates/deform/checkbox_dynamic.pt
index 45ebb576..c5d4d795 100644
--- a/tailbone/templates/deform/checkbox_dynamic.pt
+++ b/tailbone/templates/deform/checkbox_dynamic.pt
@@ -1,4 +1,4 @@
-<!-- -*- mode: html; -*- -->
+<!--! -*- mode: html; -*- -->
 <b-checkbox tal:define="name name|field.name;
                         oid oid|field.oid;
                         true_val true_val|field.widget.true_val;
diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt
index 43657045..2121f01d 100644
--- a/tailbone/templates/deform/checked_password.pt
+++ b/tailbone/templates/deform/checked_password.pt
@@ -1,42 +1,15 @@
 <div i18n:domain="deform" tal:omit-tag=""
       tal:define="oid oid|field.oid;
                   name name|field.name;
+                  vmodel vmodel|'field_model_' + name;
                   css_class css_class|field.widget.css_class;
-                  style style|field.widget.style;
-                  use_buefy use_buefy|0;">
+                  style style|field.widget.style;">
 
-  <div tal:condition="not use_buefy" tal:omit-tag="">
-    ${field.start_mapping()}
-    <div>
-      <input type="password"
-             name="${name}"
-             value="${field.widget.redisplay and cstruct or ''}"
-             tal:attributes="class string: form-control ${css_class or ''};
-                             style style;
-                             attributes|field.widget.attributes|{};"
-             id="${oid}"
-             i18n:attributes="placeholder"
-             placeholder="Password"/>
-    </div>
-    <div>
-      <input type="password"
-             name="${name}-confirm"
-             value="${field.widget.redisplay and confirm or ''}"
-             tal:attributes="class string: form-control ${css_class or ''};
-                             style style;
-                             confirm_attributes|field.widget.confirm_attributes|{};"
-             id="${oid}-confirm"
-             i18n:attributes="placeholder"
-             placeholder="Confirm Password"/>
-    </div>
-    ${field.end_mapping()}
-  </div>
-
-  <div tal:condition="use_buefy">
+  <div>
     ${field.start_mapping()}
     <b-input type="password"
              name="${name}"
-             value="${field.widget.redisplay and cstruct or ''}"
+             v-model="${vmodel}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              attributes|field.widget.attributes|{};"
@@ -46,7 +19,6 @@
     </b-input>
     <b-input type="password"
              name="${name}-confirm"
-             value="${field.widget.redisplay and confirm or ''}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              confirm_attributes|field.widget.confirm_attributes|{};"
diff --git a/tailbone/templates/deform/date_jquery.pt b/tailbone/templates/deform/date_jquery.pt
index 08176aee..c55021b9 100644
--- a/tailbone/templates/deform/date_jquery.pt
+++ b/tailbone/templates/deform/date_jquery.pt
@@ -1,42 +1,12 @@
-<!-- -*- mode: html; -*- -->
+<!--! -*- mode: html; -*- -->
 <div tal:define="css_class css_class|field.widget.css_class;
                  oid oid|field.oid;
                  field_name field_name|field.name;
                  style style|field.widget.style;
-                 type_name type_name|field.widget.type_name;
-                 use_buefy use_buefy|0;"
+                 type_name type_name|field.widget.type_name;"
       tal:omit-tag="">
 
-  <div tal:condition="not use_buefy" tal:omit-tag="">
-    ${field.start_mapping()}
-    <input type="${type_name}"
-           name="date"
-           value="${cstruct}"
-
-           tal:attributes="class string: ${css_class or ''} form-control;
-                           style style"
-           id="${oid}"/>
-    ${field.end_mapping()}
-    <script type="text/javascript">
-     deform.addCallback(
-      '${oid}',
-       function deform_cb(oid) {
-           $('#' + oid).datepicker(${options_json});
-       }
-     );
-    </script>
-    <script tal:condition="selected_callback" type="text/javascript">
-        deform.addCallback(
-          '${oid}',
-          function (oid) {
-              $('#' + oid).datepicker('option', 'onSelect', ${selected_callback});
-          }
-        );
-    </script>
-  </div>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + field_name;">
+  <div tal:define="vmodel vmodel|'field_model_' + field_name;">
     ${field.start_mapping()}
     <tailbone-datepicker name="date"
                          id="${oid}"
diff --git a/tailbone/templates/deform/datetime_falafel.pt b/tailbone/templates/deform/datetime_falafel.pt
new file mode 100644
index 00000000..17cfe6c3
--- /dev/null
+++ b/tailbone/templates/deform/datetime_falafel.pt
@@ -0,0 +1,23 @@
+<div tal:omit-tag=""
+     tal:define="name name|field.name;
+                 vmodel vmodel|'field_model_' + name;">
+
+  <b-field grouped>
+    ${field.start_mapping()}
+
+    <b-field label="Date">
+      <tailbone-datepicker name="date"
+                           v-model="${vmodel}.date">
+      </tailbone-datepicker>
+    </b-field>
+
+    <b-field label="Time">
+      <tailbone-timepicker name="time"
+                           v-model="${vmodel}.time">
+      </tailbone-timepicker>
+    </b-field>
+
+    ${field.end_mapping()}
+  </b-field>
+
+</div>
diff --git a/tailbone/templates/deform/file_upload.pt b/tailbone/templates/deform/file_upload.pt
index 1471199b..af78eaf9 100644
--- a/tailbone/templates/deform/file_upload.pt
+++ b/tailbone/templates/deform/file_upload.pt
@@ -1,32 +1,15 @@
-<!-- -*- mode: html; -*- -->
+<!--! -*- mode: html; -*- -->
 <tal:block tal:define="oid oid|field.oid;
                        css_class css_class|field.widget.css_class;
                        style style|field.widget.style;
                        field_name field_name|field.name;
-                       use_buefy use_buefy|0;">
+                       use_oruga use_oruga;">
 
-  <div tal:condition="not use_buefy" tal:omit-tag="">
+  <div tal:define="vmodel vmodel|'field_model_' + field_name;">
     ${field.start_mapping()}
-    <input type="file" name="upload" id="${oid}"
-           tal:attributes="style style;
-                           accept accept|field.widget.accept;
-                           data-filename cstruct.get('filename');
-                           attributes|field.widget.attributes|{};"/>
-    <input tal:define="uid cstruct.get('uid')"
-           tal:condition="uid"
-           type="hidden" name="uid" value="${uid}"/>
-    ${field.end_mapping()}
-    <script type="text/javascript">
-      deform.addCallback('${oid}', function (oid) {
-        $('#' + oid).upload();
-      });
-    </script>
-  </div>
 
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + field_name;">
-    ${field.start_mapping()}
-    <b-field class="file">
+    <b-field class="file"
+             tal:condition="not use_oruga">
       <b-upload name="upload"
                 v-model="${vmodel}">
         <a class="button is-primary">
@@ -38,6 +21,23 @@
         {{ ${vmodel}.name }}
       </span>
     </b-field>
+
+    <o-field class="file"
+             tal:condition="use_oruga">
+      <o-upload name="upload"
+                v-slot="{ onclick }"
+                v-model="${vmodel}">
+        <o-button variant="primary"
+                  @click="onclick">
+          <o-icon icon="upload" />
+          <span>Click to upload</span>
+        </o-button>
+      </o-upload>
+      <span class="file-name" v-if="${vmodel}">
+        {{ ${vmodel}.name }}
+      </span>
+    </o-field>
+
     ${field.end_mapping()}
   </div>
 
diff --git a/tailbone/templates/deform/message_recipients_buefy.pt b/tailbone/templates/deform/message_recipients.pt
similarity index 100%
rename from tailbone/templates/deform/message_recipients_buefy.pt
rename to tailbone/templates/deform/message_recipients.pt
diff --git a/tailbone/templates/deform/multi_file_upload.pt b/tailbone/templates/deform/multi_file_upload.pt
new file mode 100644
index 00000000..f94e59c8
--- /dev/null
+++ b/tailbone/templates/deform/multi_file_upload.pt
@@ -0,0 +1,7 @@
+<tal:block tal:define="field_name field_name|field.name;
+                       vmodel vmodel|'field_model_' + field_name;">
+  ${field.start_sequence()}
+  <multi-file-upload v-model="${vmodel}">
+  </multi-file-upload>
+  ${field.end_sequence()}
+</tal:block>
diff --git a/tailbone/templates/deform/password.pt b/tailbone/templates/deform/password.pt
index 9ad77d1b..b74d763a 100644
--- a/tailbone/templates/deform/password.pt
+++ b/tailbone/templates/deform/password.pt
@@ -3,25 +3,14 @@
                   oid oid|field.oid;
                   mask mask|field.widget.mask;
                   mask_placeholder mask_placeholder|field.widget.mask_placeholder;
-                  style style|field.widget.style;
-                  use_buefy use_buefy|0;"
+                  style style|field.widget.style;"
       tal:omit-tag="">
-
-  <input tal:condition="not use_buefy"
-         type="password" 
-         name="${name}" 
-         value="${field.widget.redisplay and cstruct or ''}"
-         tal:attributes="style style;
-                         class string: form-control ${css_class or ''};
-                         attributes|field.widget.attributes|{};"
-         id="${oid}" />
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + name;"
+  <div tal:define="vmodel vmodel|'field_model_' + name;"
        tal:omit-tag="">
     <b-input name="${name}"
              v-model="${vmodel}"
-             type="password">
+             type="password"
+             tal:attributes="attributes|field.widget.attributes|{};">
     </b-input>
   </div>
 </span>
diff --git a/tailbone/templates/deform/percentinput.pt b/tailbone/templates/deform/percentinput.pt
index 59b15341..d76e5848 100644
--- a/tailbone/templates/deform/percentinput.pt
+++ b/tailbone/templates/deform/percentinput.pt
@@ -1,25 +1,18 @@
 <span tal:define="name name|field.name;
                   css_class css_class|field.widget.css_class;
                   oid oid|field.oid;
+                  field_name field_name|field.name;
                   mask mask|field.widget.mask;
                   mask_placeholder mask_placeholder|field.widget.mask_placeholder;
                   style style|field.widget.style;
-                  autocomplete autocomplete|field.widget.autocomplete|'off';
-"
+                  autocomplete autocomplete|field.widget.autocomplete|'off';"
       tal:omit-tag="">
-    <input type="text" name="${name}" value="${cstruct}" 
-           tal:attributes="class string: form-control ${css_class or ''};
-                           style style;
-                           autocomplete autocomplete;
-                           "
-           id="${oid}"/>
-    %
-    <script tal:condition="mask" type="text/javascript">
-      deform.addCallback(
-         '${oid}',
-         function (oid) {
-            $("#" + oid).mask("${mask}", 
-                 {placeholder:"${mask_placeholder}"});
-         });
-    </script>
+
+  <div tal:define="vmodel vmodel|'field_model_' + field_name;">
+    <!-- TODO: need to handle mask somehow? -->
+    <b-input name="${field_name}"
+             id="${oid}"
+             v-model="${vmodel}">
+    </b-input>
+  </div>
 </span>
diff --git a/tailbone/templates/deform/permissions.pt b/tailbone/templates/deform/permissions.pt
index f5cbeef4..b32f36ea 100644
--- a/tailbone/templates/deform/permissions.pt
+++ b/tailbone/templates/deform/permissions.pt
@@ -1,41 +1,8 @@
 <div tal:define="oid oid|field.oid;
-                 true_val true_val|field.widget.true_val;
-                 use_buefy use_buefy|0;"
+                 true_val true_val|field.widget.true_val;"
      tal:omit-tag="">
 
-  <div tal:condition="not use_buefy"
-       tal:omit-tag="">
-  ${field.start_mapping()}
-
-  <div class="permissions-outer">
-
-  <tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
-    <div tal:define="perms permissions[groupkey]['perms'];"
-         class="permissions-group">
-      <p class="group-label">${permissions[groupkey]['label']}</p>
-
-      <tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
-        <div class="perm">
-          <label>
-            <input type="checkbox"
-                   name="${key}"
-                   id="${oid}-${key}"
-                   value="${true_val}"
-                   tal:attributes="checked python:field.widget.get_checked_value(cstruct, key);" />
-            ${perms[key]['label']}
-          </label>
-        </div>
-      </tal:loop>
-
-    </div>
-  </tal:loop>
-
-  </div>
-
-  ${field.end_mapping()}
-  </div>
-
-  <div tal:condition="use_buefy">
+  <div>
     ${field.start_mapping()}
 
     <div class="level">
diff --git a/tailbone/templates/deform/problem_report_days.pt b/tailbone/templates/deform/problem_report_days.pt
new file mode 100644
index 00000000..ff3dd70c
--- /dev/null
+++ b/tailbone/templates/deform/problem_report_days.pt
@@ -0,0 +1,17 @@
+<div tal:define="name name|field.name;"
+     tal:omit-tag="">
+  <div tal:define="vmodel vmodel|'field_model_' + name;"
+       tal:omit-tag="">
+    <b-field grouped>
+      <input type="hidden" name="${name}"
+             tal:attributes=":value 'JSON.stringify('+vmodel+')';" />
+      <b-checkbox v-model="${vmodel}.day0">${day_labels[0]['abbr']}</b-checkbox>
+      <b-checkbox v-model="${vmodel}.day1">${day_labels[1]['abbr']}</b-checkbox>
+      <b-checkbox v-model="${vmodel}.day2">${day_labels[2]['abbr']}</b-checkbox>
+      <b-checkbox v-model="${vmodel}.day3">${day_labels[3]['abbr']}</b-checkbox>
+      <b-checkbox v-model="${vmodel}.day4">${day_labels[4]['abbr']}</b-checkbox>
+      <b-checkbox v-model="${vmodel}.day5">${day_labels[5]['abbr']}</b-checkbox>
+      <b-checkbox v-model="${vmodel}.day6">${day_labels[6]['abbr']}</b-checkbox>
+    </b-field>
+  </div>
+</div>
diff --git a/tailbone/templates/deform/select.pt b/tailbone/templates/deform/select.pt
index 8bdc0c7d..b033a8e2 100644
--- a/tailbone/templates/deform/select.pt
+++ b/tailbone/templates/deform/select.pt
@@ -7,58 +7,10 @@
      unicode unicode|str;
      optgroup_class optgroup_class|field.widget.optgroup_class;
      multiple multiple|field.widget.multiple;
-     use_buefy use_buefy|0;
      input_handler input_handler|'';"
      tal:omit-tag="">
 
-  <div tal:condition="not use_buefy" tal:omit-tag="">
-  <input type="hidden" name="__start__" value="${name}:sequence"
-         tal:condition="multiple" />
-  <div class="select">
-  <select tal:attributes="
-          name name;
-          id oid;
-          class string: form-control ${css_class or ''};
-          multiple multiple;
-          size size;
-          style style;">
-    <tal:loop tal:repeat="item values">
-      <optgroup tal:condition="isinstance(item, optgroup_class)"
-                tal:attributes="label item.label">
-        <option tal:repeat="(value, description) item.options"
-                tal:attributes="
-                selected python:field.widget.get_select_value(cstruct, value);
-                class css_class;
-                label field.widget.long_label_generator and description;
-                value value"
-                tal:content="field.widget.long_label_generator and field.widget.long_label_generator(item.label, description) or description"/>
-      </optgroup>
-      <option tal:condition="not isinstance(item, optgroup_class)"
-              tal:attributes="
-              selected python:field.widget.get_select_value(cstruct, item[0]);
-              class css_class;
-              value item[0]">${item[1]}</option>
-    </tal:loop>
-  </select>
-  </div>
-  <input type="hidden" name="__end__" value="${name}:sequence"
-         tal:condition="multiple" />
-  <script tal:condition="not multiple" type="text/javascript">
-    deform.addCallback(
-      '${oid}',
-      function(oid) {
-          $('#' + oid).selectmenu();
-          $('#' + oid).on('selectmenuopen', function(event, ui) {
-              show_all_options($(this));
-          });
-      }
-    );
-  </script>
-  </div>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + name;"
-       >
+  <div tal:define="vmodel vmodel|'field_model_' + name;">
     <input type="hidden" name="__start__" value="${name}:sequence"
            tal:condition="multiple" />
     <b-select tal:attributes="name name;
@@ -66,9 +18,11 @@
                               placeholder '(please choose)';
                               class string: form-control ${css_class or ''};
                               :multiple str(multiple).lower();
+                              native-size size;
                               style style;
                               v-model vmodel;
-                              @input input_handler;">
+                              @input input_handler;
+                              attributes|field.widget.attributes|{};">
 
       <tal:loop tal:repeat="item values">
         <optgroup tal:condition="isinstance(item, optgroup_class)"
diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt
index 4ce3b01c..712830d1 100644
--- a/tailbone/templates/deform/select_dynamic.pt
+++ b/tailbone/templates/deform/select_dynamic.pt
@@ -1,4 +1,4 @@
-<!-- -*- mode: html; -*- -->
+<!--! -*- mode: html; -*- -->
 <div tal:define="
      name name|field.name;
      oid oid|field.oid;
@@ -20,12 +20,13 @@
                             size size;
                             style style;
                             v-model vmodel;
-                            @input input_handler;">
+                            @input input_handler;
+                            attributes|field.widget.attributes|{};">
 
     <option v-for="item in ${name}_options"
             tal:attributes=":key 'item.value';
                             :value 'item.value';">
-      {{ item.label }}
+      <span v-html="item.label"></span>
     </option>
 
   </b-select>
diff --git a/tailbone/templates/deform/textarea.pt b/tailbone/templates/deform/textarea.pt
index 25583b4e..bb9b6c84 100644
--- a/tailbone/templates/deform/textarea.pt
+++ b/tailbone/templates/deform/textarea.pt
@@ -3,25 +3,14 @@
                  css_class css_class|field.widget.css_class;
                  oid oid|field.oid;
                  name name|field.name;
-                 style style|field.widget.style;
-                 use_buefy use_buefy|0;"
+                 style style|field.widget.style;"
      tal:omit-tag="">
 
-  <div tal:condition="not use_buefy" tal:omit-tag="">
-    <textarea tal:attributes="rows rows;
-                              cols cols;
-                              class string: form-control ${css_class or ''};
-                              style style;
-                              attributes|field.widget.attributes|{};"
-              id="${oid}"
-              name="${name}">${cstruct}</textarea>
-  </div>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + name;">
+  <div tal:define="vmodel vmodel|'field_model_' + name;">
     <b-input type="textarea"
              name="${name}"
-             v-model="${vmodel}">
+             v-model="${vmodel}"
+             tal:attributes="attributes|field.widget.attributes|{};">
     </b-input>
   </div>
 </div>
diff --git a/tailbone/templates/deform/textinput.pt b/tailbone/templates/deform/textinput.pt
index 2e1c32ef..47621654 100644
--- a/tailbone/templates/deform/textinput.pt
+++ b/tailbone/templates/deform/textinput.pt
@@ -4,34 +4,16 @@
                   mask mask|field.widget.mask;
                   mask_placeholder mask_placeholder|field.widget.mask_placeholder;
                   style style|field.widget.style;
-                  use_buefy use_buefy|0;
                   placeholder placeholder|getattr(field.widget, 'placeholder', '');
                   autocomplete autocomplete|getattr(field.widget, 'autocomplete', 'on');"
       tal:omit-tag="">
-  <div tal:condition="not use_buefy" tal:omit-tag="">
-    <input type="text" name="${name}" value="${cstruct}" 
-           tal:attributes="class string: form-control ${css_class or ''};
-                           style style;
-                           attributes|field.widget.attributes|{};"
-           autocomplete="${autocomplete}"
-           id="${oid}"/>
-    <script tal:condition="mask" type="text/javascript">
-      deform.addCallback(
-         '${oid}',
-         function (oid) {
-            $("#" + oid).mask("${mask}", 
-                 {placeholder:"${mask_placeholder}"});
-         });
-    </script>
-  </div>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + name;"
+  <div tal:define="vmodel vmodel|'field_model_' + name;"
        tal:omit-tag="">
-    <b-input name="${name}"
-             v-model="${vmodel}"
-             placeholder="${placeholder}"
-             autocomplete="${autocomplete}">
+    <b-input tal:attributes="name name;
+                             v-model vmodel;
+                             placeholder placeholder;
+                             autocomplete autocomplete;
+                             attributes|field.widget.attributes|{};">
     </b-input>
   </div>
 </span>
diff --git a/tailbone/templates/deform/time_falafel.pt b/tailbone/templates/deform/time_falafel.pt
new file mode 100644
index 00000000..00ebc2f0
--- /dev/null
+++ b/tailbone/templates/deform/time_falafel.pt
@@ -0,0 +1,7 @@
+<div tal:omit-tag=""
+     tal:define="name name|field.name;
+                 vmodel vmodel|'field_model_' + name;">
+  <tailbone-timepicker name="${name}"
+                       v-model="${vmodel}">
+  </tailbone-timepicker>
+</div>
diff --git a/tailbone/templates/deform/time_jquery.pt b/tailbone/templates/deform/time_jquery.pt
index 4e8cdfe7..8fa6cbe7 100644
--- a/tailbone/templates/deform/time_jquery.pt
+++ b/tailbone/templates/deform/time_jquery.pt
@@ -1,35 +1,13 @@
-<!-- -*- mode: html; -*- -->
+<!--! -*- mode: html; -*- -->
 <span tal:define="size size|field.widget.size;
                   css_class css_class|field.widget.css_class;
                   oid oid|field.oid;
                   style style|field.widget.style|None;
                   type_name type_name|field.widget.type_name;
-                  field_name field_name|field.name;
-                  use_buefy use_buefy|0;"
+                  field_name field_name|field.name;"
       tal:omit-tag="">
 
-  <div tal:condition="not use_buefy" tal:omit-tag="">
-    ${field.start_mapping()}
-    <input type="${type_name}"
-           name="time"
-           value="${cstruct}"
-           tal:attributes="size size;
-                           class string: ${css_class or ''} form-control;
-                           style style"
-           id="${oid}"/>
-    ${field.end_mapping()}
-    <script type="text/javascript">
-      deform.addCallback(
-        '${oid}',
-        function(oid) {
-            $('#' + oid).timepicker(${options_json});
-        }
-      );
-    </script>
-  </div>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + field_name;">
+  <div tal:define="vmodel vmodel|'field_model_' + field_name;">
     ${field.start_mapping()}
     <tailbone-timepicker name="time"
                          id="${oid}"
diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako
index f3887af7..c5c39cbb 100644
--- a/tailbone/templates/departments/view.mako
+++ b/tailbone/templates/departments/view.mako
@@ -1,18 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="page_content()">
-  ${parent.page_content()}
-
-  <h2>Employees</h2>
-
-  % if employees:
-      <p>The following employees are assigned to this department:</p>
-      ${employees.render_grid()|n}
-  % else:
-      <p>No employees are assigned to this department.</p>
-  % endif
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n}
+  </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako
index 3e5ec99e..a78bd770 100644
--- a/tailbone/templates/diff.mako
+++ b/tailbone/templates/diff.mako
@@ -1,5 +1,5 @@
 ## -*- coding: utf-8; -*-
-<table class="diff dirty${' monospace' if diff.monospace else ''}">
+<table class="diff ${diff.nature} ${' monospace' if diff.monospace else ''}">
   <thead>
     <tr>
       % for column in diff.columns:
diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako
index 36eb0c12..f8372c88 100644
--- a/tailbone/templates/email-bounces/view.mako
+++ b/tailbone/templates/email-bounces/view.mako
@@ -1,59 +1,54 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-## TODO: this page still uses jQuery but should use Vue.js
-
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
-
-    function autosize_message(scrolldown) {
-        var msg = $('#message');
-        var height = $(window).height() - msg.offset().top - 50;
-        msg.height(height);
-        if (scrolldown) {
-            msg.animate({scrollTop: msg.get(0).scrollHeight - height}, 250);
-        }
-    }
-
-    $(function () {
-        autosize_message(true);
-        $('#message').focus();
-    });
-
-    $(window).resize(function() {
-        autosize_message(false);
-    });
-
-  </script>
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
-    #message {
+    .email-message-body {
         border: 1px solid #000000;
-        height: 400px;
-        overflow: auto;
-        padding: 4px;
+        margin-top: 2rem;
+        height: 500px;
     }
   </style>
 </%def>
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if not bounce.processed and request.has_perm('emailbounces.process'):
-      <li>${h.link_to("Mark this Email Bounce as Processed", url('emailbounces.process', uuid=bounce.uuid))}</li>
-  % elif bounce.processed and request.has_perm('emailbounces.unprocess'):
-      <li>${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounces.unprocess', uuid=bounce.uuid))}</li>
-  % endif
+<%def name="object_helpers()">
+  ${parent.object_helpers()}
+  <nav class="panel">
+    <p class="panel-heading">Processing</p>
+    <div class="panel-block">
+      <div class="display: flex; flex-align: column;">
+        % if bounce.processed:
+            <p class="block">
+              This bounce was processed
+              ${h.pretty_datetime(request.rattail_config, bounce.processed)}
+              by ${bounce.processed_by}
+            </p>
+            % if master.has_perm('unprocess'):
+                <once-button type="is-warning"
+                             tag="a" href="${url('emailbounces.unprocess', uuid=bounce.uuid)}"
+                             text="Mark this bounce as UN-processed">
+                </once-button>
+            % endif
+        % else:
+            <p class="block">
+              This bounce has NOT yet been processed.
+            </p>
+            % if master.has_perm('process'):
+                <once-button type="is-primary"
+                             tag="a" href="${url('emailbounces.process', uuid=bounce.uuid)}"
+                             text="Mark this bounce as Processed">
+                </once-button>
+            % endif
+        % endif
+      </div>
+    </div>
+  </nav>
 </%def>
 
-<%def name="page_content()">
-  ${parent.page_content()}
-  <pre id="message">
-  ${message}
-  </pre>
+<%def name="render_this_page()">
+  ${parent.render_this_page()}
+  <pre class="email-message-body">${message}</pre>
 </%def>
 
 
diff --git a/tailbone/templates/employees/configure.mako b/tailbone/templates/employees/configure.mako
new file mode 100644
index 00000000..dd01a006
--- /dev/null
+++ b/tailbone/templates/employees/configure.mako
@@ -0,0 +1,22 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">General</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If set, grid links are to Employee tab of Profile view.">
+      <b-checkbox name="rattail.employees.straight_to_profile"
+                  v-model="simpleSettings['rattail.employees.straight_to_profile']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Link directly to Profile when applicable
+      </b-checkbox>
+    </b-field>
+
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/feedback.mako b/tailbone/templates/feedback.mako
deleted file mode 100644
index e82a6068..00000000
--- a/tailbone/templates/feedback.mako
+++ /dev/null
@@ -1,57 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/base.mako" />
-
-<%def name="title()">User Feedback</%def>
-
-<%def name="head_tags()">
-  ${parent.head_tags()}
-  <style type="text/css">
-    .form p {
-        margin: 1em 0;
-    }
-    div.field-wrapper div.field input[type=text] {
-        width: 25em;
-    }
-    div.field-wrapper div.field textarea {
-        width: 50em;
-    }
-    div.buttons {
-        margin-left: 15em;
-    }
-  </style>
-</%def>
-
-<div class="form">
-  ${form.begin()}
-  ${form.csrf_token()}
-  ${form.hidden('user', value=request.user.uuid if request.user else None)}
-
-  <p>
-    Questions, suggestions, comments, complaints, etc. regarding this website
-    are welcome and may be submitted below.
-  </p>
-  <p>
-    Messages will be delivered to the local IT department, and possibly others.
-  </p>
-
-##   % if error:
-##       <div class="error">${error}</div>
-##   % endif
-
-  % if request.user:
-      ${form.field_div('user_name', form.hidden('user_name', value=six.text_type(request.user)) + six.text_type(request.user), label="Your Name")}
-  % else:
-      ${form.field_div('user_name', form.text('user_name'), label="Your Name")}
-  % endif
-
-  ${form.field_div('referrer', form.hidden('referrer', value=request.get_referrer()) + request.get_referrer(), label="Referring URL")}
-
-  ${form.field_div('message', form.textarea('message', rows=15))}
-
-  <div class="buttons">
-    ${form.submit('send', "Send Message")}
-    ${h.link_to("Cancel", request.get_referrer(), class_='button')}
-  </div>
-
-  ${form.end()}
-</div>
diff --git a/tailbone/templates/feedback_dialog.mako b/tailbone/templates/feedback_dialog.mako
deleted file mode 100644
index 2892a688..00000000
--- a/tailbone/templates/feedback_dialog.mako
+++ /dev/null
@@ -1,39 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<%def name="feedback_dialog()">
-  <div id="feedback-dialog" style="display: none;">
-    ${h.form(url('feedback'))}
-    ${h.csrf_token(request)}
-    ${h.hidden('user', value=request.user.uuid if request.user else None)}
-
-    <p>
-      Questions, suggestions, comments, complaints, etc. <span class="red">regarding this website</span>
-      are welcome and may be submitted below.
-    </p>
-
-    <div class="field-wrapper referrer">
-      <label for="referrer">Referring URL</label>
-      <div class="field"></div>
-    </div>
-
-    % if request.user:
-        ${h.hidden('user_name', value=six.text_type(request.user))}
-    % else:
-        <div class="field-wrapper">
-          <label for="user_name">Your Name</label>
-          <div class="field">
-            ${h.text('user_name')}
-          </div>
-        </div>
-    % endif
-
-    <div class="field-wrapper">
-      <label for="referrer">Message</label>
-      <div class="field">
-        ${h.textarea('message', cols=45, rows=15)}
-      </div>
-    </div>
-
-    ${h.end_form()}
-  </div>
-</%def>
diff --git a/tailbone/templates/feedback_dialog_buefy.mako b/tailbone/templates/feedback_dialog_buefy.mako
deleted file mode 100644
index 7507bd91..00000000
--- a/tailbone/templates/feedback_dialog_buefy.mako
+++ /dev/null
@@ -1,88 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<%def name="feedback_dialog()">
-  <script type="text/x-template" id="feedback-template">
-    <div>
-
-      <div class="level-item">
-        <b-button type="is-primary"
-                  @click="showFeedback()"
-                  icon-pack="fas"
-                  icon-left="fas fa-comment">
-          Feedback
-        </b-button>
-      </div>
-
-      <b-modal has-modal-card
-               :active.sync="showDialog">
-        <div class="modal-card">
-
-          <header class="modal-card-head">
-            <p class="modal-card-title">User Feedback</p>
-          </header>
-
-          <section class="modal-card-body">
-            <p>
-              Questions, suggestions, comments, complaints, etc.
-              <span class="red">regarding this website</span> are
-              welcome and may be submitted below.
-            </p>
-
-            <b-field label="User Name">
-              <b-input v-model="userName"
-                       % if request.user:
-                       disabled
-                       % endif
-                       >
-              </b-input>
-            </b-field>
-
-            <b-field label="Referring URL">
-              <b-input
-                 ## :value="referrer"
-                 v-model="referrer"
-                 disabled="true">
-              </b-input>
-            </b-field>
-
-            <b-field label="Message">
-              <b-input type="textarea"
-                       v-model="message"
-                       ref="textarea">
-              </b-input>
-            </b-field>
-
-          </section>
-
-          <footer class="modal-card-foot">
-            <b-button @click="showDialog = false">
-              Cancel
-            </b-button>
-            <once-button type="is-primary"
-                         @click="sendFeedback()"
-                         :disabled="!message.trim()"
-                         text="Send Message">
-            </once-button>
-          </footer>
-        </div>
-      </b-modal>
-
-    </div>
-  </script>
-
-  <script type="text/javascript">
-
-    FeedbackFormData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
-    FeedbackFormData.referrer = location.href
-
-    % if request.user:
-    FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
-    FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n}
-    % endif
-
-    FeedbackForm.data = function() { return FeedbackFormData }
-
-    Vue.component('feedback-form', FeedbackForm)
-
-  </script>
-</%def>
diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index 291c2741..e3a4d5dc 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -5,25 +5,64 @@
 
 <%def name="render_form_buttons()"></%def>
 
-<%def name="render_form()">
-  ${form.render(buttons=capture(self.render_form_buttons))|n}
+<%def name="render_form_template()">
+  ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n}
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
-    <tailbone-form></tailbone-form>
+    ${form.render_vue_tag()}
   </div>
 </%def>
 
 <%def name="page_content()">
-  <div class="form-wrapper">
-    % if use_buefy:
+  % if main_form_collapsible:
+      <${b}-collapse class="panel"
+                     % if request.use_oruga:
+                         v-model:open="mainFormPanelOpen"
+                     % else:
+                         :open.sync="mainFormPanelOpen"
+                     % endif
+                     >
+        <template #trigger="props">
+          <div class="panel-heading"
+               role="button"
+               style="cursor: pointer;">
+
+            ## TODO: for some reason buefy will "reuse" the icon
+            ## element in such a way that its display does not
+            ## refresh.  so to work around that, we use different
+            ## structure for the two icons, so buefy is forced to
+            ## re-draw
+
+            <b-icon v-if="props.open"
+                    pack="fas"
+                    icon="caret-down">
+            </b-icon>
+
+            <span v-if="!props.open">
+              <b-icon pack="fas"
+                      icon="caret-right">
+              </b-icon>
+            </span>
+
+            &nbsp;
+            <strong>${main_form_title}</strong>
+          </div>
+        </template>
+        <div class="panel-block">
+          <div class="form-wrapper">
+            <br />
+            ${self.render_form()}
+          </div>
+        </div>
+      </${b}-collapse>
+  % else:
+      <div class="form-wrapper">
         <br />
-        ${self.render_buefy_form()}
-    % else:
         ${self.render_form()}
-    % endif
-  </div>
+      </div>
+  % endif
 </%def>
 
 <%def name="render_this_page()">
@@ -34,6 +73,9 @@
     </div>
 
     <div style="display: flex; align-items: flex-start;">
+
+      ${before_object_helpers()}
+
       <div class="object-helpers">
         ${self.object_helpers()}
       </div>
@@ -46,25 +88,27 @@
   </div>
 </%def>
 
-<%def name="render_this_page_template()">
-  % if form is not Underined:
-      ${self.render_form()}
+<%def name="before_object_helpers()"></%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if form is not Undefined:
+      ${self.render_form_template()}
   % endif
-  ${parent.render_this_page_template()}
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
-  % if form is not Undefined:
-      <script type="text/javascript">
-
-        ${form.component_studly}.data = function() { return ${form.component_studly}Data }
-
-        Vue.component('${form.component}', ${form.component_studly})
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if main_form_collapsible:
+      <script>
+        ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'}
       </script>
   % endif
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  % if form is not Undefined:
+      ${form.render_vue_finalize()}
+  % endif
+</%def>
diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako
new file mode 100644
index 00000000..d566a467
--- /dev/null
+++ b/tailbone/templates/formposter.mako
@@ -0,0 +1,84 @@
+## -*- coding: utf-8; -*-
+
+<%def name="declare_formposter_mixin()">
+  <script type="text/javascript">
+
+    let SimpleRequestMixin = {
+        methods: {
+
+            simpleGET(url, params, success, failure) {
+
+                this.$http.get(url, {params: params}).then(response => {
+
+                    if (response.data.error) {
+                        this.$buefy.toast.open({
+                            message: `Request failed:  ${'$'}{response.data.error}`,
+                            type: 'is-danger',
+                            duration: 4000, // 4 seconds
+                        })
+                        if (failure) {
+                            failure(response)
+                        }
+
+                    } else {
+                        success(response)
+                    }
+
+                }, response => {
+                    this.$buefy.toast.open({
+                        message: "Request failed:  (unknown server error)",
+                        type: 'is-danger',
+                        duration: 4000, // 4 seconds
+                    })
+                    if (failure) {
+                        failure(response)
+                    }
+                })
+
+            },
+
+            simplePOST(action, params, success, failure) {
+
+                let csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
+
+                let headers = {
+                    '${csrf_header_name}': csrftoken,
+                }
+
+                this.$http.post(action, params, {headers: headers}).then(response => {
+
+                    if (response.data.error) {
+                        this.$buefy.toast.open({
+                            message: "Submit failed:  " + (response.data.error ||
+                                                           "(unknown error)"),
+                            type: 'is-danger',
+                            duration: 4000, // 4 seconds
+                        })
+                        if (failure) {
+                            failure(response)
+                        }
+
+                    } else {
+                        success(response)
+                    }
+
+                }, response => {
+                    this.$buefy.toast.open({
+                        message: "Submit failed!  (unknown server error)",
+                        type: 'is-danger',
+                        duration: 4000, // 4 seconds
+                    })
+                    if (failure) {
+                        failure(response)
+                    }
+                })
+            },
+        },
+    }
+
+    // TODO: deprecate / remove
+    SimpleRequestMixin.methods.submitForm = SimpleRequestMixin.methods.simplePOST
+    let FormPosterMixin = SimpleRequestMixin
+
+  </script>
+</%def>
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index d6c99953..2100b460 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -1,103 +1,211 @@
 ## -*- coding: utf-8; -*-
 
-% if not readonly:
-<% _focus_rendered = False %>
-${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)}
-${h.csrf_token(request)}
-% endif
+<% request.register_component(form.vue_tagname, form.vue_component) %>
 
-% if dform.error:
-    <div class="error-messages">
-      <div class="ui-state-error ui-corner-all">
-        <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-        Please see errors below.
-      </div>
-      <div class="ui-state-error ui-corner-all">
-        <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-        ${dform.error}
-      </div>
-    </div>
-% endif
+<script type="text/x-template" id="${form.vue_tagname}-template">
 
-% for field in form.fields:
+  <div>
+  % if not form.readonly:
+  ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))}
+  ${h.csrf_token(request)}
+  % endif
 
-    ## % if readonly or field.name in readonly_fields:
-    % if readonly:
-        ${render_field_readonly(field)|n}
-    % elif field not in dform and field in form.readonly_fields:
-        ${render_field_readonly(field)|n}
-    % elif field in dform:
-        <% field = dform[field] %>
-
-        % if form.field_visible(field.name):
-            <div class="field-wrapper ${field.name} ${'with-error' if field.error else ''}">
-              % if field.error:
-                  <div class="field-error">
-                    % for msg in field.error.messages():
-                        <span class="error-msg">${msg}</span>
-                    % endfor
-                  </div>
-              % endif
-              <div class="field-row">
-                <label for="${field.oid}">${form.get_label(field.name)}</label>
-                <div class="field">
-                  ${field.serialize()|n}
+  <section>
+    % if form_body is not Undefined and form_body:
+        ${form_body|n}
+    % elif getattr(form, 'grouping', None):
+        % for group in form.grouping:
+            <nav class="panel">
+              <p class="panel-heading">${group}</p>
+              <div class="panel-block">
+                <div>
+                  % for field in form.grouping[group]:
+                      ${form.render_field_complete(field)}
+                  % endfor
                 </div>
               </div>
-              % if form.has_helptext(field.name):
-                  <span class="instructions">${form.render_helptext(field.name)}</span>
-              % endif
-            </div>
-
-            ## % if not _focus_rendered and (fieldset.focus is True or fieldset.focus is field):
-            % if not readonly and not _focus_rendered:
-                ## % if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True):
-                % if not field.widget.readonly:
-                    <script type="text/javascript">
-                      $(function() {
-    ##                       % if hasattr(field.renderer, 'focus_name'):
-    ##                           $('#${field.renderer.focus_name}').focus();
-    ##                       % else:
-    ##                           $('#${field.renderer.name}').focus();
-    ##                       % endif
-                          $('#${field.oid}').focus();
-                      });
-                    </script>
-                    <% _focus_rendered = True %>
-                % endif
-            % endif
-
-        % else:
-            ## hidden field
-            ${field.serialize()|n}
-        % endif
-
+            </nav>
+        % endfor
+    % else:
+        % for fieldname in form.fields:
+            ${form.render_vue_field(fieldname, session=session)}
+        % endfor
     % endif
+  </section>
 
-% endfor
+  % if buttons:
+      <br />
+      ${buttons|n}
+  % elif not form.readonly and (buttons is Undefined or (buttons is not None and buttons is not False)):
+      <br />
+      <div class="buttons">
+        % if getattr(form, 'show_cancel', True):
+            % if form.auto_disable_cancel:
+                <once-button tag="a" href="${form.cancel_url or request.get_referrer()}"
+                             text="Cancel">
+                </once-button>
+            % else:
+                <b-button tag="a" href="${form.cancel_url or request.get_referrer()}">
+                  Cancel
+                </b-button>
+            % endif
+        % endif
+        % if getattr(form, 'show_reset', False):
+            <input type="reset" value="Reset" class="button" />
+        % endif
+        ## TODO: deprecate / remove the latter option here
+        % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
+            <b-button type="is-primary"
+                      native-type="submit"
+                      :disabled="${form.vue_component}Submitting"
+                      icon-pack="fas"
+                      icon-left="${form.button_icon_submit}">
+              {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }}
+            </b-button>
+        % else:
+            <b-button type="is-primary"
+                      native-type="submit"
+                      icon-pack="fas"
+                      icon-left="save">
+              ${form.button_label_submit}
+            </b-button>
+        % endif
+      </div>
+  % endif
 
-% if buttons:
-    ${buttons|n}
-% elif not readonly and (buttons is Undefined or (buttons is not None and buttons is not False)):
-    <div class="buttons">
-      ## ${h.submit('create', form.create_label if form.creating else form.update_label)}
-      ${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')}
-##         % if form.creating and form.allow_successive_creates:
-##             ${h.submit('create_and_continue', form.successive_create_label)}
-##         % endif
-      % if getattr(form, 'show_reset', False):
-          <input type="reset" value="Reset" class="button" />
-      % endif
-      % if getattr(form, 'show_cancel', True):
-          % if form.mobile:
-              ${h.link_to("Cancel", form.cancel_url, class_='ui-btn ui-corner-all')}
-          % else:
-              ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))}
+  % if not form.readonly:
+  ${h.end_form()}
+  % endif
+
+  % if can_edit_help:
+      <b-modal has-modal-card
+               :active.sync="configureFieldShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Field: {{ configureFieldName }}</p>
+          </header>
+
+          <section class="modal-card-body">
+
+            <b-field label="Label">
+              <b-input v-model="configureFieldLabel" disabled></b-input>
+            </b-field>
+
+            <b-field label="Help Text (Markdown)">
+              <b-input v-model="configureFieldMarkdown"
+                       type="textarea" rows="8"
+                       ref="configureFieldMarkdown">
+              </b-input>
+            </b-field>
+
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="configureFieldShowDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      @click="configureFieldSave()"
+                      :disabled="configureFieldSaving"
+                      icon-pack="fas"
+                      icon-left="save">
+              {{ configureFieldSaving ? "Working, please wait..." : "Save" }}
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+  % endif
+
+  </div>
+</script>
+
+<script type="text/javascript">
+
+  let ${form.vue_component} = {
+      template: '#${form.vue_tagname}-template',
+      mixins: [FormPosterMixin],
+      components: {},
+      props: {
+          % if can_edit_help:
+              configureFieldsHelp: Boolean,
           % endif
-      % endif
-    </div>
-% endif
+      },
+      watch: {},
+      computed: {},
+      methods: {
 
-% if not readonly:
-${h.end_form()}
-% endif
+          ## TODO: deprecate / remove the latter option here
+          % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
+              submit${form.vue_component}() {
+                  this.${form.vue_component}Submitting = true
+              },
+          % endif
+
+          % if can_edit_help:
+
+              configureFieldInit(fieldname) {
+                  this.configureFieldName = fieldname
+                  this.configureFieldLabel = this.fieldLabels[fieldname]
+                  this.configureFieldMarkdown = this.fieldMarkdowns[fieldname]
+                  this.configureFieldShowDialog = true
+                  this.$nextTick(() => {
+                      this.$refs.configureFieldMarkdown.focus()
+                  })
+              },
+
+              configureFieldSave() {
+                  this.configureFieldSaving = true
+                  let url = '${edit_help_url}'
+                  let params = {
+                      field_name: this.configureFieldName,
+                      markdown_text: this.configureFieldMarkdown,
+                  }
+                  this.submitForm(url, params, response => {
+                      this.configureFieldShowDialog = false
+                      this.$buefy.toast.open({
+                          message: "Info was saved; please refresh page to see changes.",
+                          type: 'is-info',
+                          duration: 4000, // 4 seconds
+                      })
+                      this.configureFieldSaving = false
+                  }, response => {
+                      this.configureFieldSaving = false
+                  })
+              },
+          % endif
+      }
+  }
+
+  let ${form.vue_component}Data = {
+
+      ## TODO: should find a better way to handle CSRF token
+      csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
+
+      % if can_edit_help:
+          fieldLabels: ${json.dumps(field_labels)|n},
+          fieldMarkdowns: ${json.dumps(field_markdowns)|n},
+          configureFieldShowDialog: false,
+          configureFieldSaving: false,
+          configureFieldName: null,
+          configureFieldLabel: null,
+          configureFieldMarkdown: null,
+      % endif
+
+      ## TODO: ugh, this seems pretty hacky.  need to declare some data models
+      ## for various field components to bind to...
+      % if not form.readonly:
+          % for field in form.fields:
+              % if field in dform:
+                  field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n},
+              % endif
+          % endfor
+      % endif
+
+      ## TODO: deprecate / remove the latter option here
+      % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
+          ${form.vue_component}Submitting: false,
+      % endif
+  }
+
+</script>
diff --git a/tailbone/templates/forms/deform_buefy.mako b/tailbone/templates/forms/deform_buefy.mako
deleted file mode 100644
index 3d60decc..00000000
--- a/tailbone/templates/forms/deform_buefy.mako
+++ /dev/null
@@ -1,122 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<script type="text/x-template" id="${form.component}-template">
-  <div>
-  % if not form.readonly:
-  ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)}
-  ${h.csrf_token(request)}
-  % endif
-
-  <section>
-    % for field in form.fields:
-        % if form.readonly or (field not in dform and field in form.readonly_fields):
-            <b-field horizontal
-                     label="${form.get_label(field)}">
-              ${form.render_field_value(field) or h.HTML.tag('span')}
-            </b-field>
-
-        % elif field in dform:
-            <% field = dform[field] %>
-
-            <b-field horizontal
-                     label="${form.get_label(field.name)}"
-                     ## TODO: is this class="file" really needed?
-                     % if isinstance(field.schema.typ, deform.FileData):
-                     class="file"
-                     % endif
-                     % if field.error:
-                     type="is-danger"
-                     :message='${form.messages_json(field.error.messages())|n}'
-                     % endif
-                     >
-              ${field.serialize(use_buefy=True)|n}
-            </b-field>
-        % endif
-
-    % endfor
-  </section>
-
-  % if buttons:
-      <br />
-      ${buttons|n}
-  % elif not form.readonly and (buttons is Undefined or (buttons is not None and buttons is not False)):
-      <br />
-      <div class="buttons">
-        ## TODO: deprecate / remove the latter option here
-        % if form.auto_disable_save or form.auto_disable:
-            <b-button type="is-primary"
-                      native-type="submit"
-                      :disabled="${form.component_studly}Submitting">
-              {{ ${form.component_studly}ButtonText }}
-            </b-button>
-        % else:
-            <b-button type="is-primary"
-                      native-type="submit">
-              ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))}
-            </b-button>
-        % endif
-        % if getattr(form, 'show_reset', False):
-            <input type="reset" value="Reset" class="button" />
-        % endif
-        % if getattr(form, 'show_cancel', True):
-            % if form.mobile:
-                ${h.link_to("Cancel", form.cancel_url, class_='ui-btn ui-corner-all')}
-            % else:
-                % if form.auto_disable_cancel:
-                    <once-button tag="a" href="${form.cancel_url or request.get_referrer()}"
-                                 text="Cancel">
-                    </once-button>
-                % else:
-                    <b-button tag="a" href="${form.cancel_url or request.get_referrer()}">
-                      Cancel
-                    </b-button>
-                % endif
-            % endif
-        % endif
-      </div>
-  % endif
-
-  % if not form.readonly:
-  ${h.end_form()}
-  % endif
-  </div>
-</script>
-
-<script type="text/javascript">
-
-  let ${form.component_studly} = {
-      template: '#${form.component}-template',
-      components: {},
-      methods: {
-
-          ## TODO: deprecate / remove the latter option here
-          % if form.auto_disable_save or form.auto_disable:
-              submit${form.component_studly}() {
-                  this.${form.component_studly}Submitting = true
-                  this.${form.component_studly}ButtonText = "Working, please wait..."
-              }
-          % endif
-      }
-  }
-
-  let ${form.component_studly}Data = {
-
-      ## TODO: ugh, this seems pretty hacky.  need to declare some data models
-      ## for various field components to bind to...
-      % if not form.readonly:
-          % for field in form.fields:
-              % if field in dform:
-                  <% field = dform[field] %>
-                  field_model_${field.name}: ${form.get_vuejs_model_value(field)|n},
-              % endif
-          % endfor
-      % endif
-
-      ## TODO: deprecate / remove the latter option here
-      % if form.auto_disable_save or form.auto_disable:
-          ${form.component_studly}Submitting: false,
-          ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
-      % endif
-  }
-
-</script>
diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako
deleted file mode 100644
index 71e28817..00000000
--- a/tailbone/templates/forms/form.mako
+++ /dev/null
@@ -1,8 +0,0 @@
-## -*- coding: utf-8; -*-
-% if form.use_buefy:
-    ${form.render_deform(buttons=buttons)|n}
-% else:
-    <div class="form">
-      ${form.render_deform(buttons=buttons)|n}
-    </div>
-% endif
diff --git a/tailbone/templates/forms/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako
deleted file mode 100644
index 306282e9..00000000
--- a/tailbone/templates/forms/form_readonly.mako
+++ /dev/null
@@ -1,8 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<div class="form">
-  ${form.render_deform(readonly=True)|n}
-  % if buttons:
-      ${buttons|n}
-  % endif
-</div><!-- form -->
diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako
new file mode 100644
index 00000000..ac096f67
--- /dev/null
+++ b/tailbone/templates/forms/vue_template.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/forms/deform.mako" />
+${parent.body()}
diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako
new file mode 100644
index 00000000..0f2a9f7b
--- /dev/null
+++ b/tailbone/templates/generate_feature.mako
@@ -0,0 +1,387 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">Generate Feature</%def>
+
+<%def name="content_title()"></%def>
+
+<%def name="page_content()">
+
+  <b-field horizontal label="App Prefix"
+           message="Unique naming prefix for the app.">
+    <b-input v-model="app.app_prefix"
+             @input="appPrefixChanged">
+    </b-input>
+  </b-field>
+
+  <b-field horizontal label="App CapPrefix"
+           message="Unique naming prefix for the app, in CapWords style.">
+    <b-input v-model="app.app_cap_prefix"></b-input>
+  </b-field>
+
+  <b-field horizontal label="Feature Type">
+    <b-select v-model="featureType">
+      <option value="new-report">New Report</option>
+      <option value="new-table">New Table</option>
+    </b-select>
+  </b-field>
+
+  <div v-if="featureType == 'new-table'">
+    ${h.form(request.current_route_url(), ref='new-table-form')}
+    ${h.csrf_token(request)}
+    ${h.hidden('feature_type', value='new-table')}
+    ${h.hidden('app_prefix', **{'v-model': 'app.app_prefix'})}
+    ${h.hidden('app_cap_prefix', **{'v-model': 'app.app_cap_prefix'})}
+    ${h.hidden('columns', **{':value': 'JSON.stringify(new_table.columns)'})}
+
+    <br />
+    <div class="card">
+      <header class="card-header">
+        <p class="card-header-title">New Table</p>
+      </header>
+      <div class="card-content">
+        <div class="content">
+
+          <b-field horizontal label="Table Name"
+                   :message="`Name for the table within the DB.  With prefix this becomes: ${'$'}{app.app_prefix}_${'$'}{new_table.table_name}`">
+            <b-input name="table_name"
+                     v-model="new_table.table_name"
+                     @input="tableNameChanged">
+            </b-input>
+          </b-field>
+
+          <b-field horizontal label="Model Name"
+                   :message="`Model name for the table, in CapWords style.  With prefix this becomes: ${'$'}{app.app_cap_prefix}${'$'}{new_table.model_name}`">
+            <b-input name="model_name" v-model="new_table.model_name"></b-input>
+          </b-field>
+
+          <b-field horizontal label="Model Title"
+                   message="Human-friendly singular model title.">
+            <b-input name="model_title" v-model="new_table.model_title"></b-input>
+          </b-field>
+
+          <b-field horizontal label="Plural Model Title"
+                   message="Human-friendly plural model title.">
+            <b-input name="model_title_plural" v-model="new_table.model_title_plural"></b-input>
+          </b-field>
+
+          <b-field horizontal label="Description"
+                   message="Description of what a record in this table represents.">
+            <b-input name="description" v-model="new_table.description"></b-input>
+          </b-field>
+
+          <b-field horizontal label="Versioned"
+                   message="Whether to record version data for this table.">
+            <b-checkbox name="versioned"
+                        v-model="new_table.versioned"
+                        native-value="true">
+              {{ new_table.versioned }}
+            </b-checkbox>
+          </b-field>
+
+          <b-field horizontal label="Columns">
+            <div class="control">
+
+              <div class="level">
+                <div class="level-left">
+                  <div class="level-item">
+                    <b-button type="is-primary"
+                              icon-pack="fas"
+                              icon-left="plus"
+                              @click="addColumn()">
+                      New Column
+                    </b-button>
+                  </div>
+                </div>
+                <div class="level-right">
+                  <div class="level-item">
+                    <b-button type="is-danger"
+                              icon-pack="fas"
+                              icon-left="trash"
+                              @click="new_table.columns = []"
+                              :disabled="!new_table.columns.length">
+                      Delete All
+                    </b-button>
+                  </div>
+                </div>
+              </div>
+
+              <${b}-table
+                 :data="new_table.columns">
+
+                <${b}-table-column field="name"
+                                label="Name"
+                                v-slot="props">
+                  {{ props.row.name }}
+                </${b}-table-column>
+
+                <${b}-table-column field="data_type"
+                                label="Data Type"
+                                v-slot="props">
+                  {{ props.row.data_type }}
+                </${b}-table-column>
+
+                <${b}-table-column field="nullable"
+                                label="Nullable"
+                                v-slot="props">
+                  {{ props.row.nullable }}
+                </${b}-table-column>
+
+                <${b}-table-column field="description"
+                                label="Description"
+                                v-slot="props">
+                  {{ props.row.description }}
+                </${b}-table-column>
+
+                <${b}-table-column field="actions"
+                                label="Actions"
+                                v-slot="props">
+                  <a href="#" class="grid-action"
+                     @click.prevent="editColumnRow(props)">
+                    % if request.use_oruga:
+                        <o-icon icon="edit" />
+                    % else:
+                        <i class="fas fa-edit"></i>
+                    % endif
+                    Edit
+                  </a>
+                  &nbsp;
+
+                  <a href="#" class="grid-action has-text-danger"
+                     @click.prevent="deleteColumn(props.index)">
+                    % if request.use_oruga:
+                        <o-icon icon="trash" />
+                    % else:
+                        <i class="fas fa-trash"></i>
+                    % endif
+                    Delete
+                  </a>
+                  &nbsp;
+                </${b}-table-column>
+
+              </${b}-table>
+
+              <${b}-modal has-modal-card
+                       % if request.use_oruga:
+                       v-model:active="showingEditColumn"
+                       % else:
+                       :active.sync="showingEditColumn"
+                       % endif
+                       >
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Edit Column</p>
+                  </header>
+
+                  <section class="modal-card-body">
+
+                    <b-field label="Name">
+                      <b-input v-model="editingColumnName"
+                               expanded />
+                    </b-field>
+
+                    <b-field label="Data Type">
+                      <b-input v-model="editingColumnDataType"
+                               expanded />
+                    </b-field>
+
+                    <b-field label="Nullable">
+                      <b-checkbox v-model="editingColumnNullable"
+                                  native-value="true">
+                        {{ editingColumnNullable }}
+                      </b-checkbox>
+                    </b-field>
+
+                    <b-field label="Description">
+                      <b-input v-model="editingColumnDescription"
+                               expanded />
+                    </b-field>
+
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button @click="showingEditColumn = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="saveColumn()">
+                      Save
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+
+            </div>
+          </b-field>
+
+        </div>
+      </div>
+    </div>
+
+    ${h.end_form()}
+  </div>
+
+  <div v-if="featureType == 'new-report'">
+    ${h.form(request.current_route_url(), ref='new-report-form')}
+    ${h.csrf_token(request)}
+    ${h.hidden('feature_type', value='new-report')}
+    ${h.hidden('app_prefix', **{'v-model': 'app.app_prefix'})}
+    ${h.hidden('app_cap_prefix', **{'v-model': 'app.app_cap_prefix'})}
+
+    <br />
+    <div class="card">
+      <header class="card-header">
+        <p class="card-header-title">New Report</p>
+      </header>
+      <div class="card-content">
+        <div class="content">
+
+          <b-field horizontal label="Name"
+                   message="Human-friendly name for the report.">
+            <b-input name="name" v-model="new_report.name"></b-input>
+          </b-field>
+
+          <b-field horizontal label="Description"
+                   message="Description of the report.">
+            <b-input name="description" v-model="new_report.description"></b-input>
+          </b-field>
+
+        </div>
+      </div>
+    </div>
+
+    ${h.end_form()}
+  </div>
+
+  <br />
+  <div class="buttons" style="padding-left: 8rem;">
+    <once-button type="is-primary"
+                 @click="submitFeatureForm()"
+                 text="Generate Feature">
+    </once-button>
+  </div>
+
+  <div class="card"
+       v-if="resultGenerated">
+    <header class="card-header">
+      <p class="card-header-title">
+        <a name="instructions" href="#instructions">Please follow these instructions carefully.</a>
+      </p>
+    </header>
+    <div class="card-content">
+      <div class="content result rendered-markdown">${rendered_result or ""|n}</div>
+    </div>
+  </div>
+
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.featureType = ${json.dumps(feature_type)|n}
+    ThisPageData.resultGenerated = ${json.dumps(bool(result))|n}
+
+    % if result:
+    ThisPage.mounted = function() {
+        location.href = '#instructions'
+    }
+    % endif
+
+    ThisPageData.app = {
+        <% dform = app_form.make_deform_form() %>
+        % for field in dform:
+            ${field.name}: ${app_form.get_vuejs_model_value(field)|n},
+        % endfor
+    }
+
+    % for key, form in feature_forms.items():
+        <% safekey = key.replace('-', '_') %>
+        ThisPageData.${safekey} = {
+            <% dform = feature_forms[key].make_deform_form() %>
+            % for field in dform:
+                ${field.name}: ${feature_forms[key].get_vuejs_model_value(field)|n},
+            % endfor
+        }
+    % endfor
+
+    ThisPage.methods.appPrefixChanged = function(prefix) {
+        let words = prefix.split('_')
+        let capitalized = []
+        words.forEach(word => {
+            capitalized.push(word[0].toUpperCase() + word.substr(1))
+        })
+
+        this.app.app_cap_prefix = capitalized.join('')
+    }
+
+    ThisPage.methods.tableNameChanged = function(name) {
+        let words = name.split('_')
+        let capitalized = []
+        words.forEach(word => {
+            capitalized.push(word[0].toUpperCase() + word.substr(1))
+        })
+
+        this.new_table.model_name = capitalized.join('')
+        this.new_table.model_title = capitalized.join(' ')
+        this.new_table.model_title_plural = capitalized.join(' ') + 's'
+        this.new_table.description = `Represents a ${'$'}{this.new_table.model_title}.`
+    }
+
+    ThisPageData.showingEditColumn = false
+    ThisPageData.editingColumn = null
+    ThisPageData.editingColumnIndex = null
+    ThisPageData.editingColumnName = null
+    ThisPageData.editingColumnDataType = null
+    ThisPageData.editingColumnNullable = null
+    ThisPageData.editingColumnDescription = null
+
+    ThisPage.methods.addColumn = function(column) {
+        this.editingColumn = null
+        this.editingColumnIndex = null
+        this.editingColumnName = null
+        this.editingColumnDataType = null
+        this.editingColumnNullable = true
+        this.editingColumnDescription = null
+        this.showingEditColumn = true
+    }
+
+    ThisPage.methods.editColumnRow = function(props) {
+        const column = props.row
+        this.editingColumn = column
+        this.editingColumnIndex = props.index
+        this.editingColumnName = column.name
+        this.editingColumnDataType = column.data_type
+        this.editingColumnNullable = column.nullable
+        this.editingColumnDescription = column.description
+        this.showingEditColumn = true
+    }
+
+    ThisPage.methods.saveColumn = function() {
+        if (this.editingColumn) {
+            column = this.new_table.columns[this.editingColumnIndex]
+        } else {
+            column = {}
+            this.new_table.columns.push(column)
+        }
+        column.name = this.editingColumnName
+        column.data_type = this.editingColumnDataType
+        column.nullable = this.editingColumnNullable
+        column.description = this.editingColumnDescription
+        this.showingEditColumn = false
+    }
+
+    ThisPage.methods.deleteColumn = function(index) {
+        this.new_table.columns.splice(index, 1)
+    }
+
+    ThisPage.methods.submitFeatureForm = function() {
+        let form = this.$refs[this.featureType + '-form']
+        // TODO: why do we have to set this?  hidden field value is blank?!
+        form['feature_type'].value = this.featureType
+        form.submit()
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/generated-projects/create.mako b/tailbone/templates/generated-projects/create.mako
new file mode 100644
index 00000000..6c3af299
--- /dev/null
+++ b/tailbone/templates/generated-projects/create.mako
@@ -0,0 +1,25 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/create.mako" />
+
+<%def name="title()">${index_title}</%def>
+
+<%def name="content_title()"></%def>
+
+<%def name="page_content()">
+  % if project_type:
+      <b-field grouped>
+        <b-field horizontal expanded label="Project Type"
+                 class="is-expanded">
+          ${project_type}
+        </b-field>
+        <once-button type="is-primary"
+                     tag="a" href="${url('generated_projects.create')}"
+                     text="Start Over">
+        </once-button>
+      </b-field>
+  % endif
+  ${parent.page_content()}
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako
index 728d285d..da9f2aae 100644
--- a/tailbone/templates/grids/b-table.mako
+++ b/tailbone/templates/grids/b-table.mako
@@ -1,5 +1,5 @@
 ## -*- coding: utf-8; -*-
-<b-table
+<${b}-table
    :data="${data_prop}"
    icon-pack="fas"
    striped
@@ -15,57 +15,72 @@
    % if loading is not Undefined and loading:
    :loading="${loading}"
    % endif
+   % if grid.default_sortkey:
+   :default-sort="['${grid.default_sortkey}', '${grid.default_sortdir}']"
+   % endif
    >
 
-  <template slot-scope="props">
-    % for i, column in enumerate(grid_columns):
-        <b-table-column field="${column['field']}"
-                        % if not empty_labels:
-                        label="${column['label']}"
-                        % elif i > 0:
-                        label=" "
-                        % endif
-                        ${'sortable' if column['sortable'] else ''}>
-          % if empty_labels and i == 0:
-              <template slot="header" slot-scope="{ column }"></template>
-          % endif
-          % if grid.is_linked(column['field']):
-              <a :href="props.row._action_url_view"
-                 v-html="props.row.${column['field']}"
-                 % if view_click_handler:
-                 @click.prevent="${view_click_handler}"
-                 % endif
-                 >
+  % for i, column in enumerate(grid_columns):
+      <${b}-table-column field="${column['field']}"
+                      % if not empty_labels:
+                      label="${column['label']}"
+                      % elif i > 0:
+                      label=" "
+                      % endif
+                      v-slot="props"
+                      ${'sortable' if column['sortable'] else ''}>
+        % if empty_labels and i == 0:
+            <template slot="header" slot-scope="{ column }"></template>
+        % endif
+        % if grid.is_linked(column['field']):
+            <a :href="props.row._action_url_view"
+               v-html="props.row.${column['field']}"
+               % if view_click_handler:
+               @click.prevent="${view_click_handler}"
+               % endif
+               >
+            </a>
+        % elif grid.has_click_handler(column['field']):
+            <span>
+              <a href="#"
+                 @click.prevent="${grid.click_handlers[column['field']]}"
+                 v-html="props.row.${column['field']}">
               </a>
-          % else:
-              <span v-html="props.row.${column['field']}"></span>
-          % endif
-        </b-table-column>
-    % endfor
+            </span>
+        % else:
+            <span v-html="props.row.${column['field']}"></span>
+        % endif
+      </${b}-table-column>
+  % endfor
 
-    % if grid.main_actions or grid.more_actions:
-        <b-table-column field="actions" label="Actions">
-          % for action in grid.main_actions:
-              <a :href="props.row._action_url_${action.key}"
-                 % if action.click_handler:
-                 @click.prevent="${action.click_handler}"
-                 % endif
-                 >
-                <i class="fas fa-${action.icon}"></i>
-                ${action.label}
-              </a>
-              &nbsp;
-          % endfor
-        </b-table-column>
-    % endif
-  </template>
+  % if grid.actions:
+      <${b}-table-column field="actions"
+                      label="Actions"
+                      v-slot="props">
+        % for action in grid.actions:
+            <a :href="props.row._action_url_${action.key}"
+               % if action.link_class:
+               class="${action.link_class}"
+               % else:
+               class="grid-action${' has-text-danger' if action.key == 'delete' else ''}"
+               % endif
+               % if action.click_handler:
+               @click.prevent="${action.click_handler}"
+               % endif
+               >
+              ${action.render_icon_and_label()}
+            </a>
+            &nbsp;
+        % endfor
+      </${b}-table-column>
+  % endif
 
-  <template slot="empty">
+  <template #empty>
     <div class="content has-text-grey has-text-centered">
       <p>
         <b-icon
            pack="fas"
-           icon="fas fa-sad-tear"
+           icon="sad-tear"
            size="is-large">
         </b-icon>
       </p>
@@ -83,4 +98,4 @@
   </template>
   % endif
 
-</b-table>
+</${b}-table>
diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
deleted file mode 100644
index ac814a8a..00000000
--- a/tailbone/templates/grids/buefy.mako
+++ /dev/null
@@ -1,442 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<script type="text/x-template" id="grid-filter-date-value-template">
-  <div class="level">
-    <div class="level-left">
-      <div class="level-item">
-        <tailbone-datepicker v-model="startDate"
-                             ref="startDate"
-                             @input="startDateChanged">
-        </tailbone-datepicker>
-      </div>
-      <div v-show="dateRange"
-           class="level-item">
-        and
-      </div>
-      <div v-show="dateRange"
-           class="level-item">
-        <tailbone-datepicker v-model="endDate"
-                             ref="endDate"
-                             @input="endDateChanged">
-        </tailbone-datepicker>
-      </div>
-    </div>
-  </div>
-</script>
-
-<script type="text/x-template" id="grid-filter-template">
-
-  <div class="level filter" v-show="filter.visible">
-    <div class="level-left">
-
-      <div class="level-item filter-fieldname">
-
-        <b-field>
-          <b-checkbox-button v-model="filter.active" native-value="IGNORED">
-            <b-icon pack="fas" icon="check" v-show="filter.active"></b-icon>
-            <span>{{ filter.label }}</span>
-          </b-checkbox-button>
-        </b-field>
-
-      </div>
-
-      <b-field grouped v-show="filter.active" class="level-item">
-
-        <b-select v-model="filter.verb"
-                  @input="focusValue()"
-                  class="filter-verb">
-          <option v-for="verb in filter.verbs"
-                  :key="verb"
-                  :value="verb">
-            {{ filter.verb_labels[verb] }}
-          </option>
-        </b-select>
-
-        ## only one of the following "value input" elements will be rendered
-
-        <grid-filter-date-value v-if="filter.data_type == 'date'"
-                                v-model="filter.value"
-                                v-show="valuedVerb()"
-                                :date-range="filter.verb == 'between'"
-                                ref="valueInput">
-        </grid-filter-date-value>
-
-        <b-select v-if="filter.data_type == 'choice'"
-                  v-model="filter.value"
-                  v-show="valuedVerb()"
-                  ref="valueInput">
-          <option v-for="choice in filter.choices"
-                  :key="choice"
-                  :value="choice">
-            {{ filter.choice_labels[choice] || choice }}
-          </option>
-        </b-select>
-
-        <b-input v-if="filter.data_type == 'string'"
-                 v-model="filter.value"
-                 v-show="valuedVerb()"
-                 ref="valueInput">
-        </b-input>
-
-      </b-field>
-
-    </div><!-- level-left -->
-  </div><!-- level -->
-
-</script>
-
-<script type="text/x-template" id="${grid.component}-template">
-  <div>
-
-    <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
-
-      <div style="display: flex; flex-direction: column; justify-content: space-between;">
-        <div></div>
-        <div class="filters">
-          % if grid.filterable:
-              ## TODO: stop using |n filter
-              ${grid.render_filters(template='/grids/filters_buefy.mako', allow_save_defaults=allow_save_defaults)|n}
-          % endif
-        </div>
-      </div>
-
-      <div style="display: flex; flex-direction: column; justify-content: space-between;">
-
-        <div class="context-menu">
-          % if context_menu:
-              <ul id="context-menu">
-                ## TODO: stop using |n filter
-                ${context_menu|n}
-              </ul>
-          % endif
-        </div>
-
-        <div class="grid-tools-wrapper">
-          % if tools:
-              <div class="grid-tools field is-grouped is-pulled-right">
-                ## TODO: stop using |n filter
-                ${tools|n}
-              </div>
-          % endif
-        </div>
-
-      </div>
-
-    </div>
-
-    <b-table
-       :data="data"
-       ## :columns="columns"
-       :loading="loading"
-       :row-class="getRowClass"
-
-       :checkable="checkable"
-       % if grid.checkboxes:
-       :checked-rows.sync="checkedRows"
-       % endif
-       % if grid.check_handler:
-       @check="${grid.check_handler}"
-       % endif
-       % if grid.check_all_handler:
-       @check-all="${grid.check_all_handler}"
-       % endif
-       ## TODO: definitely will be wanting this...
-       ## :is-row-checkable=""
-
-       :default-sort="[sortField, sortOrder]"
-       backend-sorting
-       @sort="onSort"
-
-       :paginated="paginated"
-       :per-page="perPage"
-       :current-page="currentPage"
-       backend-pagination
-       :total="total"
-       @page-change="onPageChange"
-
-       ## TODO: should let grid (or master view) decide how to set these?
-       icon-pack="fas"
-       ## note that :striped="true" was interfering with row status (e.g. warning) styles
-       :striped="false"
-       :hoverable="true"
-       :narrowed="true">
-
-      <template slot-scope="props">
-        % for column in grid_columns:
-            <b-table-column field="${column['field']}" label="${column['label']}" ${'sortable' if column['sortable'] else ''}>
-              % if grid.is_linked(column['field']):
-                  <a :href="props.row._action_url_view" v-html="props.row.${column['field']}"></a>
-              % else:
-                  <span v-html="props.row.${column['field']}"></span>
-              % endif
-            </b-table-column>
-        % endfor
-
-        % if grid.main_actions or grid.more_actions:
-            <b-table-column field="actions" label="Actions">
-              % for action in grid.main_actions:
-                  <a v-if="props.row._action_url_${action.key}"
-                     :href="props.row._action_url_${action.key}"
-                     class="grid-action${' has-text-danger' if action.key == 'delete' else ''}"
-                     % if action.click_handler:
-                     @click.prevent="${action.click_handler}"
-                     % endif
-                     >
-                    <i class="fas fa-${action.icon}"></i>
-                    ${action.label}
-                  </a>
-                  &nbsp;
-              % endfor
-            </b-table-column>
-        % endif
-      </template>
-
-      <template slot="empty">
-        <section class="section">
-          <div class="content has-text-grey has-text-centered">
-            <p>
-              <b-icon
-                 pack="fas"
-                 icon="fas fa-sad-tear"
-                 size="is-large">
-              </b-icon>
-            </p>
-            <p>Nothing here.</p>
-          </div>
-        </section>
-      </template>
-
-      % if grid.pageable:
-      <template slot="footer">
-        <b-field grouped position="is-right"
-                 v-if="firstItem">
-          <span class="control">
-            showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results;
-          </span>
-          <b-select v-model="perPage"
-                    size="is-small"
-                    @input="loadAsyncData()">
-            % for value in grid.get_pagesize_options():
-                <option value="${value}">${value}</option>
-            % endfor
-          </b-select>
-          <span class="control">
-            per page
-          </span>
-        </b-field>
-      </template>
-      % endif
-
-    </b-table>
-  </div>
-</script>
-
-<script type="text/javascript">
-
-  let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n}
-
-  let ${grid.component_studly}Data = {
-      loading: false,
-      selectedFilter: null,
-      ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n},
-
-      data: ${grid.component_studly}CurrentData,
-      rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n},
-
-      checkable: ${json.dumps(grid.checkboxes)|n},
-      % if grid.checkboxes:
-      checkedRows: ${grid_data['checked_rows_code']|n},
-      % endif
-
-      paginated: ${json.dumps(grid.pageable)|n},
-      total: ${len(grid_data['data']) if static_data else grid_data['total_items']},
-      perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n},
-      currentPage: ${json.dumps(grid.page if grid.pageable else None)|n},
-      firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n},
-      lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n},
-
-      sortField: ${json.dumps(grid.sortkey if grid.sortable else None)|n},
-      sortOrder: ${json.dumps(grid.sortdir if grid.sortable else None)|n},
-
-      ## filterable: ${json.dumps(grid.filterable)|n},
-      filters: ${json.dumps(filters_data if grid.filterable else None)|n},
-      filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n},
-      selectedFilter: null,
-  }
-
-  let ${grid.component_studly} = {
-      template: '#${grid.component}-template',
-
-      props: {
-          csrftoken: String,
-      },
-
-      computed: {
-          // note, can use this with v-model for hidden 'uuids' fields
-          selected_uuids: function() {
-              return this.checkedRowUUIDs().join(',')
-          },
-      },
-
-      methods: {
-
-          getRowClass(row, index) {
-              return this.rowStatusMap[index]
-          },
-
-          loadAsyncData(params, callback) {
-
-              if (params === undefined || params === null) {
-                  params = [
-                      'partial=true',
-                      `sortkey=${'$'}{this.sortField}`,
-                      `sortdir=${'$'}{this.sortOrder}`,
-                      `pagesize=${'$'}{this.perPage}`,
-                      `page=${'$'}{this.currentPage}`
-                  ].join('&')
-              }
-
-              this.loading = true
-              this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => {
-                  ${grid.component_studly}CurrentData = data.data
-                  this.data = ${grid.component_studly}CurrentData
-                  this.rowStatusMap = data.row_status_map
-                  this.total = data.total_items
-                  this.firstItem = data.first_item
-                  this.lastItem = data.last_item
-                  this.loading = false
-                  this.checkedRows = this.locateCheckedRows(data.checked_rows)
-                  if (callback) {
-                      callback()
-                  }
-              })
-              .catch((error) => {
-                  this.data = []
-                  this.total = 0
-                  this.loading = false
-                  throw error
-              })
-          },
-
-          locateCheckedRows(checked) {
-              let rows = []
-              if (checked) {
-                  for (let i = 0; i < this.data.length; i++) {
-                      if (checked.includes(i)) {
-                          rows.push(this.data[i])
-                      }
-                  }
-              }
-              return rows
-          },
-
-          onPageChange(page) {
-              this.currentPage = page
-              this.loadAsyncData()
-          },
-
-          onSort(field, order) {
-              this.sortField = field
-              this.sortOrder = order
-              // always reset to first page when changing sort options
-              // TODO: i mean..right? would we ever not want that?
-              this.currentPage = 1
-              this.loadAsyncData()
-          },
-
-          resetView() {
-              this.loading = true
-              location.href = '?reset-to-default-filters=true'
-          },
-
-          addFilter(filter_key) {
-
-              // reset dropdown so user again sees "Add Filter" placeholder
-              this.$nextTick(function() {
-                  this.selectedFilter = null
-              })
-
-              // show corresponding grid filter
-              this.filters[filter_key].visible = true
-              this.filters[filter_key].active = true
-
-              // track down the component
-              var gridFilter = null
-              for (var gf of this.$refs.gridFilters) {
-                  if (gf.filter.key == filter_key) {
-                      gridFilter = gf
-                      break
-                  }
-              }
-
-              // tell component to focus the value field, ASAP
-              this.$nextTick(function() {
-                  gridFilter.focusValue()
-              })
-
-          },
-
-          applyFilters(params) {
-              if (params === undefined) {
-                  params = []
-              }
-
-              params.push('partial=true')
-              params.push('filter=true')
-
-              for (var key in this.filters) {
-                  var filter = this.filters[key]
-                  if (filter.active) {
-                      params.push(key + '=' + encodeURIComponent(filter.value))
-                      params.push(key + '.verb=' + encodeURIComponent(filter.verb))
-                  } else {
-                      filter.visible = false
-                  }
-              }
-
-              this.loadAsyncData(params.join('&'))
-          },
-
-          clearFilters() {
-
-              // explicitly deactivate all filters
-              for (var key in this.filters) {
-                  this.filters[key].active = false
-              }
-
-              // then just "apply" as normal
-              this.applyFilters()
-          },
-
-          saveDefaults() {
-
-              // apply current filters as normal, but add special directive
-              const params = ['save-current-filters-as-defaults=true']
-              this.applyFilters(params)
-          },
-
-          deleteObject(event) {
-              // we let parent component/app deal with this, in whatever way makes sense...
-              // TODO: should we ever provide anything besides the URL for this?
-              this.$emit('deleteActionClicked', event.target.href)
-          },
-
-          checkedRowUUIDs() {
-              let uuids = []
-              for (let row of this.$data.checkedRows) {
-                  uuids.push(row.uuid)
-              }
-              return uuids
-          },
-
-          allRowUUIDs() {
-              let uuids = []
-              for (let row of this.data) {
-                  uuids.push(row.uuid)
-              }
-              return uuids
-          },
-      }
-  }
-
-</script>
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 169264c4..60f9a3b8 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -1,38 +1,930 @@
-## -*- coding: utf-8 -*-
-<div class="grid-wrapper">
+## -*- coding: utf-8; -*-
 
-  <table class="grid-header">
-    <tbody>
-      <tr>
+<% request.register_component(grid.vue_tagname, grid.vue_component) %>
 
-        <td class="filters" rowspan="2">
-          % if grid.filterable:
-              ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
+<script type="text/x-template" id="${grid.vue_tagname}-template">
+  <div>
+
+    <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
+
+      <div style="display: flex; flex-direction: column; justify-content: end;">
+        <div class="filters">
+          % if getattr(grid, 'filterable', False):
+              <form method="GET" @submit.prevent="applyFilters()">
+
+                <div style="display: flex; flex-direction: column; gap: 0.5rem;">
+                  <grid-filter v-for="key in filtersSequence"
+                               :key="key"
+                               :filter="filters[key]"
+                               ref="gridFilters">
+                  </grid-filter>
+                </div>
+
+                <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
+
+                  <b-button type="is-primary"
+                            native-type="submit"
+                            icon-pack="fas"
+                            icon-left="check">
+                    Apply Filters
+                  </b-button>
+
+                  <b-button v-if="!addFilterShow"
+                            icon-pack="fas"
+                            icon-left="plus"
+                            @click="addFilterInit()">
+                    Add Filter
+                  </b-button>
+
+                  <b-autocomplete v-if="addFilterShow"
+                                  ref="addFilterAutocomplete"
+                                  :data="addFilterChoices"
+                                  v-model="addFilterTerm"
+                                  placeholder="Add Filter"
+                                  field="key"
+                                  :custom-formatter="formatAddFilterItem"
+                                  open-on-focus
+                                  keep-first
+                                  icon-pack="fas"
+                                  clearable
+                                  clear-on-select
+                                  @select="addFilterSelect">
+                  </b-autocomplete>
+
+                  <b-button @click="resetView()"
+                            icon-pack="fas"
+                            icon-left="home">
+                    Default View
+                  </b-button>
+
+                  <b-button @click="clearFilters()"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    No Filters
+                  </b-button>
+
+                  % if allow_save_defaults and request.user:
+                      <b-button @click="saveDefaults()"
+                                icon-pack="fas"
+                                icon-left="save"
+                                :disabled="savingDefaults">
+                        {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
+                      </b-button>
+                  % endif
+
+                </div>
+              </form>
           % endif
-        </td>
+        </div>
+      </div>
 
-        <td class="menu">
+      <div style="display: flex; flex-direction: column; justify-content: space-between;">
+
+        <div class="context-menu">
           % if context_menu:
               <ul id="context-menu">
+                ## TODO: stop using |n filter
                 ${context_menu|n}
               </ul>
           % endif
-        </td>
-      </tr>
+        </div>
 
-      <tr>
-        <td class="tools">
+        <div class="grid-tools-wrapper">
           % if tools:
               <div class="grid-tools">
+                ## TODO: stop using |n filter
                 ${tools|n}
-              </div><!-- grid-tools -->
+              </div>
           % endif
-        </td>
-      </tr>
+        </div>
 
-    </tbody>
-  </table><!-- grid-header -->
+      </div>
 
-  ${grid.render_grid()|n}
+    </div>
 
-</div><!-- grid-wrapper -->
+    <${b}-table
+       :data="visibleData"
+       :loading="loading"
+       :row-class="getRowClass"
+       % if request.use_oruga:
+           tr-checked-class="is-checked"
+       % endif
+
+       % if request.rattail_config.getbool('tailbone', 'sticky_headers'):
+       sticky-header
+       height="600px"
+       % endif
+
+       :checkable="checkable"
+
+       % if getattr(grid, 'checkboxes', False):
+           % if request.use_oruga:
+               v-model:checked-rows="checkedRows"
+           % else:
+               :checked-rows.sync="checkedRows"
+           % endif
+           % if grid.clicking_row_checks_box:
+               @click="rowClick"
+           % endif
+       % endif
+
+       % if getattr(grid, 'check_handler', None):
+       @check="${grid.check_handler}"
+       % endif
+       % if getattr(grid, 'check_all_handler', None):
+       @check-all="${grid.check_all_handler}"
+       % endif
+
+       % if hasattr(grid, 'checkable'):
+       % if isinstance(grid.checkable, str):
+       :is-row-checkable="${grid.row_checkable}"
+       % elif grid.checkable:
+       :is-row-checkable="row => row._checkable"
+       % endif
+       % endif
+
+       ## sorting
+       % if grid.sortable:
+           ## nb. buefy/oruga only support *one* default sorter
+           :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
+           % if grid.sort_on_backend:
+               backend-sorting
+               @sort="onSort"
+           % endif
+           % if grid.sort_multiple:
+               % if grid.sort_on_backend:
+                   ## TODO: there is a bug (?) which prevents the arrow
+                   ## from displaying for simple default single-column sort,
+                   ## when multi-column sort is allowed for the table.  for
+                   ## now we work around that by waiting until mount to
+                   ## enable the multi-column support.  see also
+                   ## https://github.com/buefy/buefy/issues/2584
+                   :sort-multiple="allowMultiSort"
+                   :sort-multiple-data="sortingPriority"
+                   @sorting-priority-removed="sortingPriorityRemoved"
+               % else:
+                   sort-multiple
+               % endif
+               ## nb. user must ctrl-click column header for multi-sort
+               sort-multiple-key="ctrlKey"
+           % endif
+       % endif
+
+       % if getattr(grid, 'click_handlers', None):
+       @cellclick="cellClick"
+       % endif
+
+       ## paging
+       % if grid.paginated:
+           paginated
+           pagination-size="${'small' if request.use_oruga else 'is-small'}"
+           :per-page="perPage"
+           :current-page="currentPage"
+           @page-change="onPageChange"
+           % if grid.paginate_on_backend:
+               backend-pagination
+               :total="pagerStats.item_count"
+           % endif
+       % endif
+
+       ## TODO: should let grid (or master view) decide how to set these?
+       icon-pack="fas"
+       ## note that :striped="true" was interfering with row status (e.g. warning) styles
+       :striped="false"
+       :hoverable="true"
+       :narrowed="true">
+
+      % for column in grid.get_vue_columns():
+          <${b}-table-column field="${column['field']}"
+                          label="${column['label']}"
+                          v-slot="props"
+                          :sortable="${json.dumps(column.get('sortable', False))|n}"
+                          :searchable="${json.dumps(column.get('searchable', False))|n}"
+                          cell-class="c_${column['field']}"
+                          :visible="${json.dumps(column.get('visible', True))}">
+            % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers:
+                ${grid.raw_renderers[column['field']]()}
+            % elif grid.is_linked(column['field']):
+                <a :href="props.row._action_url_view"
+                   % if view_click_handler:
+                   @click.prevent="${view_click_handler}"
+                   % endif
+                   v-html="props.row.${column['field']}">
+                </a>
+            % else:
+                <span v-html="props.row.${column['field']}"></span>
+            % endif
+          </${b}-table-column>
+      % endfor
+
+      % if grid.actions:
+          <${b}-table-column field="actions"
+                          label="Actions"
+                          v-slot="props">
+            ## TODO: we do not currently differentiate for "main vs. more"
+            ## here, but ideally we would tuck "more" away in a drawer etc.
+            % for action in grid.actions:
+                <a v-if="props.row._action_url_${action.key}"
+                   :href="props.row._action_url_${action.key}"
+                   class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}"
+                   % if getattr(action, 'click_handler', None):
+                   @click.prevent="${action.click_handler}"
+                   % endif
+                   % if getattr(action, 'target', None):
+                   target="${action.target}"
+                   % endif
+                   >
+                  ${action.render_icon_and_label()}
+                </a>
+                &nbsp;
+            % endfor
+          </${b}-table-column>
+      % endif
+
+      <template #empty>
+        <section class="section">
+          <div class="content has-text-grey has-text-centered">
+            <p>
+              <b-icon
+                 pack="fas"
+                 icon="sad-tear"
+                 size="is-large">
+              </b-icon>
+            </p>
+            <p>Nothing here.</p>
+          </div>
+        </section>
+      </template>
+
+      <template #footer>
+        <div style="display: flex; justify-content: space-between;">
+
+          % if getattr(grid, 'expose_direct_link', False):
+              <b-button type="is-primary"
+                        size="is-small"
+                        @click="copyDirectLink()"
+                        title="Copy link to clipboard">
+                % if request.use_oruga:
+                    <o-icon icon="share-alt" />
+                % else:
+                    <span><i class="fa fa-share-alt"></i></span>
+                % endif
+              </b-button>
+          % else:
+              <div></div>
+          % endif
+
+          % if grid.paginated:
+              <div v-if="pagerStats.first_item"
+                   style="display: flex; gap: 0.5rem; align-items: center;">
+                <span>
+                  showing
+                  {{ renderNumber(pagerStats.first_item) }}
+                  - {{ renderNumber(pagerStats.last_item) }}
+                  of {{ renderNumber(pagerStats.item_count) }} results;
+                </span>
+                <b-select v-model="perPage"
+                          size="is-small"
+                          @input="perPageUpdated">
+                  % for value in grid.get_pagesize_options():
+                      <option value="${value}">${value}</option>
+                  % endfor
+                </b-select>
+                <span>
+                  per page
+                </span>
+              </div>
+          % endif
+
+        </div>
+      </template>
+
+    </${b}-table>
+
+    ## dummy input field needed for sharing links on *insecure* sites
+    % if getattr(request, 'scheme', None) == 'http':
+        <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input>
+    % endif
+
+  </div>
+</script>
+
+<script type="text/javascript">
+
+  const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n}
+  let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data
+
+  let ${grid.vue_component}Data = {
+      loading: false,
+      ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n},
+
+      ## nb. this tracks whether grid.fetchFirstData() happened
+      fetchedFirstData: false,
+
+      savingDefaults: false,
+
+      data: ${grid.vue_component}CurrentData,
+      rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n},
+
+      checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n},
+      % if getattr(grid, 'checkboxes', False):
+      checkedRows: ${grid_data['checked_rows_code']|n},
+      % endif
+
+      ## paging
+      % if grid.paginated:
+          pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
+          perPage: ${json.dumps(grid.pagesize)|n},
+          currentPage: ${json.dumps(grid.page)|n},
+          % if grid.paginate_on_backend:
+              pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n},
+          % endif
+      % endif
+
+      ## sorting
+      % if grid.sortable:
+          sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
+          % if grid.sort_multiple:
+              % if grid.sort_on_backend:
+                  ## TODO: there is a bug (?) which prevents the arrow
+                  ## from displaying for simple default single-column sort,
+                  ## when multi-column sort is allowed for the table.  for
+                  ## now we work around that by waiting until mount to
+                  ## enable the multi-column support.  see also
+                  ## https://github.com/buefy/buefy/issues/2584
+                  allowMultiSort: false,
+                  ## nb. this should be empty when current sort is single-column
+                  % if len(grid.active_sorters) > 1:
+                      sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n},
+                  % else:
+                      sortingPriority: [],
+                  % endif
+              % endif
+          % endif
+      % endif
+
+      ## filterable: ${json.dumps(grid.filterable)|n},
+      filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n},
+      filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n},
+      addFilterTerm: '',
+      addFilterShow: false,
+
+      ## dummy input value needed for sharing links on *insecure* sites
+      % if getattr(request, 'scheme', None) == 'http':
+      shareLink: null,
+      % endif
+  }
+
+  let ${grid.vue_component} = {
+      template: '#${grid.vue_tagname}-template',
+
+      mixins: [FormPosterMixin],
+
+      props: {
+          csrftoken: String,
+      },
+
+      computed: {
+
+          ## TODO: this should be temporary? but anyway 'total' is
+          ## still referenced in other places, e.g. "delete results"
+          % if grid.paginated:
+              total() { return this.pagerStats.item_count },
+          % endif
+
+          % if not grid.paginate_on_backend:
+
+              pagerStats() {
+                  const data = this.visibleData
+                  let last = this.currentPage * this.perPage
+                  let first = last - this.perPage + 1
+                  if (last > data.length) {
+                      last = data.length
+                  }
+                  return {
+                      'item_count': data.length,
+                      'items_per_page': this.perPage,
+                      'page': this.currentPage,
+                      'first_item': first,
+                      'last_item': last,
+                  }
+              },
+
+          % endif
+
+          addFilterChoices() {
+              // nb. this returns all choices available for "Add Filter" operation
+
+              // collect all filters, which are *not* already shown
+              let choices = []
+              for (let field of this.filtersSequence) {
+                  let filtr = this.filters[field]
+                  if (!filtr.visible) {
+                      choices.push(filtr)
+                  }
+              }
+
+              // parse list of search terms
+              let terms = []
+              for (let term of this.addFilterTerm.toLowerCase().split(' ')) {
+                  term = term.trim()
+                  if (term) {
+                      terms.push(term)
+                  }
+              }
+
+              // only filters matching all search terms are presented
+              // as choices to the user
+              return choices.filter(option => {
+                  let label = option.label.toLowerCase()
+                  for (let term of terms) {
+                      if (label.indexOf(term) < 0) {
+                          return false
+                      }
+                  }
+                  return true
+              })
+          },
+
+          // note, can use this with v-model for hidden 'uuids' fields
+          selected_uuids: function() {
+              return this.checkedRowUUIDs().join(',')
+          },
+
+          // nb. this can be overridden if needed, e.g. to dynamically
+          // show/hide certain records in a static data set
+          visibleData() {
+              return this.data
+          },
+
+          directLink() {
+              let params = new URLSearchParams(this.getAllParams())
+              return `${request.path_url}?${'$'}{params}`
+          },
+      },
+
+      % if grid.sortable and grid.sort_multiple and grid.sort_on_backend:
+
+            ## TODO: there is a bug (?) which prevents the arrow
+            ## from displaying for simple default single-column sort,
+            ## when multi-column sort is allowed for the table.  for
+            ## now we work around that by waiting until mount to
+            ## enable the multi-column support.  see also
+            ## https://github.com/buefy/buefy/issues/2584
+            mounted() {
+                this.allowMultiSort = true
+            },
+
+      % endif
+
+      methods: {
+
+          renderNumber(value) {
+              if (value != undefined) {
+                  return value.toLocaleString('en')
+              }
+          },
+
+          formatAddFilterItem(filtr) {
+              if (!filtr.key) {
+                  filtr = this.filters[filtr]
+              }
+              return filtr.label || filtr.key
+          },
+
+          % if getattr(grid, 'click_handlers', None):
+              cellClick(row, column, rowIndex, columnIndex) {
+                  % for key in grid.click_handlers:
+                      if (column._props.field == '${key}') {
+                          ${grid.click_handlers[key]}(row)
+                      }
+                  % endfor
+              },
+          % endif
+
+          copyDirectLink() {
+
+              if (navigator.clipboard) {
+                  // this is the way forward, but requires HTTPS
+                  navigator.clipboard.writeText(this.directLink)
+
+              } else {
+                  // use deprecated 'copy' command, but this just
+                  // tells the browser to copy currently-selected
+                  // text..which means we first must "add" some text
+                  // to screen, and auto-select that, before copying
+                  // to clipboard
+                  this.shareLink = this.directLink
+                  this.$nextTick(() => {
+                      let input = this.$refs.shareLink.$el.firstChild
+                      input.select()
+                      document.execCommand('copy')
+                      // re-hide the dummy input
+                      this.shareLink = null
+                  })
+              }
+
+              this.$buefy.toast.open({
+                  message: "Link was copied to clipboard",
+                  type: 'is-info',
+                  duration: 2000, // 2 seconds
+              })
+          },
+
+          addRowClass(index, className) {
+
+              // TODO: this may add duplicated name to class string
+              // (not a serious problem i think, but could be improved)
+              this.rowStatusMap[index] = (this.rowStatusMap[index] || '')
+                  + ' ' + className
+
+              // nb. for some reason b-table does not always "notice"
+              // when we update status; so we force it to refresh
+              this.$forceUpdate()
+          },
+
+          getRowClass(row, index) {
+              return this.rowStatusMap[index]
+          },
+
+          getBasicParams() {
+              const params = {
+                  % if grid.paginated and grid.paginate_on_backend:
+                      pagesize: this.perPage,
+                      page: this.currentPage,
+                  % endif
+              }
+              % if grid.sortable and grid.sort_on_backend:
+                  for (let i = 1; i <= this.sorters.length; i++) {
+                      params['sort'+i+'key'] = this.sorters[i-1].field
+                      params['sort'+i+'dir'] = this.sorters[i-1].order
+                  }
+              % endif
+              return params
+          },
+
+          getFilterParams() {
+              let params = {}
+              for (var key in this.filters) {
+                  var filter = this.filters[key]
+                  if (filter.active) {
+                      params[key] = filter.value
+                      params[key+'.verb'] = filter.verb
+                  }
+              }
+              if (Object.keys(params).length) {
+                  params.filter = true
+              }
+              return params
+          },
+
+          getAllParams() {
+              return {...this.getBasicParams(),
+                      ...this.getFilterParams()}
+          },
+
+          ## nb. this is meant to call for a grid which is hidden at
+          ## first, when it is first being shown to the user.  and if
+          ## it was initialized with empty data set.
+          async fetchFirstData() {
+              if (this.fetchedFirstData) {
+                  return
+              }
+              await this.loadAsyncData()
+              this.fetchedFirstData = true
+          },
+
+          ## TODO: i noticed buefy docs show using `async` keyword here,
+          ## so now i am too.  knowing nothing at all of if/how this is
+          ## supposed to improve anything.  we shall see i guess
+          async loadAsyncData(params, success, failure) {
+
+              if (params === undefined || params === null) {
+                  params = new URLSearchParams(this.getBasicParams())
+              } else {
+                  params = new URLSearchParams(params)
+              }
+              if (!params.has('partial')) {
+                  params.append('partial', true)
+              }
+              params = params.toString()
+
+              this.loading = true
+              this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => {
+                  if (!response.data.error) {
+                      ${grid.vue_component}CurrentData = response.data.data
+                      this.data = ${grid.vue_component}CurrentData
+                      % if grid.paginated and grid.paginate_on_backend:
+                          this.pagerStats = response.data.pager_stats
+                      % endif
+                      this.rowStatusMap = response.data.row_status_map || {}
+                      this.loading = false
+                      this.savingDefaults = false
+                      this.checkedRows = this.locateCheckedRows(response.data.checked_rows || [])
+                      if (success) {
+                          success()
+                      }
+                  } else {
+                      this.$buefy.toast.open({
+                          message: response.data.error,
+                          type: 'is-danger',
+                          duration: 2000, // 4 seconds
+                      })
+                      this.loading = false
+                      this.savingDefaults = false
+                      if (failure) {
+                          failure()
+                      }
+                  }
+              })
+              .catch((error) => {
+                  ${grid.vue_component}CurrentData = []
+                  this.data = []
+                  % if grid.paginated and grid.paginate_on_backend:
+                      this.pagerStats = {}
+                  % endif
+                  this.loading = false
+                  this.savingDefaults = false
+                  if (failure) {
+                      failure()
+                  }
+                  throw error
+              })
+          },
+
+          locateCheckedRows(checked) {
+              let rows = []
+              if (checked) {
+                  for (let i = 0; i < this.data.length; i++) {
+                      if (checked.includes(i)) {
+                          rows.push(this.data[i])
+                      }
+                  }
+              }
+              return rows
+          },
+
+          onPageChange(page) {
+              this.currentPage = page
+              this.loadAsyncData()
+          },
+
+          perPageUpdated(value) {
+
+              // nb. buefy passes value, oruga passes event
+              if (value.target) {
+                  value = event.target.value
+              }
+
+              this.loadAsyncData({
+                  pagesize: value,
+              })
+          },
+
+          % if grid.sortable and grid.sort_on_backend:
+
+              onSort(field, order, event) {
+
+                  ## nb. buefy passes field name; oruga passes field object
+                  % if request.use_oruga:
+                      field = field.field
+                  % endif
+
+                  % if grid.sort_multiple:
+
+                      // did user ctrl-click the column header?
+                      if (event.ctrlKey) {
+
+                          // toggle direction for existing, or add new sorter
+                          const sorter = this.sorters.filter(s => s.field === field)[0]
+                          if (sorter) {
+                              sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
+                          } else {
+                              this.sorters.push({field, order})
+                          }
+
+                          // apply multi-column sorting
+                          this.sortingPriority = this.sorters
+
+                      } else {
+
+                  % endif
+
+                  // sort by single column only
+                  this.sorters = [{field, order}]
+
+                  % if grid.sort_multiple:
+                          // multi-column sort not engaged
+                          this.sortingPriority = []
+                      }
+                  % endif
+
+                  // nb. always reset to first page when sorting changes
+                  this.currentPage = 1
+                  this.loadAsyncData()
+              },
+
+              % if grid.sort_multiple:
+
+                  sortingPriorityRemoved(field) {
+
+                      // prune from active sorters
+                      this.sorters = this.sorters.filter(s => s.field !== field)
+
+                      // nb. even though we might have just one sorter
+                      // now, we are still technically in multi-sort mode
+                      this.sortingPriority = this.sorters
+
+                      this.loadAsyncData()
+                  },
+
+              % endif
+
+          % endif
+
+          resetView() {
+              this.loading = true
+
+              // use current url proper, plus reset param
+              let url = '?reset-view=true'
+
+              // add current hash, to preserve that in redirect
+              if (location.hash) {
+                  url += '&hash=' + location.hash.slice(1)
+              }
+
+              location.href = url
+          },
+
+          addFilterInit() {
+              this.addFilterShow = true
+
+              this.$nextTick(() => {
+                  const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
+                  input.addEventListener('keydown', this.addFilterKeydown)
+                  this.$refs.addFilterAutocomplete.focus()
+              })
+          },
+
+          addFilterHide() {
+              const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
+              input.removeEventListener('keydown', this.addFilterKeydown)
+              this.addFilterTerm = ''
+              this.addFilterShow = false
+          },
+
+          addFilterKeydown(event) {
+
+              // ESC will clear searchbox
+              if (event.which == 27) {
+                  this.addFilterHide()
+              }
+          },
+
+          addFilterSelect(filtr) {
+              this.addFilter(filtr.key)
+              this.addFilterHide()
+          },
+
+          addFilter(filter_key) {
+
+              // show corresponding grid filter
+              this.filters[filter_key].visible = true
+              this.filters[filter_key].active = true
+
+              // track down the component
+              var gridFilter = null
+              for (var gf of this.$refs.gridFilters) {
+                  if (gf.filter.key == filter_key) {
+                      gridFilter = gf
+                      break
+                  }
+              }
+
+              // tell component to focus the value field, ASAP
+              this.$nextTick(function() {
+                  gridFilter.focusValue()
+              })
+
+          },
+
+          applyFilters(params) {
+              if (params === undefined) {
+                  params = {}
+              }
+
+              // merge in actual filter params
+              // cf. https://stackoverflow.com/a/171256
+              params = {...params, ...this.getFilterParams()}
+
+              // hide inactive filters
+              for (var key in this.filters) {
+                  var filter = this.filters[key]
+                  if (!filter.active) {
+                      filter.visible = false
+                  }
+              }
+
+              // set some explicit params
+              params.partial = true
+              params.filter = true
+
+              params = new URLSearchParams(params)
+              this.loadAsyncData(params)
+              this.appliedFiltersHook()
+          },
+
+          appliedFiltersHook() {},
+
+          clearFilters() {
+
+              // explicitly deactivate all filters
+              for (var key in this.filters) {
+                  this.filters[key].active = false
+              }
+
+              // then just "apply" as normal
+              this.applyFilters()
+          },
+
+          // explicitly set filters for the grid, to the given set.
+          // this totally overrides whatever might be current.  the
+          // new filter set should look like:
+          //
+          //     [
+          //         {key: 'status_code',
+          //          verb: 'equal',
+          //          value: 1},
+          //         {key: 'description',
+          //          verb: 'contains',
+          //          value: 'whatever'},
+          //     ]
+          //
+          setFilters(newFilters) {
+              for (let key in this.filters) {
+                  let filter = this.filters[key]
+                  let active = false
+                  for (let newFilter of newFilters) {
+                      if (newFilter.key == key) {
+                          active = true
+                          filter.active = true
+                          filter.visible = true
+                          filter.verb = newFilter.verb
+                          filter.value = newFilter.value
+                          break
+                      }
+                  }
+                  if (!active) {
+                      filter.active = false
+                      filter.visible = false
+                  }
+              }
+              this.applyFilters()
+          },
+
+          saveDefaults() {
+              this.savingDefaults = true
+
+              // apply current filters as normal, but add special directive
+              this.applyFilters({'save-current-filters-as-defaults': true})
+          },
+
+          deleteObject(event) {
+              // we let parent component/app deal with this, in whatever way makes sense...
+              // TODO: should we ever provide anything besides the URL for this?
+              this.$emit('deleteActionClicked', event.target.href)
+          },
+
+          checkedRowUUIDs() {
+              let uuids = []
+              for (let row of this.$data.checkedRows) {
+                  uuids.push(row.uuid)
+              }
+              return uuids
+          },
+
+          allRowUUIDs() {
+              let uuids = []
+              for (let row of this.data) {
+                  uuids.push(row.uuid)
+              }
+              return uuids
+          },
+
+          // when a user clicks a row, handle as if they clicked checkbox.
+          // note that this method is only used if table is "checkable"
+          rowClick(row) {
+              let i = this.checkedRows.indexOf(row)
+              if (i >= 0) {
+                  this.checkedRows.splice(i, 1)
+              } else {
+                  this.checkedRows.push(row)
+              }
+              % if getattr(grid, 'check_handler', None):
+              this.${grid.check_handler}(this.checkedRows, row)
+              % endif
+          },
+      }
+  }
+
+</script>
diff --git a/tailbone/templates/grids/filter-components.mako b/tailbone/templates/grids/filter-components.mako
new file mode 100644
index 00000000..e4915065
--- /dev/null
+++ b/tailbone/templates/grids/filter-components.mako
@@ -0,0 +1,350 @@
+## -*- coding: utf-8; -*-
+
+<%def name="make_grid_filter_components()">
+  ${self.make_grid_filter_numeric_value_component()}
+  ${self.make_grid_filter_date_value_component()}
+  ${self.make_grid_filter_component()}
+</%def>
+
+<%def name="make_grid_filter_numeric_value_component()">
+  <% request.register_component('grid-filter-numeric-value', 'GridFilterNumericValue') %>
+  <script type="text/x-template" id="grid-filter-numeric-value-template">
+    <div class="level">
+      <div class="level-left">
+        <div class="level-item">
+          <b-input v-model="startValue"
+                   ref="startValue"
+                   @input="startValueChanged">
+          </b-input>
+        </div>
+        <div v-show="wantsRange"
+             class="level-item">
+          and
+        </div>
+        <div v-show="wantsRange"
+             class="level-item">
+          <b-input v-model="endValue"
+                   ref="endValue"
+                   @input="endValueChanged">
+          </b-input>
+        </div>
+      </div>
+    </div>
+  </script>
+  <script>
+
+    const GridFilterNumericValue = {
+        template: '#grid-filter-numeric-value-template',
+        props: {
+            ${'modelValue' if request.use_oruga else 'value'}: String,
+            wantsRange: Boolean,
+        },
+        data() {
+            const value = this.${'modelValue' if request.use_oruga else 'value'}
+            const {startValue, endValue} = this.parseValue(value)
+            return {
+                startValue,
+                endValue,
+            }
+        },
+        watch: {
+            // when changing from e.g. 'equal' to 'between' filter verbs,
+            // must proclaim new filter value, to reflect (lack of) range
+            wantsRange(val) {
+                if (val) {
+                    this.$emit('input', this.startValue + '|' + this.endValue)
+                } else {
+                    this.$emit('input', this.startValue)
+                }
+            },
+
+            ${'modelValue' if request.use_oruga else 'value'}(to, from) {
+                const parsed = this.parseValue(to)
+                this.startValue = parsed.startValue
+                this.endValue = parsed.endValue
+            },
+        },
+        methods: {
+            focus() {
+                this.$refs.startValue.focus()
+            },
+            startValueChanged(value) {
+                if (this.wantsRange) {
+                    value += '|' + this.endValue
+                }
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
+            },
+            endValueChanged(value) {
+                value = this.startValue + '|' + value
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
+            },
+
+            parseValue(value) {
+                let startValue = null
+                let endValue = null
+                if (this.wantsRange) {
+                    if (value.includes('|')) {
+                        let values = value.split('|')
+                        if (values.length == 2) {
+                            startValue = values[0]
+                            endValue = values[1]
+                        } else {
+                            startValue = value
+                        }
+                    } else {
+                        startValue = value
+                    }
+                } else {
+                    startValue = value
+                }
+
+                return {
+                    startValue,
+                    endValue,
+                }
+            },
+        },
+    }
+
+    Vue.component('grid-filter-numeric-value', GridFilterNumericValue)
+
+  </script>
+</%def>
+
+<%def name="make_grid_filter_date_value_component()">
+  <% request.register_component('grid-filter-date-value', 'GridFilterDateValue') %>
+  <script type="text/x-template" id="grid-filter-date-value-template">
+    <div class="level">
+      <div class="level-left">
+        <div class="level-item">
+          <tailbone-datepicker v-model="startDate"
+                               ref="startDate"
+                               @${'update:model-value' if request.use_oruga else 'input'}="startDateChanged">
+          </tailbone-datepicker>
+        </div>
+        <div v-show="dateRange"
+             class="level-item">
+          and
+        </div>
+        <div v-show="dateRange"
+             class="level-item">
+          <tailbone-datepicker v-model="endDate"
+                               ref="endDate"
+                               @${'update:model-value' if request.use_oruga else 'input'}="endDateChanged">
+          </tailbone-datepicker>
+        </div>
+      </div>
+    </div>
+  </script>
+  <script>
+
+    const GridFilterDateValue = {
+        template: '#grid-filter-date-value-template',
+        props: {
+            ${'modelValue' if request.use_oruga else 'value'}: String,
+            dateRange: Boolean,
+        },
+        data() {
+            let startDate = null
+            let endDate = null
+            let value = this.${'modelValue' if request.use_oruga else 'value'}
+            if (value) {
+
+                if (this.dateRange) {
+                    let values = value.split('|')
+                    if (values.length == 2) {
+                        startDate = this.parseDate(values[0])
+                        endDate = this.parseDate(values[1])
+                    } else {    // no end date specified?
+                        startDate = this.parseDate(value)
+                    }
+
+                } else {        // not a range, so start date only
+                    startDate = this.parseDate(value)
+                }
+            }
+
+            return {
+                startDate,
+                endDate,
+            }
+        },
+        methods: {
+            focus() {
+                this.$refs.startDate.focus()
+            },
+            formatDate(date) {
+                if (date === null) {
+                    return null
+                }
+                if (typeof(date) == 'string') {
+                    return date
+                }
+                // just need to convert to simple ISO date format here, seems
+                // like there should be a more obvious way to do that?
+                var year = date.getFullYear()
+                var month = date.getMonth() + 1
+                var day = date.getDate()
+                month = month < 10 ? '0' + month : month
+                day = day < 10 ? '0' + day : day
+                return year + '-' + month + '-' + day
+            },
+            parseDate(value) {
+                if (value) {
+                    // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
+                    const parts = value.split('-')
+                    return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+                }
+            },
+            startDateChanged(value) {
+                value = this.formatDate(value)
+                if (this.dateRange) {
+                    value += '|' + this.formatDate(this.endDate)
+                }
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
+            },
+            endDateChanged(value) {
+                value = this.formatDate(this.startDate) + '|' + this.formatDate(value)
+                this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
+            },
+        },
+    }
+
+    Vue.component('grid-filter-date-value', GridFilterDateValue)
+
+  </script>
+</%def>
+
+<%def name="make_grid_filter_component()">
+  <% request.register_component('grid-filter', 'GridFilter') %>
+  <script type="text/x-template" id="grid-filter-template">
+    <div class="filter"
+         v-show="filter.visible"
+         style="display: flex; gap: 0.5rem;">
+
+        <div class="filter-fieldname">
+          <b-button @click="filter.active = !filter.active"
+                    icon-pack="fas"
+                    :icon-left="filter.active ? 'check' : null">
+            {{ filter.label }}
+          </b-button>
+        </div>
+
+        <div v-show="filter.active"
+             style="display: flex; gap: 0.5rem;">
+
+          <b-select v-model="filter.verb"
+                    @input="focusValue()"
+                    class="filter-verb">
+            <option v-for="verb in filter.verbs"
+                    :key="verb"
+                    :value="verb">
+              {{ filter.verb_labels[verb] }}
+            </option>
+          </b-select>
+
+          ## only one of the following "value input" elements will be rendered
+
+          <grid-filter-date-value v-if="filter.data_type == 'date'"
+                                  v-model="filter.value"
+                                  v-show="valuedVerb()"
+                                  :date-range="filter.verb == 'between'"
+                                  ref="valueInput">
+          </grid-filter-date-value>
+
+          <b-select v-if="filter.data_type == 'choice'"
+                    v-model="filter.value"
+                    v-show="valuedVerb()"
+                    ref="valueInput">
+            <option v-for="choice in filter.choices"
+                    :key="choice"
+                    :value="choice">
+              {{ filter.choice_labels[choice] || choice }}
+            </option>
+          </b-select>
+
+          <grid-filter-numeric-value v-if="filter.data_type == 'number'"
+                                    v-model="filter.value"
+                                    v-show="valuedVerb()"
+                                    :wants-range="filter.verb == 'between'"
+                                    ref="valueInput">
+          </grid-filter-numeric-value>
+
+          <b-input v-if="filter.data_type == 'string' && !multiValuedVerb()"
+                   v-model="filter.value"
+                   v-show="valuedVerb()"
+                   ref="valueInput">
+          </b-input>
+
+          <b-input v-if="filter.data_type == 'string' && multiValuedVerb()"
+                   type="textarea"
+                   v-model="filter.value"
+                   v-show="valuedVerb()"
+                   ref="valueInput">
+          </b-input>
+
+        </div>
+    </div>
+  </script>
+  <script>
+
+    const GridFilter = {
+        template: '#grid-filter-template',
+        props: {
+            filter: Object
+        },
+
+        methods: {
+
+            changeVerb() {
+                // set focus to value input, "as quickly as we can"
+                this.$nextTick(function() {
+                    this.focusValue()
+                })
+            },
+
+            valuedVerb() {
+                /* this returns true if the filter's current verb should expose value input(s) */
+
+                // if filter has no "valueless" verbs, then all verbs should expose value inputs
+                if (!this.filter.valueless_verbs) {
+                    return true
+                }
+
+                // if filter *does* have valueless verbs, check if "current" verb is valueless
+                if (this.filter.valueless_verbs.includes(this.filter.verb)) {
+                    return false
+                }
+
+                // current verb is *not* valueless
+                return true
+            },
+
+            multiValuedVerb() {
+                /* this returns true if the filter's current verb should expose a multi-value input */
+
+                // if filter has no "multi-value" verbs then we safely assume false
+                if (!this.filter.multiple_value_verbs) {
+                    return false
+                }
+
+                // if filter *does* have multi-value verbs, see if "current" is one
+                if (this.filter.multiple_value_verbs.includes(this.filter.verb)) {
+                    return true
+                }
+
+                // current verb is not multi-value
+                return false
+            },
+
+            focusValue: function() {
+                this.$refs.valueInput.focus()
+                // this.$refs.valueInput.select()
+            }
+        }
+    }
+
+    Vue.component('grid-filter', GridFilter)
+
+  </script>
+</%def>
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
deleted file mode 100644
index 857f53b1..00000000
--- a/tailbone/templates/grids/filters.mako
+++ /dev/null
@@ -1,38 +0,0 @@
-## -*- coding: utf-8; -*-
-<div class="newfilters">
-
-  ${h.form(form.action_url, method='get')}
-    ${h.hidden('reset-to-default-filters', value='false')}
-    ${h.hidden('save-current-filters-as-defaults', value='false')}
-
-    <fieldset>
-      <legend>Filters</legend>
-      % for filtr in form.iter_filters():
-          <div class="filter" id="filter-${filtr.key}" data-key="${filtr.key}"${' style="display: none;"' if not filtr.active else ''|n}>
-            ${h.checkbox('{}-active'.format(filtr.key), class_='active', id='filter-active-{}'.format(filtr.key), checked=filtr.active)}
-            <label for="filter-active-${filtr.key}">${filtr.label}</label>
-            <div class="inputs" style="display: inline-block;">
-              ${form.filter_verb(filtr)}
-              ${form.filter_value(filtr)}
-            </div>
-          </div>
-      % endfor
-    </fieldset>
-
-    <div class="buttons">
-      <button type="submit" id="apply-filters">Apply Filters</button>
-      <select id="add-filter">
-        <option value="">Add a Filter</option>
-        % for filtr in form.iter_filters():
-            <option value="${filtr.key}"${' disabled="disabled"' if filtr.active else ''|n}>${filtr.label}</option>
-        % endfor
-      </select>
-      <button type="button" id="default-filters">Default View</button>
-      <button type="button" id="clear-filters">No Filters</button>
-      % if allow_save_defaults and request.user:
-          <button type="button" id="save-defaults">Save Defaults</button>
-      % endif
-    </div>
-
-  ${h.end_form()}
-</div><!-- newfilters -->
diff --git a/tailbone/templates/grids/filters_buefy.mako b/tailbone/templates/grids/filters_buefy.mako
deleted file mode 100644
index 914c98d4..00000000
--- a/tailbone/templates/grids/filters_buefy.mako
+++ /dev/null
@@ -1,59 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<form action="${form.action_url}" method="GET" v-on:submit.prevent="applyFilters()">
-
-  <grid-filter v-for="key in filtersSequence"
-               :key="key"
-               :filter="filters[key]"
-               ref="gridFilters">
-  </grid-filter>
-
-  <b-field grouped>
-
-    <b-button type="is-primary"
-              native-type="submit"
-              icon-pack="fas"
-              icon-left="check"
-              class="control">
-      Apply Filters
-    </b-button>
-
-    <b-select @input="addFilter"
-              placeholder="Add Filter"
-              v-model="selectedFilter">
-      <option v-for="key in filtersSequence"
-              :key="key"
-              :value="key"
-              ## TODO: previous code here was simpler; trying to track down
-              ## why disabled options don't appear so on Windows Chrome (?)
-              :disabled="filters[key].visible ? 'disabled' : null">
-        {{ filters[key].label }}
-      </option>
-    </b-select>
-
-    <b-button @click="resetView()"
-              icon-pack="fas"
-              icon-left="home"
-              class="control">
-      Default View
-    </b-button>
-
-    <b-button @click="clearFilters()"
-              icon-pack="fas"
-              icon-left="trash"
-              class="control">
-      No Filters
-    </b-button>
-
-    % if allow_save_defaults and request.user:
-        <b-button @click="saveDefaults()"
-                  icon-pack="fas"
-                  icon-left="save"
-                  class="control">
-          Save Defaults
-        </b-button>
-    % endif
-
-  </b-field>
-
-</form>
diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako
deleted file mode 100644
index 146fcab6..00000000
--- a/tailbone/templates/grids/grid.mako
+++ /dev/null
@@ -1,21 +0,0 @@
-## -*- coding: utf-8; -*-
-<div class="grid ${grid_class}" data-delete-speedbump="${'true' if grid.delete_speedbump else 'false'}" ${h.HTML.render_attrs(grid_attrs)}>
-  <table>
-    ${grid.make_webhelpers_grid()}
-  </table>
-  % if grid.pageable and grid.pager:
-      <div class="pager">
-        <p class="showing">
-          ${"showing {} thru {} of {:,d}".format(grid.pager.first_item, grid.pager.last_item, grid.pager.item_count)}
-          % if grid.pager.page_count > 1:
-              ${"(page {} of {:,d})".format(grid.pager.page, grid.pager.page_count)}
-          % endif
-        </p>
-        <p class="page-links">
-          ${h.select('pagesize', grid.pager.items_per_page, grid.get_pagesize_options())}
-          per page&nbsp;
-          ${grid.pager.pager('$link_first $link_previous ~1~ $link_next $link_last', symbol_next='next', symbol_previous='prev')|n}
-        </p>
-      </div>
-  % endif
-</div>
diff --git a/tailbone/templates/grids/search.mako b/tailbone/templates/grids/search.mako
deleted file mode 100644
index fbb030f9..00000000
--- a/tailbone/templates/grids/search.mako
+++ /dev/null
@@ -1,37 +0,0 @@
-## -*- coding: utf-8 -*-
-<div class="filters" url="${search.request.current_route_url()}">
-  ${search.begin()}
-  ${search.hidden('filters', 'true')}
-  <% visible = [] %>
-  % for f in search.sorted_filters():
-      <div class="filter" id="filter-${f.name}"${' style="display: none;"' if not search.config.get('include_filter_'+f.name) else ''|n}>
-        ${search.checkbox('include_filter_'+f.name)}
-        <label for="${f.name}">${f.label}</label>
-        ${f.types_select()}
-        <div class="value">
-          ${f.value_control()}
-        </div>
-      </div>
-      % if search.config.get('include_filter_'+f.name):
-          <% visible.append(f.name) %>
-      % endif
-  % endfor
-  <div class="buttons">
-    ${search.add_filter(visible)}
-    ${search.submit('submit', "Search", style='display: none;' if not visible else None)}
-    <button type="reset"${' style="display: none;"' if not visible else ''|n}>Reset</button>
-  </div>
-  ${search.end()}
-  % if visible:
-      <script language="javascript" type="text/javascript">
-        filters_to_disable = [
-          % for field in visible:
-              '${field}',
-          % endfor
-        ];
-        $(function() {
-            disable_filter_options();
-        });
-      </script>
-  % endif
-</div>
diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako
new file mode 100644
index 00000000..625f046b
--- /dev/null
+++ b/tailbone/templates/grids/vue_template.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/grids/complete.mako" />
+${parent.body()}
diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako
index e4f7d072..54e44d57 100644
--- a/tailbone/templates/home.mako
+++ b/tailbone/templates/home.mako
@@ -1,33 +1,7 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/page.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">Home</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-    .logo {
-        text-align: center;
-    }
-    .logo img {
-        margin: 3em auto;
-        max-height: 350px;
-        max-width: 800px;
-    }
-  </style>
-</%def>
+<%inherit file="wuttaweb:templates/home.mako" />
 
+## DEPRECATED; remains for back-compat
 <%def name="render_this_page()">
   ${self.page_content()}
 </%def>
-
-<%def name="page_content()">
-  <div class="logo">
-    ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-    <h1>Welcome to ${base_meta.app_title()}</h1>
-  </div>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/ifps-plu-codes/index.mako b/tailbone/templates/ifps-plu-codes/index.mako
new file mode 100644
index 00000000..3f014343
--- /dev/null
+++ b/tailbone/templates/ifps-plu-codes/index.mako
@@ -0,0 +1,10 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="context_menu_items()">
+  ${parent.context_menu_items()}
+  <li>${h.link_to("Go to IFPS Website", 'https://www.ifpsglobal.com/PLU-Codes/PLU-codes-Search', target='_blank')}</li>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako
new file mode 100644
index 00000000..2445341d
--- /dev/null
+++ b/tailbone/templates/importing/configure.mako
@@ -0,0 +1,205 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+  ${h.hidden('handlers', **{':value': 'JSON.stringify(handlersData)'})}
+
+  <h3 class="is-size-3">Designated Handlers</h3>
+
+  <${b}-table :data="handlersData"
+           narrowed
+           icon-pack="fas"
+           :default-sort="['host_title', 'asc']">
+    <${b}-table-column field="host_title"
+                    label="Data Source"
+                    v-slot="props"
+                    sortable>
+      {{ props.row.host_title }}
+    </${b}-table-column>
+    <${b}-table-column field="local_title"
+                    label="Data Target"
+                    v-slot="props"
+                    sortable>
+      {{ props.row.local_title }}
+    </${b}-table-column>
+    <${b}-table-column field="direction"
+                    label="Direction"
+                    v-slot="props"
+                    sortable>
+      {{ props.row.direction_display }}
+    </${b}-table-column>
+    <${b}-table-column field="handler_spec"
+                    label="Handler Spec"
+                    v-slot="props"
+                    sortable>
+      {{ props.row.handler_spec }}
+    </${b}-table-column>
+    <${b}-table-column field="cmd"
+                    label="Command"
+                    v-slot="props"
+                    sortable>
+      {{ props.row.command }} {{ props.row.subcommand }}
+    </${b}-table-column>
+    <${b}-table-column field="runas"
+                    label="Default Runas"
+                    v-slot="props"
+                    sortable>
+      {{ props.row.default_runas }}
+    </${b}-table-column>
+    <${b}-table-column label="Actions"
+                    v-slot="props">
+      <a href="#" class="grid-action"
+         @click.prevent="editHandler(props.row)">
+        % if request.use_oruga:
+            <o-icon icon="edit" />
+        % else:
+        <i class="fas fa-edit"></i>
+        % endif
+        Edit
+      </a>
+    </${b}-table-column>
+    <template #empty>
+      <section class="section">
+        <div class="content has-text-grey has-text-centered">
+          <p>
+            <b-icon
+               pack="fas"
+               icon="sad-tear"
+               size="is-large">
+            </b-icon>
+          </p>
+          <p>Nothing here.</p>
+        </div>
+      </section>
+    </template>
+  </${b}-table>
+  
+  <b-modal :active.sync="editHandlerShowDialog">
+    <div class="card">
+      <div class="card-content">
+
+        <b-field :label="editingHandlerDirection" horizontal expanded>
+          {{ editingHandlerHostTitle }} -> {{ editingHandlerLocalTitle }}
+        </b-field>
+
+        <b-field label="Handler Spec"
+                 :type="editingHandlerSpec ? null : 'is-danger'">
+          <b-select v-model="editingHandlerSpec">
+            <option v-for="option in editingHandlerSpecOptions"
+                    :key="option"
+                    :value="option">
+              {{ option }}
+            </option>
+          </b-select>
+        </b-field>
+
+        <b-field grouped>
+          
+          <b-field label="Command"
+                   :type="editingHandlerCommand ? null : 'is-danger'">
+            <div class="level">
+              <div class="level-left">
+                <div class="level-item" style="margin-right: 0;">
+                  bin/
+                </div>
+                <div class="level-item" style="margin-left: 0;">
+                  <b-input v-model="editingHandlerCommand">
+                  </b-input>
+                </div>
+              </div>
+            </div>
+          </b-field>
+
+          <b-field label="Subcommand"
+                   :type="editingHandlerSubcommand ? null : 'is-danger'">
+            <b-input v-model="editingHandlerSubcommand">
+            </b-input>
+          </b-field>
+
+          <b-field label="Default Runas">
+            <b-input v-model="editingHandlerRunas">
+            </b-input>
+          </b-field>
+
+        </b-field>
+
+        <b-field grouped>
+
+          <b-button @click="editHandlerShowDialog = false"
+                    class="control">
+            Cancel
+          </b-button>
+
+          <b-button type="is-primary"
+                    class="control"
+                    @click="updateHandler()"
+                    :disabled="updateHandlerDisabled">
+            Update Handler
+          </b-button>
+
+        </b-field>
+
+      </div>
+    </div>
+  </b-modal>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.handlersData = ${json.dumps(handlers_data)|n}
+
+    ThisPageData.editHandlerShowDialog = false
+    ThisPageData.editingHandler = null
+    ThisPageData.editingHandlerHostTitle = null
+    ThisPageData.editingHandlerLocalTitle = null
+    ThisPageData.editingHandlerDirection = 'import'
+    ThisPageData.editingHandlerSpec = null
+    ThisPageData.editingHandlerSpecOptions = []
+    ThisPageData.editingHandlerCommand = null
+    ThisPageData.editingHandlerSubcommand = null
+    ThisPageData.editingHandlerRunas = null
+
+    ThisPage.computed.updateHandlerDisabled = function() {
+        if (!this.editingHandlerSpec) {
+            return true
+        }
+        if (!this.editingHandlerCommand) {
+            return true
+        }
+        if (!this.editingHandlerSubcommand) {
+            return true
+        }
+        return false
+    }
+
+    ThisPage.methods.editHandler = function(row) {
+        this.editingHandler = row
+
+        this.editingHandlerHostTitle = row.host_title
+        this.editingHandlerLocalTitle = row.local_title
+        this.editingHandlerDirection = row.direction_display
+        this.editingHandlerSpec = row.handler_spec
+        this.editingHandlerSpecOptions = row.spec_options
+        this.editingHandlerCommand = row.command
+        this.editingHandlerSubcommand = row.subcommand
+        this.editingHandlerRunas = row.default_runas
+
+        this.editHandlerShowDialog = true
+    }
+
+    ThisPage.methods.updateHandler = function() {
+        let row = this.editingHandler
+
+        row.handler_spec = this.editingHandlerSpec
+        row.command = this.editingHandlerCommand
+        row.subcommand = this.editingHandlerSubcommand
+        row.default_runas = this.editingHandlerRunas
+
+        this.settingsNeedSaved = true
+        this.editHandlerShowDialog = false
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/importing/index.mako b/tailbone/templates/importing/index.mako
new file mode 100644
index 00000000..c2d9c6ec
--- /dev/null
+++ b/tailbone/templates/importing/index.mako
@@ -0,0 +1,12 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="render_grid_component()">
+  <p class="block">
+    ${request.rattail_config.get_app().get_title()} can run import / export jobs for the following:
+  </p>
+  ${parent.render_grid_component()}
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako
new file mode 100644
index 00000000..a9625bc3
--- /dev/null
+++ b/tailbone/templates/importing/runjob.mako
@@ -0,0 +1,88 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/form.mako" />
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+
+    .tailbone-markdown p {
+        margin-bottom: 1.5rem;
+        margin-top: 1rem;
+    }
+
+  </style>
+</%def>
+
+<%def name="title()">
+  Run ${handler.direction.capitalize()}:&nbsp; ${handler.get_generic_title()}
+</%def>
+
+<%def name="context_menu_items()">
+  ${parent.context_menu_items()}
+  % if master.has_perm('view'):
+      <li>${h.link_to("View this {}".format(model_title), action_url('view', handler_info))}</li>
+  % endif
+</%def>
+
+<%def name="render_this_page()">
+  % if 'rattail.importing.runjob.notes' in request.session:
+      <b-notification type="is-info tailbone-markdown">
+        ${request.session['rattail.importing.runjob.notes']|n}
+      </b-notification>
+      <% del request.session['rattail.importing.runjob.notes'] %>
+  % endif
+
+  ${parent.render_this_page()}
+</%def>
+
+<%def name="render_form_buttons()">
+  <br />
+  ${h.hidden('runjob', **{':value': 'runJob'})}
+  <div class="buttons">
+    <once-button tag="a" href="${form.cancel_url or request.get_referrer()}"
+                 text="Cancel">
+    </once-button>
+    <b-button type="is-primary"
+              @click="submitRun()"
+              % if handler.safe_for_web_app:
+              :disabled="submittingRun"
+              % else:
+              disabled
+              title="Handler is not (yet) safe to run with this tool"
+              % endif
+              icon-pack="fas"
+              icon-left="arrow-circle-right">
+      {{ submittingRun ? "Working, please wait..." : "Run this ${handler.direction.capitalize()}" }}
+    </b-button>
+    <b-button @click="submitExplain()"
+              :disabled="submittingExplain"
+              icon-pack="fas"
+              icon-left="question-circle">
+      {{ submittingExplain ? "Working, please wait..." : "Just show me the notes" }}
+    </b-button>
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ${form.vue_component}Data.submittingRun = false
+    ${form.vue_component}Data.submittingExplain = false
+    ${form.vue_component}Data.runJob = false
+
+    ${form.vue_component}.methods.submitRun = function() {
+        this.submittingRun = true
+        this.runJob = true
+        this.$nextTick(() => {
+            this.$refs.${form.vue_component}.submit()
+        })
+    }
+
+    ${form.vue_component}.methods.submitExplain = function() {
+        this.submittingExplain = true
+        this.$refs.${form.vue_component}.submit()
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/importing/view.mako b/tailbone/templates/importing/view.mako
new file mode 100644
index 00000000..3a28737c
--- /dev/null
+++ b/tailbone/templates/importing/view.mako
@@ -0,0 +1,22 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="object_helpers()">
+  ${parent.object_helpers()}
+  % if master.has_perm('runjob'):
+      <nav class="panel">
+        <p class="panel-heading">Tools</p>
+        <div class="panel-block buttons">
+          <once-button type="is-primary"
+                       tag="a" href="${url('{}.runjob'.format(route_prefix), key=handler.get_key())}"
+                       icon-pack="fas"
+                       icon-left="arrow-circle-right"
+                       text="Run ${handler.direction.capitalize()} Job">
+          </once-button>
+        </div>
+      </nav>  
+  % endif
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/labels/profiles/view.mako b/tailbone/templates/labels/profiles/view.mako
index 2609ffbf..b93570af 100644
--- a/tailbone/templates/labels/profiles/view.mako
+++ b/tailbone/templates/labels/profiles/view.mako
@@ -35,7 +35,7 @@
         % for name, display in printer.required_settings.items():
             <div class="field-wrapper">
               <label>${display}</label>
-              <div class="field">${instance.get_printer_setting(name) or ''}</div>
+              <div class="field">${label_handler.get_printer_setting(instance, name) or ''}</div>
             </div>
         % endfor
       </div>
diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako
index 0e65b4ad..d2ea7828 100644
--- a/tailbone/templates/login.mako
+++ b/tailbone/templates/login.mako
@@ -1,76 +1,17 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/form.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">Login</%def>
-
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/login.js'))}
-</%def>
+<%inherit file="wuttaweb:templates/auth/login.mako" />
 
+## TODO: this will not be needed with wuttaform
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if use_buefy:
-      <style type="text/css">
-        .logo img {
-            display: block;
-            margin: 3rem auto;
-            max-height: 350px;
-            max-width: 800px;
-        }
-
-        /* must force a particular label with, in order to make sure */
-        /* the username and password inputs are the same size */
-        .field.is-horizontal .field-label .label {
-            text-align: left;
-            width: 6rem;
-        }
-
-        .buttons {
-            justify-content: right;
-        }
-      </style>
-  % else:
-      ${h.stylesheet_link(request.static_url('tailbone:static/css/login.css'))}
-  % endif
-</%def>
-
-<%def name="logo()">
-  ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-</%def>
-
-<%def name="login_form()">
-  <div class="form">
-    ${form.render_deform(form_kwargs={'data-ajax': 'false'})|n}
-  </div>
+  <style>
+    .card-content .buttons {
+        justify-content: right;
+    }
+  </style>
 </%def>
 
+## DEPRECATED; remains for back-compat
 <%def name="render_this_page()">
   ${self.page_content()}
 </%def>
-
-<%def name="page_content()">
-  <div class="logo">
-    ${self.logo()}
-  </div>
-
-  % if use_buefy:
-
-      <div class="columns is-centered">
-        <div class="column is-narrow">
-          <div class="card">
-            <div class="card-content">
-              <tailbone-form></tailbone-form>
-            </div>
-          </div>
-        </div>
-      </div>
-
-  % else:
-      ${self.login_form()}
-  % endif
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako
new file mode 100644
index 00000000..de364828
--- /dev/null
+++ b/tailbone/templates/luigi/configure.mako
@@ -0,0 +1,427 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+  ${h.hidden('overnight_tasks', **{':value': 'JSON.stringify(overnightTasks)'})}
+  ${h.hidden('backfill_tasks', **{':value': 'JSON.stringify(backfillTasks)'})}
+
+  <div class="level">
+    <div class="level-left">
+      <div class="level-item">
+        <h3 class="is-size-3">Overnight Tasks</h3>
+      </div>
+      <div class="level-item">
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="plus"
+                  @click="overnightTaskCreate()">
+          New Task
+        </b-button>
+      </div>
+    </div>
+  </div>
+  <div class="block" style="padding-left: 2rem; display: flex;">
+
+    <${b}-table :data="overnightTasks">
+      <!-- <${b}-table-column field="key" -->
+      <!--                 label="Key" -->
+      <!--                 sortable> -->
+      <!--   {{ props.row.key }} -->
+      <!-- </${b}-table-column> -->
+      <${b}-table-column field="key"
+                      label="Key"
+                      v-slot="props">
+        {{ props.row.key }}
+      </${b}-table-column>
+      <${b}-table-column field="description"
+                      label="Description"
+                      v-slot="props">
+        {{ props.row.description }}
+      </${b}-table-column>
+      <${b}-table-column field="class_name"
+                      label="Class Name"
+                      v-slot="props">
+        {{ props.row.class_name }}
+      </${b}-table-column>
+      <${b}-table-column field="script"
+                      label="Script"
+                      v-slot="props">
+        {{ props.row.script }}
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
+                      v-slot="props">
+        <a href="#"
+           @click.prevent="overnightTaskEdit(props.row)">
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
+          Edit
+        </a>
+        &nbsp;
+        <a href="#"
+           class="has-text-danger"
+           @click.prevent="overnightTaskDelete(props.row)">
+          % if request.use_oruga:
+              <o-icon icon="trash" />
+          % else:
+              <i class="fas fa-trash"></i>
+          % endif
+          Delete
+        </a>
+      </${b}-table-column>
+    </${b}-table>
+
+    <b-modal has-modal-card
+             :active.sync="overnightTaskShowDialog">
+      <div class="modal-card">
+
+        <header class="modal-card-head">
+          <p class="modal-card-title">Overnight Task</p>
+        </header>
+
+        <section class="modal-card-body">
+          <b-field label="Key"
+                   :type="overnightTaskKey ? null : 'is-danger'">
+            <b-input v-model.trim="overnightTaskKey"
+                     ref="overnightTaskKey"
+                     expanded />
+          </b-field>
+          <b-field label="Description"
+                   :type="overnightTaskDescription ? null : 'is-danger'">
+            <b-input v-model.trim="overnightTaskDescription"
+                     ref="overnightTaskDescription"
+                     expanded />
+          </b-field>
+          <b-field label="Module">
+            <b-input v-model.trim="overnightTaskModule"
+                     expanded />
+          </b-field>
+          <b-field label="Class Name">
+            <b-input v-model.trim="overnightTaskClass"
+                     expanded />
+          </b-field>
+          <b-field label="Script">
+            <b-input v-model.trim="overnightTaskScript"
+                     expanded />
+          </b-field>
+          <b-field label="Notes">
+            <b-input v-model.trim="overnightTaskNotes"
+                     type="textarea"
+                     expanded />
+          </b-field>
+        </section>
+
+        <footer class="modal-card-foot">
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="save"
+                    @click="overnightTaskSave()"
+                    :disabled="!overnightTaskKey || !overnightTaskDescription">
+            Save
+          </b-button>
+          <b-button @click="overnightTaskShowDialog = false">
+            Cancel
+          </b-button>
+        </footer>
+      </div>
+    </b-modal>
+
+  </div>
+
+  <div class="level">
+    <div class="level-left">
+      <div class="level-item">
+        <h3 class="is-size-3">Backfill Tasks</h3>
+      </div>
+      <div class="level-item">
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="plus"
+                  @click="backfillTaskCreate()">
+          New Task
+        </b-button>
+      </div>
+    </div>
+  </div>
+  <div class="block" style="padding-left: 2rem; display: flex;">
+
+    <${b}-table :data="backfillTasks">
+      <${b}-table-column field="key"
+                      label="Key"
+                      v-slot="props">
+        {{ props.row.key }}
+      </${b}-table-column>
+      <${b}-table-column field="description"
+                      label="Description"
+                      v-slot="props">
+        {{ props.row.description }}
+      </${b}-table-column>
+      <${b}-table-column field="script"
+                      label="Script"
+                      v-slot="props">
+        {{ props.row.script }}
+      </${b}-table-column>
+      <${b}-table-column field="forward"
+                      label="Orientation"
+                      v-slot="props">
+        {{ props.row.forward ? "Forward" : "Backward" }}
+      </${b}-table-column>
+      <${b}-table-column field="target_date"
+                      label="Target Date"
+                      v-slot="props">
+        {{ props.row.target_date }}
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
+                      v-slot="props">
+        <a href="#"
+           @click.prevent="backfillTaskEdit(props.row)">
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
+          Edit
+        </a>
+        &nbsp;
+        <a href="#"
+           class="has-text-danger"
+           @click.prevent="backfillTaskDelete(props.row)">
+          % if request.use_oruga:
+              <o-icon icon="trash" />
+          % else:
+              <i class="fas fa-trash"></i>
+          % endif
+          Delete
+        </a>
+      </${b}-table-column>
+    </${b}-table>
+
+    <b-modal has-modal-card
+             :active.sync="backfillTaskShowDialog">
+      <div class="modal-card">
+
+        <header class="modal-card-head">
+          <p class="modal-card-title">Backfill Task</p>
+        </header>
+
+        <section class="modal-card-body">
+          <b-field label="Key"
+                   :type="backfillTaskKey ? null : 'is-danger'">
+            <b-input v-model.trim="backfillTaskKey"
+                     ref="backfillTaskKey"
+                     expanded />
+          </b-field>
+          <b-field label="Description"
+                   :type="backfillTaskDescription ? null : 'is-danger'">
+            <b-input v-model.trim="backfillTaskDescription"
+                     ref="backfillTaskDescription"
+                     expanded />
+          </b-field>
+          <b-field label="Script"
+                   :type="backfillTaskScript ? null : 'is-danger'">
+            <b-input v-model.trim="backfillTaskScript"
+                     expanded />
+          </b-field>
+          <b-field grouped>
+            <b-field label="Orientation">
+              <b-select v-model="backfillTaskForward">
+                <option :value="false">Backward</option>
+                <option :value="true">Forward</option>
+              </b-select>
+            </b-field>
+            <b-field label="Target Date">
+              <tailbone-datepicker v-model="backfillTaskTargetDate">
+              </tailbone-datepicker>
+            </b-field>
+          </b-field>
+          <b-field label="Notes">
+            <b-input v-model.trim="backfillTaskNotes"
+                     type="textarea"
+                     expanded />
+          </b-field>
+        </section>
+
+        <footer class="modal-card-foot">
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="save"
+                    @click="backfillTaskSave()"
+                    :disabled="!backfillTaskKey || !backfillTaskDescription || !backfillTaskScript">
+            Save
+          </b-button>
+          <b-button @click="backfillTaskShowDialog = false">
+            Cancel
+          </b-button>
+        </footer>
+      </div>
+    </b-modal>
+
+  </div>
+
+  <h3 class="is-size-3">Luigi Proper</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field label="Luigi URL"
+             message="This should be the URL to Luigi Task Visualiser web user interface."
+             expanded>
+      <b-input name="rattail.luigi.url"
+               v-model="simpleSettings['rattail.luigi.url']"
+               @input="settingsNeedSaved = true"
+               expanded>
+      </b-input>
+    </b-field>
+
+    <b-field label="Supervisor Process Name"
+             message="This should be the complete name, including group - e.g. luigi:luigid"
+             expanded>
+      <b-input name="rattail.luigi.scheduler.supervisor_process_name"
+               v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']"
+               @input="settingsNeedSaved = true"
+               expanded>
+      </b-input>
+    </b-field>
+
+    <b-field label="Restart Command"
+             message="This will run as '${system_user}' system user - please configure sudoers as needed.  Typical command is like:  sudo supervisorctl restart luigi:luigid"
+             expanded>
+      <b-input name="rattail.luigi.scheduler.restart_command"
+               v-model="simpleSettings['rattail.luigi.scheduler.restart_command']"
+               @input="settingsNeedSaved = true"
+               expanded>
+      </b-input>
+    </b-field>
+
+  </div>
+
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n}
+    ThisPageData.overnightTaskShowDialog = false
+    ThisPageData.overnightTask = null
+    ThisPageData.overnightTaskCounter = 0
+    ThisPageData.overnightTaskKey = null
+    ThisPageData.overnightTaskDescription = null
+    ThisPageData.overnightTaskModule = null
+    ThisPageData.overnightTaskClass = null
+    ThisPageData.overnightTaskScript = null
+    ThisPageData.overnightTaskNotes = null
+
+    ThisPage.methods.overnightTaskCreate = function() {
+        this.overnightTask = {key: null, isNew: true}
+        this.overnightTaskKey = null
+        this.overnightTaskDescription = null
+        this.overnightTaskModule = null
+        this.overnightTaskClass = null
+        this.overnightTaskScript = null
+        this.overnightTaskNotes = null
+        this.overnightTaskShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.overnightTaskKey.focus()
+        })
+    }
+
+    ThisPage.methods.overnightTaskEdit = function(task) {
+        this.overnightTask = task
+        this.overnightTaskKey = task.key
+        this.overnightTaskDescription = task.description
+        this.overnightTaskModule = task.module
+        this.overnightTaskClass = task.class_name
+        this.overnightTaskScript = task.script
+        this.overnightTaskNotes = task.notes
+        this.overnightTaskShowDialog = true
+    }
+
+    ThisPage.methods.overnightTaskSave = function() {
+        this.overnightTask.key = this.overnightTaskKey
+        this.overnightTask.description = this.overnightTaskDescription
+        this.overnightTask.module = this.overnightTaskModule
+        this.overnightTask.class_name = this.overnightTaskClass
+        this.overnightTask.script = this.overnightTaskScript
+        this.overnightTask.notes = this.overnightTaskNotes
+
+        if (this.overnightTask.isNew) {
+            this.overnightTasks.push(this.overnightTask)
+            this.overnightTask.isNew = false
+        }
+
+        this.overnightTaskShowDialog = false
+        this.settingsNeedSaved = true
+    }
+
+    ThisPage.methods.overnightTaskDelete = function(task) {
+        if (confirm("Really delete this task?")) {
+            let i = this.overnightTasks.indexOf(task)
+            this.overnightTasks.splice(i, 1)
+            this.settingsNeedSaved = true
+        }
+    }
+
+    ThisPageData.backfillTasks = ${json.dumps(backfill_tasks)|n}
+    ThisPageData.backfillTaskShowDialog = false
+    ThisPageData.backfillTask = null
+    ThisPageData.backfillTaskCounter = 0
+    ThisPageData.backfillTaskKey = null
+    ThisPageData.backfillTaskDescription = null
+    ThisPageData.backfillTaskScript = null
+    ThisPageData.backfillTaskForward = false
+    ThisPageData.backfillTaskTargetDate = null
+    ThisPageData.backfillTaskNotes = null
+
+    ThisPage.methods.backfillTaskCreate = function() {
+        this.backfillTask = {key: null, isNew: true}
+        this.backfillTaskKey = null
+        this.backfillTaskDescription = null
+        this.backfillTaskScript = null
+        this.backfillTaskForward = false
+        this.backfillTaskTargetDate = null
+        this.backfillTaskNotes = null
+        this.backfillTaskShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.backfillTaskKey.focus()
+        })
+    }
+
+    ThisPage.methods.backfillTaskEdit = function(task) {
+        this.backfillTask = task
+        this.backfillTaskKey = task.key
+        this.backfillTaskDescription = task.description
+        this.backfillTaskScript = task.script
+        this.backfillTaskForward = task.forward
+        this.backfillTaskTargetDate = task.target_date
+        this.backfillTaskNotes = task.notes
+        this.backfillTaskShowDialog = true
+    }
+
+    ThisPage.methods.backfillTaskDelete = function(task) {
+        if (confirm("Really delete this task?")) {
+            let i = this.backfillTasks.indexOf(task)
+            this.backfillTasks.splice(i, 1)
+            this.settingsNeedSaved = true
+        }
+    }
+
+    ThisPage.methods.backfillTaskSave = function() {
+        this.backfillTask.key = this.backfillTaskKey
+        this.backfillTask.description = this.backfillTaskDescription
+        this.backfillTask.script = this.backfillTaskScript
+        this.backfillTask.forward = this.backfillTaskForward
+        this.backfillTask.target_date = this.backfillTaskTargetDate
+        this.backfillTask.notes = this.backfillTaskNotes
+
+        if (this.backfillTask.isNew) {
+            this.backfillTasks.push(this.backfillTask)
+            this.backfillTask.isNew = false
+        }
+
+        this.backfillTaskShowDialog = false
+        this.settingsNeedSaved = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako
new file mode 100644
index 00000000..0dd72d01
--- /dev/null
+++ b/tailbone/templates/luigi/index.mako
@@ -0,0 +1,376 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">View / Launch Tasks</%def>
+
+<%def name="page_content()">
+  <br />
+  <div class="form">
+
+    <div class="buttons">
+
+      <b-button tag="a"
+                % if luigi_url:
+                href="${luigi_url}"
+                % else:
+                href="#" disabled
+                title="Luigi URL is not configured"
+                % endif
+                icon-pack="fas"
+                icon-left="external-link-alt"
+                target="_blank">
+        Luigi Task Visualiser
+      </b-button>
+
+      <b-button tag="a"
+                % if luigi_history_url:
+                href="${luigi_history_url}"
+                % else:
+                href="#" disabled
+                title="Luigi URL is not configured"
+                % endif
+                icon-pack="fas"
+                icon-left="external-link-alt"
+                target="_blank">
+        Luigi Task History
+      </b-button>
+
+      % if master.has_perm('restart_scheduler'):
+          ${h.form(url('{}.restart_scheduler'.format(route_prefix)), **{'@submit': 'submitRestartSchedulerForm'})}
+          ${h.csrf_token(request)}
+          <b-button type="is-primary"
+                    native-type="submit"
+                    icon-pack="fas"
+                    icon-left="redo"
+                    :disabled="restartSchedulerFormSubmitting">
+            {{ restartSchedulerFormSubmitting ? "Working, please wait..." : "Restart Luigi Scheduler" }}
+          </b-button>
+          ${h.end_form()}
+      % endif
+    </div>
+
+    % if master.has_perm('launch_overnight'):
+
+        <h3 class="block is-size-3">Overnight Tasks</h3>
+
+        <${b}-table :data="overnightTasks" hoverable>
+          <${b}-table-column field="description"
+                          label="Description"
+                          v-slot="props">
+            {{ props.row.description }}
+          </${b}-table-column>
+          <${b}-table-column field="script"
+                          label="Command"
+                          v-slot="props">
+            {{ props.row.script || props.row.class_name }}
+          </${b}-table-column>
+          <${b}-table-column field="last_date"
+                          label="Last Date"
+                          v-slot="props">
+            <span :class="overnightTextClass(props.row)">
+              {{ props.row.last_date || "never!" }}
+            </span>
+          </${b}-table-column>
+          <${b}-table-column label="Actions"
+                          v-slot="props">
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-circle-right"
+                      @click="overnightTaskLaunchInit(props.row)">
+              Launch
+            </b-button>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="overnightTaskShowLaunchDialog"
+                        % else:
+                            :active.sync="overnightTaskShowLaunchDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Launch Overnight Task</p>
+                </header>
+
+                <section class="modal-card-body"
+                         v-if="overnightTask">
+
+                  <b-field label="Task" horizontal>
+                    <span>{{ overnightTask.description }}</span>
+                  </b-field>
+
+                  <b-field label="Last Date" horizontal>
+                    <span :class="overnightTextClass(overnightTask)">
+                      {{ overnightTask.last_date || "n/a" }}
+                    </span>
+                  </b-field>
+
+                  <b-field label="Next Date" horizontal>
+                    <span>
+                      ${rattail_app.render_date(rattail_app.yesterday())} (yesterday)
+                    </span>
+                  </b-field>
+
+                  <p class="block">
+                    Launching this task will schedule it to begin
+                    within one minute.&nbsp; See the Luigi Task
+                    Visualizer after that, for current status.
+                  </p>
+
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button @click="overnightTaskShowLaunchDialog = false">
+                    Cancel
+                  </b-button>
+                  <b-button type="is-primary"
+                            icon-pack="fas"
+                            icon-left="arrow-circle-right"
+                            @click="overnightTaskLaunchSubmit()"
+                            :disabled="overnightTaskLaunching">
+                    {{ overnightTaskLaunching ? "Working, please wait..." : "Launch" }}
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+          </${b}-table-column>
+          <template #empty>
+            <p class="block">No tasks defined.</p>
+          </template>
+        </${b}-table>
+
+    % endif
+
+    % if master.has_perm('launch_backfill'):
+
+        <h3 class="block is-size-3">Backfill Tasks</h3>
+
+        <${b}-table :data="backfillTasks" hoverable>
+          <${b}-table-column field="description"
+                          label="Description"
+                          v-slot="props">
+            {{ props.row.description }}
+          </${b}-table-column>
+          <${b}-table-column field="script"
+                          label="Script"
+                          v-slot="props">
+            {{ props.row.script }}
+          </${b}-table-column>
+          <${b}-table-column field="forward"
+                          label="Orientation"
+                          v-slot="props">
+            {{ props.row.forward ? "Forward" : "Backward" }}
+          </${b}-table-column>
+          <${b}-table-column field="last_date"
+                          label="Last Date"
+                          v-slot="props">
+            <span :class="backfillTextClass(props.row)">
+              {{ props.row.last_date }}
+            </span>
+          </${b}-table-column>
+          <${b}-table-column field="target_date"
+                          label="Target Date"
+                          v-slot="props">
+            {{ props.row.target_date }}
+          </${b}-table-column>
+          <${b}-table-column label="Actions"
+                          v-slot="props">
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-circle-right"
+                      @click="backfillTaskLaunch(props.row)">
+              Launch
+            </b-button>
+          </${b}-table-column>
+          <template #empty>
+            <p class="block">No tasks defined.</p>
+          </template>
+        </${b}-table>
+
+        <${b}-modal has-modal-card
+                    % if request.use_oruga:
+                        v-model:active="backfillTaskShowLaunchDialog"
+                    % else:
+                        :active.sync="backfillTaskShowLaunchDialog"
+                    % endif
+                    >
+          <div class="modal-card">
+
+            <header class="modal-card-head">
+              <p class="modal-card-title">Launch Backfill Task</p>
+            </header>
+
+            <section class="modal-card-body"
+                     v-if="backfillTask">
+
+              <p class="block has-text-weight-bold">
+                {{ backfillTask.description }}
+                (goes {{ backfillTask.forward ? "FORWARD" : "BACKWARD" }})
+              </p>
+
+              <b-field grouped>
+                <b-field label="Last Date">
+                  {{ backfillTask.last_date || "n/a" }}
+                </b-field>
+                <b-field label="Target Date">
+                  {{ backfillTask.target_date || "n/a" }}
+                </b-field>
+              </b-field>
+
+              <b-field grouped>
+
+                <b-field label="Start Date"
+                         :type="backfillTaskStartDate ? null : 'is-danger'">
+                  <tailbone-datepicker v-model="backfillTaskStartDate">
+                  </tailbone-datepicker>
+                </b-field>
+
+                <b-field label="End Date"
+                         :type="backfillTaskEndDate ? null : 'is-danger'">
+                  <tailbone-datepicker v-model="backfillTaskEndDate">
+                  </tailbone-datepicker>
+                </b-field>
+
+              </b-field>
+
+            </section>
+
+            <footer class="modal-card-foot">
+              <b-button @click="backfillTaskShowLaunchDialog = false">
+                Cancel
+              </b-button>
+              <b-button type="is-primary"
+                        icon-pack="fas"
+                        icon-left="arrow-circle-right"
+                        @click="backfillTaskLaunchSubmit()"
+                        :disabled="backfillTaskLaunching || !backfillTaskStartDate || !backfillTaskEndDate">
+                {{ backfillTaskLaunching ? "Working, please wait..." : "Launch" }}
+              </b-button>
+            </footer>
+          </div>
+        </${b}-modal>
+
+    % endif
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if master.has_perm('restart_scheduler'):
+
+        ThisPageData.restartSchedulerFormSubmitting = false
+
+        ThisPage.methods.submitRestartSchedulerForm = function() {
+            this.restartSchedulerFormSubmitting = true
+        }
+
+    % endif
+
+    % if master.has_perm('launch_overnight'):
+
+        ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n}
+        ThisPageData.overnightTask = null
+        ThisPageData.overnightTaskShowLaunchDialog = false
+        ThisPageData.overnightTaskLaunching = false
+
+        ThisPage.methods.overnightTextClass = function(task) {
+            let yesterday = '${rattail_app.today() - datetime.timedelta(days=1)}'
+            if (task.last_date) {
+                if (task.last_date == yesterday) {
+                    return 'has-text-success'
+                } else {
+                    return 'has-text-warning'
+                }
+            } else {
+                return 'has-text-warning'
+            }
+        }
+
+        ThisPage.methods.overnightTaskLaunchInit = function(task) {
+            this.overnightTask = task
+            this.overnightTaskShowLaunchDialog = true
+        }
+
+        ThisPage.methods.overnightTaskLaunchSubmit = function() {
+            this.overnightTaskLaunching = true
+
+            let url = '${url('{}.launch_overnight'.format(route_prefix))}'
+            let params = {key: this.overnightTask.key}
+
+            this.submitForm(url, params, response => {
+                this.$buefy.toast.open({
+                    message: "Task has been scheduled for immediate launch!",
+                    type: 'is-success',
+                    duration: 5000, // 5 seconds
+                })
+                this.overnightTaskLaunching = false
+                this.overnightTaskShowLaunchDialog = false
+            })
+        }
+
+    % endif
+
+    % if master.has_perm('launch_backfill'):
+
+        ThisPageData.backfillTasks = ${json.dumps(backfill_tasks)|n}
+        ThisPageData.backfillTask = null
+        ThisPageData.backfillTaskStartDate = null
+        ThisPageData.backfillTaskEndDate = null
+        ThisPageData.backfillTaskShowLaunchDialog = false
+        ThisPageData.backfillTaskLaunching = false
+
+        ThisPage.methods.backfillTextClass = function(task) {
+            if (task.target_date) {
+                if (task.last_date) {
+                    if (task.forward) {
+                        if (task.last_date >= task.target_date) {
+                            return 'has-text-success'
+                        } else {
+                            return 'has-text-warning'
+                        }
+                    } else {
+                        if (task.last_date <= task.target_date) {
+                            return 'has-text-success'
+                        } else {
+                            return 'has-text-warning'
+                        }
+                    }
+                }
+            }
+        }
+
+        ThisPage.methods.backfillTaskLaunch = function(task) {
+            this.backfillTask = task
+            this.backfillTaskStartDate = null
+            this.backfillTaskEndDate = null
+            this.backfillTaskShowLaunchDialog = true
+        }
+
+        ThisPage.methods.backfillTaskLaunchSubmit = function() {
+            this.backfillTaskLaunching = true
+
+            let url = '${url('{}.launch_backfill'.format(route_prefix))}'
+            let params = {
+                key: this.backfillTask.key,
+                start_date: this.backfillTaskStartDate,
+                end_date: this.backfillTaskEndDate,
+            }
+
+            this.submitForm(url, params, response => {
+                this.$buefy.toast.open({
+                    message: "Task has been scheduled for immediate launch!",
+                    type: 'is-success',
+                    duration: 5000, // 5 seconds
+                })
+                this.backfillTaskLaunching = false
+                this.backfillTaskShowLaunchDialog = false
+            })
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako
index 17b13751..4c7e4662 100644
--- a/tailbone/templates/master/clone.mako
+++ b/tailbone/templates/master/clone.mako
@@ -3,58 +3,40 @@
 
 <%def name="title()">Clone ${model_title}: ${instance_title}</%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <br />
-  % if use_buefy:
-      <b-notification :closable="false">
-        You are about to clone the following ${model_title} as a new record:
-      </b-notification>
-  % else:
-  <p>You are about to clone the following ${model_title} as a new record:</p>
-  % endif
-
-  ${parent.render_buefy_form()}
+  <b-notification :closable="false">
+    You are about to clone the following ${model_title} as a new record:
+  </b-notification>
+  ${parent.render_form()}
 </%def>
 
 <%def name="render_form_buttons()">
   <br />
-  % if use_buefy:
-      <b-notification :closable="false">
-        Are you sure about this?
-      </b-notification>
-  % else:
-  <p>Are you sure about this?</p>
-  % endif
+  <b-notification :closable="false">
+    Are you sure about this?
+  </b-notification>
   <br />
 
-  % if use_buefy:
   ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})}
-  % else:
-  ${h.form(request.current_route_url(), class_='autodisable')}
-  % endif
   ${h.csrf_token(request)}
   ${h.hidden('clone', value='clone')}
     <div class="buttons">
-      % if use_buefy:
-          <once-button tag="a" href="${form.cancel_url}"
-                       text="Whoops, nevermind...">
-          </once-button>
-          <b-button type="is-primary"
-                    native-type="submit"
-                    :disabled="formSubmitting">
-            {{ submitButtonText }}
-          </b-button>
-      % else:
-          ${h.link_to("Whoops, nevermind...", form.cancel_url, class_='button autodisable')}
-          ${h.submit('submit', "Yes, please clone away")}
-      % endif
+      <once-button tag="a" href="${form.cancel_url}"
+                   text="Whoops, nevermind...">
+      </once-button>
+      <b-button type="is-primary"
+                native-type="submit"
+                :disabled="formSubmitting">
+        {{ submitButtonText }}
+      </b-button>
     </div>
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     TailboneFormData.formSubmitting = false
     TailboneFormData.submitButtonText = "Yes, please clone away"
@@ -66,6 +48,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/configure.mako b/tailbone/templates/master/configure.mako
new file mode 100644
index 00000000..bfe0574c
--- /dev/null
+++ b/tailbone/templates/master/configure.mako
@@ -0,0 +1,34 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="page_content()">
+  ${parent.page_content()}
+
+  <h3 class="block is-size-3">TODO</h3>
+
+  <p class="block">
+    You should create a custom template file at:&nbsp;
+    <span class="is-family-monospace">${master.get_template_prefix()}/configure.mako</span>
+  </p>
+
+  <p class="block">
+    Within that you should define (at least) the
+    <span class="is-family-monospace">page_content()</span>
+    def block.
+  </p>
+
+  <p class="block">
+    You can see the following examples for reference:
+  </p>
+
+  <ul class="block">
+    <li class="is-family-monospace">/datasync/configure.mako</li>
+    <li class="is-family-monospace">/importing/configure.mako</li>
+    <li class="is-family-monospace">/products/configure.mako</li>
+    <li class="is-family-monospace">/receiving/configure.mako</li>
+  </ul>
+
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako
index 27cd404c..d7dcbbd8 100644
--- a/tailbone/templates/master/create.mako
+++ b/tailbone/templates/master/create.mako
@@ -1,6 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
 
-<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def>
+<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def>
 
 ${parent.body()}
diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako
index 444c4e1d..d2f517d9 100644
--- a/tailbone/templates/master/delete.mako
+++ b/tailbone/templates/master/delete.mako
@@ -3,85 +3,45 @@
 
 <%def name="title()">Delete ${model_title}: ${instance_title}</%def>
 
-<%def name="context_menu_items()">
-  <li>${h.link_to("Back to {}".format(model_title_plural), url(route_prefix))}</li>
-  % if master.viewable and request.has_perm('{}.view'.format(permission_prefix)):
-      <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li>
-  % endif
-  % if master.editable and request.has_perm('{}.edit'.format(permission_prefix)):
-      <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li>
-  % endif
-  % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
-      % if master.creates_multiple:
-          <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li>
-      % else:
-          <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li>
-      % endif
-  % endif
-</%def>
-
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <br />
-  % if use_buefy:
-      <b-notification type="is-danger" :closable="false">
-        You are about to delete the following ${model_title} and all associated data:
-      </b-notification>
-  % else:
-  <p>You are about to delete the following ${model_title} and all associated data:</p>
-  % endif
-
-  ${parent.render_buefy_form()}
+  <b-notification type="is-danger" :closable="false">
+    You are about to delete the following ${model_title} and all associated data:
+  </b-notification>
+  ${parent.render_form()}
 </%def>
 
 <%def name="render_form_buttons()">
   <br />
-  % if use_buefy:
-      <b-notification type="is-danger" :closable="false">
-        Are you sure about this?
-      </b-notification>
-  % else:
-  <p>Are you sure about this?</p>
-  % endif
+  <b-notification type="is-danger" :closable="false">
+    Are you sure about this?
+  </b-notification>
   <br />
 
-  % if use_buefy:
   ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})}
-  % else:
-  ${h.form(request.current_route_url(), class_='autodisable')}
-  % endif
   ${h.csrf_token(request)}
     <div class="buttons">
-      % if use_buefy:
-          <once-button tag="a" href="${form.cancel_url}"
-                       text="Whoops, nevermind...">
-          </once-button>
-          <b-button type="is-primary is-danger"
-                    native-type="submit"
-                    :disabled="formSubmitting">
-            {{ formButtonText }}
-          </b-button>
-      % else:
-      <a class="button" href="${form.cancel_url}">Whoops, nevermind...</a>
-      ${h.submit('submit', "Yes, please DELETE this data forever!", class_='button is-primary')}
-      % endif
+      <once-button tag="a" href="${form.cancel_url}"
+                   text="Whoops, nevermind...">
+      </once-button>
+      <b-button type="is-primary is-danger"
+                native-type="submit"
+                :disabled="formSubmitting">
+        {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
+      </b-button>
     </div>
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.formSubmitting = false
-    TailboneFormData.formButtonText = "Yes, please DELETE this data forever!"
+    ${form.vue_component}Data.formSubmitting = false
 
-    TailboneForm.methods.submitForm = function() {
+    ${form.vue_component}.methods.submitForm = function() {
         this.formSubmitting = true
-        this.formButtonText = "Working, please wait..."
     }
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako
index febd0bcd..a03912e6 100644
--- a/tailbone/templates/master/edit.mako
+++ b/tailbone/templates/master/edit.mako
@@ -1,40 +1,8 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/form.mako" />
 
-<%def name="title()">Edit: ${instance_title}</%def>
-
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    $(function() {
-
-        $('form').submit(function() {
-            var submit = $(this).find('input[type="submit"]');
-            if (submit.length) {
-                submit.button('disable').button('option', 'label', "Saving, please wait...");
-            }
-        });
-
-    });
-  </script>
-  % endif
-</%def>
-
-<%def name="context_menu_items()">
-  % if master.viewable and request.has_perm('{}.view'.format(permission_prefix)):
-      <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li>
-  % endif
-  ${self.context_menu_item_delete()}
-  % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
-      % if master.creates_multiple:
-          <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li>
-      % else:
-          <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li>
-      % endif
-  % endif
-</%def>
+<%def name="title()">${index_title} &raquo; ${instance_title} &raquo; Edit</%def>
 
+<%def name="content_title()">Edit: ${instance_title}</%def>
 
 ${parent.body()}
diff --git a/tailbone/templates/master/edit_row.mako b/tailbone/templates/master/edit_row.mako
index dab77592..4d6a9573 100644
--- a/tailbone/templates/master/edit_row.mako
+++ b/tailbone/templates/master/edit_row.mako
@@ -1,8 +1,8 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
 <%inherit file="/master/edit.mako" />
 
 <%def name="context_menu_items()">
-  <li>${h.link_to("Back to {}".format(model_title), index_url)}</li>
+  <li>${h.link_to("Back to {}".format(parent_model_title), parent_url)}</li>
   % if master.rows_viewable and request.has_perm('{}.view'.format(row_permission_prefix)):
       <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', instance))}</li>
   % endif
diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako
index 6f67f77e..17063c21 100644
--- a/tailbone/templates/master/form.mako
+++ b/tailbone/templates/master/form.mako
@@ -1,35 +1,18 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
 
-<%def name="context_menu_item_delete()">
-  % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)):
-      % if master.delete_confirm == 'simple':
-          <li>
-            ## note, the `ref` here is for buefy only
-            ${h.form(action_url('delete', instance), ref='deleteObjectForm')}
-            ${h.csrf_token(request)}
-            <a href="${action_url('delete', instance)}"
-               % if use_buefy:
-               @click.prevent="deleteObject"
-               % else:
-               class="delete-instance"
-               % endif
-               >
-              Delete this ${model_title}
-            </a>
-            ${h.end_form()}
-          </li>
-      % else:
-          ## assuming here that: delete_confirm == 'full'
-          <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li>
-      % endif
-  % endif
-</%def>
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-      <script type="text/javascript">
+    ## declare extra data needed by form
+    % if form is not Undefined and getattr(form, 'json_data', None):
+        % for key, value in form.json_data.items():
+            ${form.vue_component}Data.${key} = ${json.dumps(value)|n}
+        % endfor
+    % endif
+
+    % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
 
         ThisPage.methods.deleteObject = function() {
             if (confirm("Are you sure you wish to delete this ${model_title}?")) {
@@ -37,9 +20,11 @@
             }
         }
 
-      </script>
+    % endif
+  </script>
+
+  % if form is not Undefined and hasattr(form, 'render_included_templates'):
+      ${form.render_included_templates()}
   % endif
+
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 8826c096..a2d26c60 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -12,291 +12,374 @@
 
 <%def name="content_title()"></%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-    $(function() {
-
-        $('.grid-wrapper').gridwrapper();
-
-        % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-
-            $('.grid-wrapper').on('click', '.grid .actions a.delete', function() {
-                if (confirm("Are you sure you wish to delete this ${model_title}?")) {
-                    var link = $(this).get(0);
-                    var form = $('#delete-object-form').get(0);
-                    form.action = link.href;
-                    form.submit();
-                }
-                return false;
-            });
-
-        % endif
-
-        % if master.mergeable and master.has_perm('merge'):
-
-            $('form[name="merge-things"] button').button('option', 'disabled', $('.grid').gridcore('count_selected') != 2);
-
-            $('.grid-wrapper').on('gridchecked', '.grid', function(event, count) {
-                $('form[name="merge-things"] button').button('option', 'disabled', count != 2);
-            });
-
-            $('form[name="merge-things"]').submit(function() {
-                var uuids = $('.grid').gridcore('selected_uuids');
-                if (uuids.length != 2) {
-                    return false;
-                }
-                $(this).find('[name="uuids"]').val(uuids.toString());
-                $(this).find('button')
-                    .button('option', 'label', "Preparing to Merge...")
-                    .button('disable');
-            });
-
-        % endif
-
-        % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
-
-        $('form[name="bulk-delete"] button').click(function() {
-            var count = $('.grid-wrapper').gridwrapper('results_count', true);
-            if (count === null) {
-                alert("There don't seem to be any results to delete!");
-                return;
-            }
-            if (! confirm("You are about to delete " + count + " ${model_title_plural}.\n\nAre you sure?")) {
-                return
-            }
-            $(this).button('disable').button('option', 'label', "Deleting Results...");
-            $('form[name="bulk-delete"]').submit();
-        });
-
-        % endif
-
-        % if master.supports_set_enabled_toggle and request.has_perm('{}.enable_disable_set'.format(permission_prefix)):
-            $('form[name="enable-set"] button').click(function() {
-                var form = $(this).parents('form');
-                var uuids = $('.grid').gridcore('selected_uuids');
-                if (! uuids.length) {
-                    alert("You must first select one or more objects to enable.");
-                    return false;
-                }
-                if (! confirm("Are you sure you wish to ENABLE the " + uuids.length + " selected objects?")) {
-                    return false;
-                }
-                form.find('[name="uuids"]').val(uuids.toString());
-                disable_button(this);
-                form.submit();
-            });
-
-            $('form[name="disable-set"] button').click(function() {
-                var form = $(this).parents('form');
-                var uuids = $('.grid').gridcore('selected_uuids');
-                if (! uuids.length) {
-                    alert("You must first select one or more objects to disable.");
-                    return false;
-                }
-                if (! confirm("Are you sure you wish to DISABLE the " + uuids.length + " selected objects?")) {
-                    return false;
-                }
-                form.find('[name="uuids"]').val(uuids.toString());
-                disable_button(this);
-                form.submit();
-            });
-        % endif
-
-        % if master.set_deletable and request.has_perm('{}.delete_set'.format(permission_prefix)):
-            $('form[name="delete-set"] button').click(function() {
-                var form = $(this).parents('form');
-                var uuids = $('.grid').gridcore('selected_uuids');
-                if (! uuids.length) {
-                    alert("You must first select one or more objects to delete.");
-                    return false;
-                }
-                if (! confirm("Are you sure you wish to DELETE the " + uuids.length + " selected objects?")) {
-                    return false;
-                }
-                form.find('[name="uuids"]').val(uuids.toString());
-                disable_button(this);
-                form.submit();
-            });
-        % endif
-    });
-  </script>
-  % endif
-</%def>
-
-<%def name="context_menu_items()">
-  % if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)):
-      <li>${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}</li>
-  % endif
-  % if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)):
-      <li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li>
-  % endif
-  % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
-      % if master.creates_multiple:
-          <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li>
-      % else:
-          <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li>
-      % endif
-  % endif
-</%def>
-
 <%def name="grid_tools()">
 
-  ## merge 2 objects
-  % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)):
+  ## grid totals
+  % if getattr(master, 'supports_grid_totals', False):
+      <div style="display: flex; align-items: center;">
+        <b-button v-if="gridTotalsDisplay == null"
+                  :disabled="gridTotalsFetching"
+                  @click="gridTotalsFetch()">
+          {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }}
+        </b-button>
+        <div v-if="gridTotalsDisplay != null"
+             class="control">
+          Totals: {{ gridTotalsDisplay }}
+        </div>
+      </div>
+  % endif
 
-      % if use_buefy:
-      ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
-      % else:
-      ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')}
-      % endif
+  ## download search results
+  % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
+      <div>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="download"
+                  @click="showDownloadResultsDialog = true"
+                  :disabled="!total">
+          Download Results
+        </b-button>
+
+        ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')}
+        ${h.csrf_token(request)}
+        <input type="hidden" name="fmt" :value="downloadResultsFormat" />
+        <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" />
+        ${h.end_form()}
+
+        <b-modal :active.sync="showDownloadResultsDialog">
+          <div class="card">
+
+            <div class="card-content">
+              <p>
+                There are
+                <span class="is-size-4 has-text-weight-bold">
+                  {{ total.toLocaleString('en') }} ${model_title_plural}
+                </span>
+                matching your current filters.
+              </p>
+              <p>
+                You may download this set as a single data file if you like.
+              </p>
+              <br />
+
+              <b-notification type="is-warning" :closable="false"
+                              v-if="downloadResultsFormat == 'xlsx' && total >= 1000">
+                Excel downloads for large data sets can take a long time to
+                generate, and bog down the server in the meantime.  You are
+                encouraged to choose CSV for a large data set, even though
+                the end result (file size) may be larger with CSV.
+              </b-notification>
+
+              <div style="display: flex; justify-content: space-between">
+
+                <div>
+                  <b-field label="Format">
+                    <b-select v-model="downloadResultsFormat">
+                      % for key, label in master.download_results_supported_formats().items():
+                      <option value="${key}">${label}</option>
+                      % endfor
+                    </b-select>
+                  </b-field>
+                </div>
+
+                <div>
+
+                  <div v-show="downloadResultsFieldsMode != 'choose'"
+                       class="has-text-right">
+                    <p v-if="downloadResultsFieldsMode == 'default'">
+                      Will use DEFAULT fields.
+                    </p>
+                    <p v-if="downloadResultsFieldsMode == 'all'">
+                      Will use ALL fields.
+                    </p>
+                    <br />
+                  </div>
+
+                  <div class="buttons is-right">
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'default'"
+                              @click="downloadResultsUseDefaultFields()">
+                      Use Default Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'all'"
+                              @click="downloadResultsUseAllFields()">
+                      Use All Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'choose'"
+                              @click="downloadResultsFieldsMode = 'choose'">
+                      Choose Fields
+                    </b-button>
+                  </div>
+
+                  <div v-show="downloadResultsFieldsMode == 'choose'">
+                    <div style="display: flex;">
+                      <div>
+                        <b-field label="Excluded Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsExcludedFieldsSelected"
+                                    ref="downloadResultsExcludedFields">
+                            <option v-for="field in downloadResultsFieldsExcluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                      <div>
+                        <br /><br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsExcludeFields()">
+                          &lt;
+                        </b-button>
+                        <br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsIncludeFields()">
+                          &gt;
+                        </b-button>
+                      </div>
+                      <div>
+                        <b-field label="Included Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsIncludedFieldsSelected"
+                                    ref="downloadResultsIncludedFields">
+                            <option v-for="field in downloadResultsFieldsIncluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                    </div>
+                  </div>
+
+                </div>
+              </div>
+            </div> <!-- card-content -->
+
+            <footer class="modal-card-foot">
+              <b-button @click="showDownloadResultsDialog = false">
+                Cancel
+              </b-button>
+              <once-button type="is-primary"
+                           @click="downloadResultsSubmit()"
+                           icon-pack="fas"
+                           icon-left="download"
+                           :disabled="!downloadResultsFieldsIncluded.length"
+                           text="Download Results">
+              </once-button>
+            </footer>
+          </div>
+        </b-modal>
+      </div>
+  % endif
+
+  ## download rows for search results
+  % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+      <b-button type="is-primary"
+                icon-pack="fas"
+                icon-left="download"
+                @click="downloadResultsRows()"
+                :disabled="downloadResultsRowsButtonDisabled">
+        {{ downloadResultsRowsButtonText }}
+      </b-button>
+      ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')}
       ${h.csrf_token(request)}
-      % if use_buefy:
-          <input type="hidden"
-                 name="uuids"
-                 :value="checkedRowUUIDs()" />
-          <b-button type="is-primary"
-                    native-type="submit"
-                    icon-pack="fas"
-                    icon-left="object-ungroup"
-                    :disabled="mergeFormSubmitting || checkedRows.length != 2">
-            {{ mergeFormButtonText }}
-          </b-button>
-      % else:
-          ${h.hidden('uuids')}
-          <button type="submit" class="button">Merge 2 ${model_title_plural}</button>
-      % endif
+      ${h.end_form()}
+  % endif
+
+  ## merge 2 objects
+  % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
+
+      ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
+      ${h.csrf_token(request)}
+      <input type="hidden"
+             name="uuids"
+             :value="checkedRowUUIDs()" />
+      <b-button type="is-primary"
+                native-type="submit"
+                icon-pack="fas"
+                icon-left="object-ungroup"
+                :disabled="mergeFormSubmitting || checkedRows.length != 2">
+        {{ mergeFormButtonText }}
+      </b-button>
       ${h.end_form()}
   % endif
 
   ## enable / disable selected objects
-  % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'):
+  % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
 
-      % if use_buefy:
-          ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
-          ${h.csrf_token(request)}
-          ${h.hidden('uuids', v_model='selected_uuids')}
-          <b-button :disabled="enableSelectedDisabled"
-                    @click="enableSelectedSubmit()">
-            {{ enableSelectedText }}
-          </b-button>
-          ${h.end_form()}
-      % else:
-          ${h.form(url('{}.enable_set'.format(route_prefix)), name='enable-set', class_='control')}
-          ${h.csrf_token(request)}
-          ${h.hidden('uuids')}
-          <button type="button" class="button">Enable Selected</button>
-          ${h.end_form()}
-      % endif
+      ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="enableSelectedDisabled"
+                @click="enableSelectedSubmit()">
+        {{ enableSelectedText }}
+      </b-button>
+      ${h.end_form()}
 
-      % if use_buefy:
-          ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')}
-          ${h.csrf_token(request)}
-          ${h.hidden('uuids', v_model='selected_uuids')}
-          <b-button :disabled="disableSelectedDisabled"
-                    @click="disableSelectedSubmit()">
-            {{ disableSelectedText }}
-          </b-button>
-          ${h.end_form()}
-      % else:
-          ${h.form(url('{}.disable_set'.format(route_prefix)), name='disable-set', class_='control')}
-          ${h.csrf_token(request)}
-          ${h.hidden('uuids')}
-          <button type="button" class="button">Disable Selected</button>
-          ${h.end_form()}
-      % endif
+      ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="disableSelectedDisabled"
+                @click="disableSelectedSubmit()">
+        {{ disableSelectedText }}
+      </b-button>
+      ${h.end_form()}
   % endif
 
   ## delete selected objects
-  % if master.set_deletable and master.has_perm('delete_set'):
-      % if use_buefy:
-          ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
-          ${h.csrf_token(request)}
-          ${h.hidden('uuids', v_model='selected_uuids')}
-          <b-button type="is-danger"
-                    :disabled="deleteSelectedDisabled"
-                    @click="deleteSelectedSubmit()"
-                    icon-pack="fas"
-                    icon-left="trash">
-            {{ deleteSelectedText }}
-          </b-button>
-          ${h.end_form()}
-      % else:
-          ${h.form(url('{}.delete_set'.format(route_prefix)), name='delete-set', class_='control')}
-          ${h.csrf_token(request)}
-          ${h.hidden('uuids')}
-          <button type="button" class="button">Delete Selected</button>
-          ${h.end_form()}
-      % endif
+  % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
+      ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button type="is-danger"
+                :disabled="deleteSelectedDisabled"
+                @click="deleteSelectedSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteSelectedText }}
+      </b-button>
+      ${h.end_form()}
   % endif
 
   ## delete search results
-  % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
-      % if use_buefy:
-          ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
-          ${h.csrf_token(request)}
-          <b-button type="is-danger"
-                    :disabled="deleteResultsDisabled"
-                    :title="total ? null : 'There are no results to delete'"
-                    @click="deleteResultsSubmit()"
-                    icon-pack="fas"
-                    icon-left="trash">
-            {{ deleteResultsText }}
-          </b-button>
-          ${h.end_form()}
-      % else:
-          ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete', class_='control')}
-          ${h.csrf_token(request)}
-          <button type="button">Delete Results</button>
-          ${h.end_form()}
-      % endif
+  % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
+      ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
+      ${h.csrf_token(request)}
+      <b-button type="is-danger"
+                :disabled="deleteResultsDisabled"
+                :title="total ? null : 'There are no results to delete'"
+                @click="deleteResultsSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteResultsText }}
+      </b-button>
+      ${h.end_form()}
   % endif
 
 </%def>
 
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
 <%def name="page_content()">
-  <${grid.component} :csrftoken="csrftoken"
-     % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-     @deleteActionClicked="deleteObject"
-     % endif
-     >
-  </${grid.component}>
-  % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
+
+  % if download_results_path:
+      <b-notification type="is-info">
+        Your download should start automatically, or you can
+        ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results'.format(route_prefix)), h.os.path.basename(download_results_path)))}
+      </b-notification>
+  % endif
+
+  % if download_results_rows_path:
+      <b-notification type="is-info">
+        Your download should start automatically, or you can
+        ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results_rows'.format(route_prefix)), h.os.path.basename(download_results_rows_path)))}
+      </b-notification>
+  % endif
+
+  ${self.render_grid_component()}
+
+  % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
       ${h.form('#', ref='deleteObjectForm')}
       ${h.csrf_token(request)}
       ${h.end_form()}
   % endif
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="render_grid_component()">
+  ${grid.render_vue_tag()}
+</%def>
+
+##############################
+## vue components
+##############################
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+
+  ## DEPRECATED; called for back-compat
+  ${self.make_grid_component()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_grid_component()">
+  ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   <script type="text/javascript">
 
-    ${grid.component_studly}.data = function() { return ${grid.component_studly}Data }
+    % if getattr(master, 'supports_grid_totals', False):
+        ${grid.vue_component}Data.gridTotalsDisplay = null
+        ${grid.vue_component}Data.gridTotalsFetching = false
 
-    Vue.component('${grid.component}', ${grid.component_studly})
+        ${grid.vue_component}.methods.gridTotalsFetch = function() {
+            this.gridTotalsFetching = true
 
-  </script>
-</%def>
+            let url = '${url(f'{route_prefix}.fetch_grid_totals')}'
+            this.simpleGET(url, {}, response => {
+                this.gridTotalsDisplay = response.data.totals_display
+                this.gridTotalsFetching = false
+            }, response => {
+                this.gridTotalsFetching = false
+            })
+        }
 
-<%def name="render_this_page()">
-  ${self.page_content()}
-</%def>
+        ${grid.vue_component}.methods.appliedFiltersHook = function() {
+            this.gridTotalsDisplay = null
+            this.gridTotalsFetching = false
+        }
+    % endif
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+    ## maybe auto-redirect to download latest results file
+    % if download_results_path:
+        ThisPage.methods.downloadResultsRedirect = function() {
+            location.href = '${url('{}.download_results'.format(route_prefix))}?filename=${h.os.path.basename(download_results_path)}';
+        }
+        ThisPage.mounted = function() {
+            // we give this 1 second before attempting the redirect; otherwise
+            // the FontAwesome icons do not seem to load properly.  so this way
+            // the page should fully render before redirecting
+            window.setTimeout(this.downloadResultsRedirect, 1000)
+        }
+    % endif
 
-  ## TODO: stop using |n filter
-  ${grid.render_buefy(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
-</%def>
+    ## maybe auto-redirect to download latest "rows for results" file
+    % if download_results_rows_path:
+        ThisPage.methods.downloadResultsRowsRedirect = function() {
+            location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}';
+        }
+        ThisPage.mounted = function() {
+            // we give this 1 second before attempting the redirect; otherwise
+            // the FontAwesome icons do not seem to load properly.  so this way
+            // the page should fully render before redirecting
+            window.setTimeout(this.downloadResultsRowsRedirect, 1000)
+        }
+    % endif
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+    % if request.session.pop('{}.results_csv.generated'.format(route_prefix), False):
+        ThisPage.mounted = function() {
+            location.href = '${url('{}.results_csv_download'.format(route_prefix))}';
+        }
+    % endif
+    % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False):
+        ThisPage.mounted = function() {
+            location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}';
+        }
+    % endif
 
     ## delete single object
-    % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
+    % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
         ThisPage.methods.deleteObject = function(url) {
             if (confirm("Are you sure you wish to delete this ${model_title}?")) {
                 let form = this.$refs.deleteObjectForm
@@ -306,13 +389,113 @@
         }
     % endif
 
+    ## download results
+    % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
+
+        ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}'
+        ${grid.vue_component}Data.showDownloadResultsDialog = false
+        ${grid.vue_component}Data.downloadResultsFieldsMode = 'default'
+        ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n}
+        ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
+        ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
+
+        ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = []
+        ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = []
+
+        ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() {
+            let excluded = []
+            this.downloadResultsFieldsAvailable.forEach(field => {
+                if (!this.downloadResultsFieldsIncluded.includes(field)) {
+                    excluded.push(field)
+                }
+            }, this)
+            return excluded
+        }
+
+        ${grid.vue_component}.methods.downloadResultsExcludeFields = function() {
+            const selected = Array.from(this.downloadResultsIncludedFieldsSelected)
+            if (!selected) {
+                return
+            }
+
+            selected.forEach(field => {
+                let index
+
+                // remove field from selected
+                index = this.downloadResultsIncludedFieldsSelected.indexOf(field)
+                if (index >= 0) {
+                    this.downloadResultsIncludedFieldsSelected.splice(index, 1)
+                }
+
+                // remove field from included
+                // nb. excluded list will reflect this change too
+                index = this.downloadResultsFieldsIncluded.indexOf(field)
+                if (index >= 0) {
+                    this.downloadResultsFieldsIncluded.splice(index, 1)
+                }
+            })
+        }
+
+        ${grid.vue_component}.methods.downloadResultsIncludeFields = function() {
+            const selected = Array.from(this.downloadResultsExcludedFieldsSelected)
+            if (!selected) {
+                return
+            }
+
+            selected.forEach(field => {
+                let index
+
+                // remove field from selected
+                index = this.downloadResultsExcludedFieldsSelected.indexOf(field)
+                if (index >= 0) {
+                    this.downloadResultsExcludedFieldsSelected.splice(index, 1)
+                }
+
+                // add field to included
+                // nb. excluded list will reflect this change too
+                this.downloadResultsFieldsIncluded.push(field)
+            })
+        }
+
+        ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() {
+            this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault)
+            this.downloadResultsFieldsMode = 'default'
+        }
+
+        ${grid.vue_component}.methods.downloadResultsUseAllFields = function() {
+            this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable)
+            this.downloadResultsFieldsMode = 'all'
+        }
+
+        ${grid.vue_component}.methods.downloadResultsSubmit = function() {
+            this.$refs.download_results_form.submit()
+        }
+    % endif
+
+    ## download rows for results
+    % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+
+        ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false
+        ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results"
+
+        ${grid.vue_component}.methods.downloadResultsRows = function() {
+            if (confirm("This will generate an Excel file which contains "
+                        + "not the results themselves, but the *rows* for "
+                        + "each.\n\nAre you sure you want this?")) {
+                this.downloadResultsRowsButtonDisabled = true
+                this.downloadResultsRowsButtonText = "Working, please wait..."
+                this.$refs.downloadResultsRowsForm.submit()
+            }
+        }
+    % endif
+
     ## enable / disable selected objects
-    % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'):
+    % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
 
-        ${grid.component_studly}Data.enableSelectedSubmitting = false
-        ${grid.component_studly}Data.enableSelectedText = "Enable Selected"
+        ${grid.vue_component}Data.enableSelectedSubmitting = false
+        ${grid.vue_component}Data.enableSelectedText = "Enable Selected"
 
-        ${grid.component_studly}.computed.enableSelectedDisabled = function() {
+        ${grid.vue_component}.computed.enableSelectedDisabled = function() {
             if (this.enableSelectedSubmitting) {
                 return true
             }
@@ -322,7 +505,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.enableSelectedSubmit = function() {
+        ${grid.vue_component}.methods.enableSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -337,10 +520,10 @@
             this.$refs.enable_selected_form.submit()
         }
 
-        ${grid.component_studly}Data.disableSelectedSubmitting = false
-        ${grid.component_studly}Data.disableSelectedText = "Disable Selected"
+        ${grid.vue_component}Data.disableSelectedSubmitting = false
+        ${grid.vue_component}Data.disableSelectedText = "Disable Selected"
 
-        ${grid.component_studly}.computed.disableSelectedDisabled = function() {
+        ${grid.vue_component}.computed.disableSelectedDisabled = function() {
             if (this.disableSelectedSubmitting) {
                 return true
             }
@@ -350,7 +533,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.disableSelectedSubmit = function() {
+        ${grid.vue_component}.methods.disableSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -368,12 +551,12 @@
     % endif
 
     ## delete selected objects
-    % if master.set_deletable and master.has_perm('delete_set'):
+    % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
 
-        ${grid.component_studly}Data.deleteSelectedSubmitting = false
-        ${grid.component_studly}Data.deleteSelectedText = "Delete Selected"
+        ${grid.vue_component}Data.deleteSelectedSubmitting = false
+        ${grid.vue_component}Data.deleteSelectedText = "Delete Selected"
 
-        ${grid.component_studly}.computed.deleteSelectedDisabled = function() {
+        ${grid.vue_component}.computed.deleteSelectedDisabled = function() {
             if (this.deleteSelectedSubmitting) {
                 return true
             }
@@ -383,7 +566,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.deleteSelectedSubmit = function() {
+        ${grid.vue_component}.methods.deleteSelectedSubmit = function() {
             let uuids = this.checkedRowUUIDs()
             if (!uuids.length) {
                 alert("You must first select one or more objects to disable.")
@@ -399,12 +582,12 @@
         }
     % endif
 
-    % if master.bulk_deletable and master.has_perm('bulk_delete'):
+    % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
 
-        ${grid.component_studly}Data.deleteResultsSubmitting = false
-        ${grid.component_studly}Data.deleteResultsText = "Delete Results"
+        ${grid.vue_component}Data.deleteResultsSubmitting = false
+        ${grid.vue_component}Data.deleteResultsText = "Delete Results"
 
-        ${grid.component_studly}.computed.deleteResultsDisabled = function() {
+        ${grid.vue_component}.computed.deleteResultsDisabled = function() {
             if (this.deleteResultsSubmitting) {
                 return true
             }
@@ -414,7 +597,7 @@
             return false
         }
 
-        ${grid.component_studly}.methods.deleteResultsSubmit = function() {
+        ${grid.vue_component}.methods.deleteResultsSubmit = function() {
             // TODO: show "plural model title" here?
             if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) {
                 return
@@ -427,12 +610,12 @@
 
     % endif
 
-    % if master.mergeable and master.has_perm('merge'):
+    % if getattr(master, 'mergeable', False) and master.has_perm('merge'):
 
-        ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}"
-        ${grid.component_studly}Data.mergeFormSubmitting = false
+        ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}"
+        ${grid.vue_component}Data.mergeFormSubmitting = false
 
-        ${grid.component_studly}.methods.submitMergeForm = function() {
+        ${grid.vue_component}.methods.submitMergeForm = function() {
             this.mergeFormSubmitting = true
             this.mergeFormButtonText = "Working, please wait..."
         }
@@ -440,17 +623,10 @@
   </script>
 </%def>
 
-
-% if use_buefy:
-    ${parent.body()}
-
-% else:
-    ## no buefy, so do the traditional thing
-    ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
-
-    % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-        ${h.form('#', id='delete-object-form')}
-        ${h.csrf_token(request)}
-        ${h.end_form()}
-    % endif
-% endif
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    ${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
+    Vue.component('${grid.vue_tagname}', ${grid.vue_component})
+  </script>
+</%def>
diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako
index 8924fcd0..487d258d 100644
--- a/tailbone/templates/master/merge.mako
+++ b/tailbone/templates/master/merge.mako
@@ -3,36 +3,6 @@
 
 <%def name="title()">Merge 2 ${model_title_plural}</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    $(function() {
-
-        $('button.swap').click(function() {
-            $(this).button('disable').button('option', 'label', "Swapping, please wait...");
-            var form = $(this).parents('form');
-            var input = form.find('input[name="uuids"]');
-            var uuids = input.val().split(',');
-            uuids.reverse();
-            input.val(uuids.join(','));
-            form.submit();
-        });
-
-        $('form.merge input[type="submit"]').click(function() {
-            $(this).button('disable').button('option', 'label', "Merging, please wait...");
-            var form = $(this).parents('form');
-            form.append($('<input type="hidden" name="commit-merge" value="yes" />'));
-            form.submit();
-        });
-
-    });
-
-  </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
@@ -92,70 +62,55 @@
 </%def>
 
 <%def name="page_content()">
+  <p>
+    You are about to <strong>merge</strong> two ${model_title} records,
+    (possibly) along with various related data.&nbsp; The tool you are using now
+    is somewhat generic and is not able to give you the full picture of the
+    implications of this merge.&nbsp; You are urged to proceed with caution!&nbsp;
+  </p>
 
-<p>
-  You are about to <strong>merge</strong> two ${model_title} records,
-  (possibly) along with various related data.&nbsp; The tool you are using now
-  is somewhat generic and is not able to give you the full picture of the
-  implications of this merge.&nbsp; You are urged to proceed with caution!&nbsp;
-</p>
+  <p class="warning">
+    <strong>Unless you know what you're doing, a good rule of thumb (though still no
+    guarantee) is to merge <em>only</em> if the "resulting" column is all-white.</strong>&nbsp;
+    (You may be able to swap kept/removed in order to achieve this.)
+  </p>
 
-<p class="warning">
-  <strong>Unless you know what you're doing, a good rule of thumb (though still no
-  guarantee) is to merge <em>only</em> if the "resulting" column is all-white.</strong>&nbsp;
-  (You may be able to swap kept/removed in order to achieve this.)
-</p>
+  <p>
+    The ${h.link_to("{} on the left".format(model_title), view_url(object_to_remove), target='_blank', class_='merge-object')}
+    will be <strong>deleted</strong>
+    and the ${h.link_to("{} on the right".format(model_title), view_url(object_to_keep), target='_blank', class_='merge-object')}
+    will be <strong>kept</strong>.&nbsp; The one which is to be kept may also
+    be updated to reflect certain aspects of the one being deleted; however again
+    the details are up to the app logic for this type of merge and aren't fully
+    known to the generic tool which you're using now.
+  </p>
 
-<p>
-  The ${h.link_to("{} on the left".format(model_title), view_url(object_to_remove), target='_blank', class_='merge-object')}
-  will be <strong>deleted</strong>
-  and the ${h.link_to("{} on the right".format(model_title), view_url(object_to_keep), target='_blank', class_='merge-object')}
-  will be <strong>kept</strong>.&nbsp; The one which is to be kept may also
-  be updated to reflect certain aspects of the one being deleted; however again
-  the details are up to the app logic for this type of merge and aren't fully
-  known to the generic tool which you're using now.
-</p>
+  <table class="diff">
+    <thead>
+      <tr>
+        <th>field name</th>
+        <th>deleting ${model_title}</th>
+        <th>keeping ${model_title}</th>
+        <th>resulting ${model_title}</th>
+      </tr>
+    </thead>
+    <tbody>
+      % for field in sorted(merge_fields):
+          <tr${' class="diff"' if keep_data[field] != remove_data[field] else ''|n}>
+            <td class="field">${field}</td>
+            <td class="value remove-value">${repr(remove_data[field])}</td>
+            <td class="value keep-value">${repr(keep_data[field])}</td>
+            <td class="value result-value${' diff' if resulting_data[field] != keep_data[field] else ''}">${repr(resulting_data[field])}</td>
+          </tr>
+      % endfor
+    </tbody>
+  </table>
 
-<table class="diff">
-  <thead>
-    <tr>
-      <th>field name</th>
-      <th>deleting ${model_title}</th>
-      <th>keeping ${model_title}</th>
-      <th>resulting ${model_title}</th>
-    </tr>
-  </thead>
-  <tbody>
-    % for field in sorted(merge_fields):
-        <tr${' class="diff"' if keep_data[field] != remove_data[field] else ''|n}>
-          <td class="field">${field}</td>
-          <td class="value remove-value">${repr(remove_data[field])}</td>
-          <td class="value keep-value">${repr(keep_data[field])}</td>
-          <td class="value result-value${' diff' if resulting_data[field] != keep_data[field] else ''}">${repr(resulting_data[field])}</td>
-        </tr>
-    % endfor
-  </tbody>
-</table>
-
-% if use_buefy:
-    <merge-buttons></merge-buttons>
-
-% else:
-## no buefy; do legacy stuff
-${h.form(request.current_route_url(), class_='merge')}
-${h.csrf_token(request)}
-<div class="buttons">
-  ${h.hidden('uuids', value='{},{}'.format(object_to_remove.uuid, object_to_keep.uuid))}
-  <a class="button" href="${index_url}">Whoops, nevermind</a>
-  <button type="button" class="swap">Swap which ${model_title} is kept/removed</button>
-  ${h.submit('merge', "Yes, perform this merge")}
-</div>
-${h.end_form()}
-% endif
+  <merge-buttons></merge-buttons>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
 
   <script type="text/x-template" id="merge-buttons-template">
     <div class="level" style="margin-top: 2em;">
@@ -168,7 +123,7 @@ ${h.end_form()}
         <div class="level-item">
           ${h.form(request.current_route_url(), **{'@submit': 'submitSwapForm'})}
           ${h.csrf_token(request)}
-          ${h.hidden('uuids', value='{},{}'.format(object_to_keep.uuid, object_to_remove.uuid))}
+          ${h.hidden('uuids', value=f'{keeping_uuid},{removing_uuid}')}
           <b-button native-type="submit"
                     :disabled="swapFormSubmitting">
             {{ swapFormButtonText }}
@@ -177,13 +132,9 @@ ${h.end_form()}
         </div>
 
         <div class="level-item">
-          % if use_buefy:
           ${h.form(request.current_route_url(), **{'@submit': 'submitMergeForm'})}
-          % else:
-          ${h.form(request.current_route_url())}
-          % endif
           ${h.csrf_token(request)}
-          ${h.hidden('uuids', value='{},{}'.format(object_to_remove.uuid, object_to_keep.uuid))}
+          ${h.hidden('uuids', value=f'{removing_uuid},{keeping_uuid}')}
           ${h.hidden('commit-merge', value='yes')}
           <b-button type="is-primary"
                     native-type="submit"
@@ -196,11 +147,7 @@ ${h.end_form()}
       </div>
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+  <script>
 
     const MergeButtons = {
         template: '#merge-buttons-template',
@@ -224,10 +171,13 @@ ${h.end_form()}
         }
     }
 
-    Vue.component('merge-buttons', MergeButtons)
-
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('merge-buttons', MergeButtons)
+    <% request.register_component('merge-buttons', 'MergeButtons') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako
index 2d1b4db3..a6bb14f0 100644
--- a/tailbone/templates/master/versions.mako
+++ b/tailbone/templates/master/versions.mako
@@ -6,19 +6,8 @@
 ## ##############################################################################
 <%inherit file="/page.mako" />
 
-## TODO: this page still uses old-style grid but should use Buefy grid
-
 <%def name="title()">${model_title_plural} » ${instance_title} » history</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
-    $(function() {
-        $('.grid-wrapper').gridwrapper();
-    });
-  </script>
-</%def>
-
 <%def name="content_title()">
   Version History
 </%def>
@@ -27,31 +16,16 @@
   ${self.page_content()}
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
-
-    TailboneGrid.data = function() { return TailboneGridData }
-
-    Vue.component('tailbone-grid', TailboneGrid)
-
-  </script>
-</%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
-  ## TODO: stop using |n filter
-  ${grid.render_buefy()|n}
-</%def>
-
 <%def name="page_content()">
-  % if use_buefy:
-      <tailbone-grid :csrftoken="csrftoken">
-      </tailbone-grid>
-  % else:
-      ${grid.render_complete()|n}
-  % endif
+  ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})}
 </%def>
 
-${parent.body()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${grid.render_vue_template()}
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${grid.render_vue_finalize()}
+</%def>
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 94454bd9..118c028c 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -3,116 +3,334 @@
 
 <%def name="title()">${index_title} &raquo; ${instance_title}</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-      % if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-          <script type="text/javascript">
-
-            $(function () {
-
-                $('#context-menu a.delete-instance').on('click', function() {
-                    if (confirm("Are you sure you wish to delete this ${model_title}?")) {
-                        $(this).parents('form').submit();
-                    }
-                    return false;
-                });
-
-            });
-
-          </script>
-      % endif
-      % if master.has_rows:
-          <script type="text/javascript">
-            $(function() {
-                $('.grid-wrapper').gridwrapper();
-            });
-          </script>
-      % endif
-  % endif
-</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  % if master.has_rows and not use_buefy:
-      <style type="text/css">
-        .grid-wrapper {
-            margin-top: 10px;
-        }
-      </style>
-  % endif
-</%def>
-
 <%def name="content_title()">
   ${instance_title}
 </%def>
 
-<%def name="context_menu_items()">
-  <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li>
-  % if master.has_versions and request.rattail_config.versioning_enabled() and request.has_perm('{}.versions'.format(permission_prefix)):
-      <li>${h.link_to("Version History", action_url('versions', instance))}</li>
+<%def name="render_instance_header_title_extras()">
+  % if getattr(master, 'touchable', False) and master.has_perm('touch'):
+      <b-button title="&quot;Touch&quot; this record to trigger sync"
+                @click="touchRecord()"
+                :disabled="touchSubmitting">
+        % if request.use_oruga:
+            <o-icon icon="hand-pointer" />
+        % else:
+            <span><i class="fa fa-hand-pointer"></i></span>
+        % endif
+      </b-button>
   % endif
-  % if master.editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
-      <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li>
+  % if expose_versions:
+      <b-button icon-pack="fas"
+                icon-left="history"
+                @click="viewingHistory = !viewingHistory">
+        {{ viewingHistory ? "View Current" : "View History" }}
+      </b-button>
   % endif
-  ${self.context_menu_item_delete()}
-  % if master.creatable and master.show_create_link and request.has_perm('{}.create'.format(permission_prefix)):
-      % if master.creates_multiple:
-          <li>${h.link_to("Create new {}".format(model_title_plural), url('{}.create'.format(route_prefix)))}</li>
-      % else:
-          <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li>
-      % endif
-  % endif
-  % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)):
-      <li>${h.link_to("Clone this as new {}".format(model_title), url('{}.clone'.format(route_prefix), uuid=instance.uuid))}</li>
-  % endif
-  % if master.touchable and request.has_perm('{}.touch'.format(permission_prefix)):
-      <li>${h.link_to("\"Touch\" this {}".format(model_title), url('{}.touch'.format(route_prefix), uuid=instance.uuid))}</li>
-  % endif
-  % if master.has_rows and master.rows_downloadable_csv and request.has_perm('{}.row_results_csv'.format(permission_prefix)):
-      <li>${h.link_to("Download row results as CSV", url('{}.row_results_csv'.format(route_prefix), uuid=instance.uuid))}</li>
-  % endif
-  % if master.has_rows and master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'):
-      <li>${h.link_to("Download row results as XLSX", master.get_action_url('row_results_xlsx', instance))}</li>
+</%def>
+
+<%def name="object_helpers()">
+  ${parent.object_helpers()}
+  ${self.render_xref_helper()}
+</%def>
+
+<%def name="render_xref_helper()">
+  % if xref_buttons or xref_links:
+      <nav class="panel">
+        <p class="panel-heading">Cross-Reference</p>
+        <div class="panel-block">
+          <div style="display: flex; flex-direction: column; gap: 0.5rem;">
+            % for button in xref_buttons:
+                ${button}
+            % endfor
+            % for link in xref_links:
+                ${link}
+            % endfor
+          </div>
+        </div>
+      </nav>
   % endif
 </%def>
 
 <%def name="render_row_grid_tools()">
   ${rows_grid_tools}
+  % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'):
+      <b-button tag="a" href="${master.get_action_url('row_results_xlsx', instance)}"
+                icon-pack="fas"
+                icon-left="download">
+        Download Results XLSX
+      </b-button>
+  % endif
+  % if master.rows_downloadable_csv and master.has_perm('row_results_csv'):
+      <b-button tag="a" href="${master.get_action_url('row_results_csv', instance)}"
+                icon-pack="fas"
+                icon-left="download">
+        Download Results CSV
+      </b-button>
+  % endif
+</%def>
+
+<%def name="render_this_page_component()">
+  ## TODO: should override this in a cleaner way!  too much duplicate code w/ parent template
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+             :configure-fields-help="configureFieldsHelp"
+             % endif
+             % if expose_versions:
+             :viewing-history="viewingHistory"
+             % endif
+             >
+  </this-page>
 </%def>
 
 <%def name="render_this_page()">
-  ${parent.render_this_page()}
-  % if master.has_rows:
-      % if use_buefy:
-          <br />
-          <tailbone-grid></tailbone-grid>
-      % else:
-          ${rows_grid|n}
-      % endif
+  <div
+    % if expose_versions:
+    v-show="!viewingHistory"
+    % endif
+    >
+
+    ## render main form
+    ${parent.render_this_page()}
+
+    ## render row grid
+    % if getattr(master, 'has_rows', False):
+        <br />
+        % if rows_title:
+            <h4 class="block is-size-4">${rows_title}</h4>
+        % endif
+        ${self.render_row_grid_component()}
+    % endif
+  </div>
+
+  % if expose_versions:
+      <div v-show="viewingHistory">
+
+        <div style="display: flex; align-items: center; gap: 2rem;">
+          <h3 class="is-size-3">Version History</h3>
+          <p class="block">
+            <a href="${master.get_action_url('versions', instance)}"
+               target="_blank">
+              % if request.use_oruga:
+                  <o-icon icon="external-link-alt" />
+              % else:
+                  <i class="fas fa-external-link-alt"></i>
+              % endif
+              View as separate page
+            </a>
+          </p>
+        </div>
+
+        ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})}
+
+        <${b}-modal :width="1200"
+                    % if request.use_oruga:
+                    v-model:active="viewVersionShowDialog"
+                    % else:
+                    :active.sync="viewVersionShowDialog"
+                    % endif
+                    >
+          <div class="card">
+            <div class="card-content">
+              <div style="display: flex; flex-direction: column; gap: 1.5rem;">
+
+                <div style="display: flex; gap: 1rem;">
+
+                  <div style="flex-grow: 1;">
+                    <b-field horizontal label="Changed">
+                      <div v-html="viewVersionData.changed"></div>
+                    </b-field>
+                    <b-field horizontal label="Changed by">
+                      <div v-html="viewVersionData.changed_by"></div>
+                    </b-field>
+                    <b-field horizontal label="IP Address">
+                      <div v-html="viewVersionData.remote_addr"></div>
+                    </b-field>
+                    <b-field horizontal label="Comment">
+                      <div v-html="viewVersionData.comment"></div>
+                    </b-field>
+                    <b-field horizontal label="TXN ID">
+                      <div v-html="viewVersionData.txnid"></div>
+                    </b-field>
+                  </div>
+
+                  <div style="display: flex; flex-direction: column; justify-content: space-between;">
+
+                    <div class="buttons">
+                      <b-button @click="viewPrevRevision()"
+                                type="is-primary"
+                                icon-pack="fas"
+                                icon-left="arrow-left"
+                                :disabled="!viewVersionData.prev_txnid">
+                        Older
+                      </b-button>
+                      <b-button @click="viewNextRevision()"
+                                type="is-primary"
+                                icon-pack="fas"
+                                icon-right="arrow-right"
+                                :disabled="!viewVersionData.next_txnid">
+                        Newer
+                      </b-button>
+                    </div>
+
+                    <div>
+                      <a :href="viewVersionData.url"
+                         target="_blank">
+                        % if request.use_oruga:
+                            <o-icon icon="external-link-alt" />
+                        % else:
+                            <i class="fas fa-external-link-alt"></i>
+                        % endif
+                        View as separate page
+                      </a>
+                    </div>
+
+                    <b-button @click="toggleVersionFields()">
+                      {{ viewVersionShowAllFields ? "Show Diffs Only" : "Show All Fields" }}
+                    </b-button>
+                  </div>
+
+                </div>
+
+                <div v-for="version in viewVersionData.versions"
+                     :key="version.key">
+
+                  <p class="block has-text-weight-bold">
+                    {{ version.model_title }}
+                    ({{ version.operation }})
+                  </p>
+
+                  <table class="diff monospace is-size-7"
+                         :class="version.diff_class">
+                    <thead>
+                      <tr>
+                        <th>field name</th>
+                        <th>old value</th>
+                        <th>new value</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr v-for="field in version.fields"
+                          :key="field"
+                          :class="{diff: version.values[field].after != version.values[field].before}"
+                          v-show="viewVersionShowAllFields || version.values[field].after != version.values[field].before">
+                        <td class="field has-text-weight-bold">{{ field }}</td>
+                        <td class="old-value" v-html="version.values[field].before"></td>
+                        <td class="new-value" v-html="version.values[field].after"></td>
+                      </tr>
+                    </tbody>
+                  </table>
+
+                </div>
+
+              </div>
+              % if request.use_oruga:
+                  <o-loading v-model:active="viewVersionLoading" :is-full-page="false" />
+              % else:
+                  <b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading>
+              % endif
+            </div>
+          </div>
+        </${b}-modal>
+      </div>
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  % if master.has_rows:
-      ## TODO: stop using |n filter
-      ${rows_grid.render_buefy(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n}
-  % endif
-  ${parent.render_this_page_template()}
+<%def name="render_row_grid_component()">
+  ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')}
 </%def>
 
-<%def name="make_this_page_component()">
-  % if master.has_rows:
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if getattr(master, 'has_rows', False):
+      ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))}
+  % endif
+  % if expose_versions:
+      ${versions_grid.render_vue_template()}
+  % endif
+</%def>
 
-    TailboneGrid.data = function() { return TailboneGridData }
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    Vue.component('tailbone-grid', TailboneGrid)
+    % if getattr(master, 'touchable', False) and master.has_perm('touch'):
 
+        WholePageData.touchSubmitting = false
+
+        WholePage.methods.touchRecord = function() {
+            this.touchSubmitting = true
+            location.href = '${master.get_action_url('touch', instance)}'
+        }
+
+    % endif
+
+    % if expose_versions:
+
+        WholePageData.viewingHistory = false
+        ThisPage.props.viewingHistory = Boolean
+
+        ThisPageData.gettingRevisions = false
+        ThisPageData.gotRevisions = false
+
+        ThisPageData.viewVersionShowDialog = false
+        ThisPageData.viewVersionData = {}
+        ThisPageData.viewVersionShowAllFields = false
+        ThisPageData.viewVersionLoading = false
+
+        // auto-fetch grid results when first viewing history
+        ThisPage.watch.viewingHistory = function(newval, oldval) {
+            if (!this.gotRevisions && !this.gettingRevisions) {
+                this.gettingRevisions = true
+                this.$refs.versionsGrid.loadAsyncData(null, () => {
+                    this.gettingRevisions = false
+                    this.gotRevisions = true
+                }, () => {
+                    this.gettingRevisions = false
+                })
+            }
+        }
+
+        VersionsGrid.methods.viewRevision = function(row) {
+            this.$emit('view-revision', row)
+        }
+
+        ThisPage.methods.viewRevision = function(row) {
+            this.viewVersionLoading = true
+
+            let url = '${master.get_action_url('revisions_data', instance)}'
+            let params = {txnid: row.id}
+            this.simpleGET(url, params, response => {
+                this.viewVersionData = response.data
+                this.viewVersionLoading = false
+            }, response => {
+                this.viewVersionLoading = false
+            })
+
+            this.viewVersionShowDialog = true
+        }
+
+        ThisPage.methods.viewPrevRevision = function() {
+            this.viewRevision({id: this.viewVersionData.prev_txnid})
+        }
+
+        ThisPage.methods.viewNextRevision = function() {
+            this.viewRevision({id: this.viewVersionData.next_txnid})
+        }
+
+        ThisPage.methods.toggleVersionFields = function() {
+            this.viewVersionShowAllFields = !this.viewVersionShowAllFields
+        }
+
+    % endif
   </script>
-  % endif
-  ${parent.make_this_page_component()}
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  % if getattr(master, 'has_rows', False):
+      ${rows_grid.render_vue_finalize()}
+  % endif
+  % if expose_versions:
+      ${versions_grid.render_vue_finalize()}
+  % endif
+</%def>
diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako
index 66756c3e..623a33a0 100644
--- a/tailbone/templates/master/view_row.mako
+++ b/tailbone/templates/master/view_row.mako
@@ -12,9 +12,6 @@
   % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
       <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li>
   % endif
-  % if master.rows_deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)):
-      <li>${h.link_to("Delete this {}".format(model_title), action_url('delete', instance))}</li>
-  % endif
   % if rows_creatable and request.has_perm('{}.create'.format(permission_prefix)):
       <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li>
   % endif
diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako
index 13c87ae6..dfe03a64 100644
--- a/tailbone/templates/master/view_version.mako
+++ b/tailbone/templates/master/view_version.mako
@@ -11,104 +11,47 @@
         overflow: auto;
     }
 
+    .versions-wrapper {
+        margin-left: 2rem;
+    }
+
   </style>
 </%def>
 
 <%def name="page_content()">
-## TODO: this was basically copied from Revel diff template..need to abstract
 
-<div class="form-wrapper">
+  <div class="form-wrapper" style="margin: 1rem; 0;">
+    <div class="form">
 
-  <div class="form">
+      <b-field label="Changed" horizontal>
+        <span>${h.pretty_datetime(request.rattail_config, changed)}</span>
+      </b-field>
+
+      <b-field label="Changed by" horizontal>
+        <span>${transaction.user or ''}</span>
+      </b-field>
+
+      <b-field label="IP Address" horizontal>
+        <span>${transaction.remote_addr}</span>
+      </b-field>
+
+      <b-field label="Comment" horizontal>
+        <span>${transaction.meta.get('comment') or ''}</span>
+      </b-field>
+
+      <b-field label="TXN ID" horizontal>
+        <span>${transaction.id}</span>
+      </b-field>
 
-    <div class="field-wrapper">
-      <label>Changed</label>
-      <div class="field">${h.pretty_datetime(request.rattail_config, changed)}</div>
     </div>
-
-    <div class="field-wrapper">
-      <label>Changed by</label>
-      <div class="field">${transaction.user or ''}</div>
-    </div>
-
-    <div class="field-wrapper">
-      <label>IP Address</label>
-      <div class="field">${transaction.remote_addr}</div>
-    </div>
-
-    <div class="field-wrapper">
-      <label>Comment</label>
-      <div class="field">${transaction.meta.get('comment') or ''}</div>
-    </div>
-
   </div>
 
-</div><!-- form-wrapper -->
-
-% for version in versions:
-
-    <h2>${title_for_version(version)}</h2>
-
-    % if version.previous and version.operation_type == continuum.Operation.DELETE:
-        <table class="diff monospace deleted">
-          <thead>
-            <tr>
-              <th>field name</th>
-              <th>old value</th>
-              <th>new value</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for field in fields_for_version(version):
-               <tr>
-                 <td class="field">${field}</td>
-                 <td class="value old-value">${repr(getattr(version.previous, field))}</td>
-                 <td class="value new-value">&nbsp;</td>
-               </tr>
-            % endfor
-          </tbody>
-        </table>
-    % elif version.previous:
-        <table class="diff monospace dirty">
-          <thead>
-            <tr>
-              <th>field name</th>
-              <th>old value</th>
-              <th>new value</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for field in fields_for_version(version):
-               <tr${' class="diff"' if getattr(version, field) != getattr(version.previous, field) else ''|n}>
-                 <td class="field">${field}</td>
-                 <td class="value old-value">${repr(getattr(version.previous, field))}</td>
-                 <td class="value new-value">${repr(getattr(version, field))}</td>
-               </tr>
-            % endfor
-          </tbody>
-        </table>
-    % else:
-        <table class="diff monospace new">
-          <thead>
-            <tr>
-              <th>field name</th>
-              <th>old value</th>
-              <th>new value</th>
-            </tr>
-          </thead>
-          <tbody>
-            % for field in fields_for_version(version):
-               <tr>
-                 <td class="field">${field}</td>
-                 <td class="value old-value">&nbsp;</td>
-                 <td class="value new-value">${repr(getattr(version, field))}</td>
-               </tr>
-            % endfor
-          </tbody>
-        </table>
-    % endif
-
-% endfor
+  <div class="versions-wrapper">
+    % for diff in version_diffs:
+        <h4 class="is-size-4 block">${diff.title}</h4>
+        ${diff.render_html()}
+    % endfor
+  </div>
 </%def>
 
 
diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako
new file mode 100644
index 00000000..f1f0e39f
--- /dev/null
+++ b/tailbone/templates/members/configure.mako
@@ -0,0 +1,77 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">General</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field grouped>
+
+      <b-field label="Key Field">
+        <b-select name="rattail.members.key_field"
+                  v-model="simpleSettings['rattail.members.key_field']"
+                  @input="updateKeyLabel()">
+          <option value="id">id</option>
+          <option value="number">number</option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Key Field Label">
+        <b-input name="rattail.members.key_label"
+                 v-model="simpleSettings['rattail.members.key_label']"
+                 @input="settingsNeedSaved = true">
+        </b-input>
+      </b-field>
+
+    </b-field>
+
+    <b-field message="If set, grid links are to Member tab of Profile view.">
+      <b-checkbox name="rattail.members.straight_to_profile"
+                  v-model="simpleSettings['rattail.members.straight_to_profile']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Link directly to Profile when applicable
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Relationships</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="By default a Person may have multiple Member accounts.">
+      <b-checkbox name="rattail.members.max_one_per_person"
+                  v-model="simpleSettings['rattail.members.max_one_per_person']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Limit one (1) Member account per Person
+      </b-checkbox>
+    </b-field>
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPage.methods.getLabelForKey = function(key) {
+        switch (key) {
+        case 'id':
+            return "ID"
+        case 'number':
+            return "Number"
+        default:
+            return "Key"
+        }
+    }
+
+    ThisPage.methods.updateKeyLabel = function() {
+        this.simpleSettings['rattail.members.key_label'] = this.getLabelForKey(
+            this.simpleSettings['rattail.members.key_field'])
+        this.settingsNeedSaved = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/members/view.mako b/tailbone/templates/members/view.mako
index 3f2b6c14..071e37d3 100644
--- a/tailbone/templates/members/view.mako
+++ b/tailbone/templates/members/view.mako
@@ -4,16 +4,7 @@
 
 <%def name="object_helpers()">
   ${parent.object_helpers()}
-  <% people = h.OrderedDict() %>
-  % if instance.person:
-      <% people[instance.person.uuid] = instance.person %>
-  % endif
-  % if instance.customer:
-      % for person in instance.customer.people:
-          <% people[person.uuid] = person %>
-      % endfor
-  % endif
-  ${view_profiles_helper(people.values())}
+  ${view_profiles_helper(show_profiles_people)}
 </%def>
 
 ${parent.body()}
diff --git a/tailbone/templates/menu.mako b/tailbone/templates/menu.mako
index 7549e763..65acd0dd 100644
--- a/tailbone/templates/menu.mako
+++ b/tailbone/templates/menu.mako
@@ -4,16 +4,16 @@
 
   % for topitem in menus:
       <li>
-        % if topitem.is_link:
-            ${h.link_to(topitem.title, topitem.url, target=topitem.target)}
+        % if topitem['is_link']:
+            ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'])}
         % else:
-            <a>${topitem.title}</a>
+            <a>${topitem['title']}</a>
             <ul>
-              % for subitem in topitem.items:
-                  % if subitem.is_sep:
+              % for subitem in topitem['items']:
+                  % if subitem['is_sep']:
                       <li>-</li>
                   % else:
-                      <li>${h.link_to(subitem.title, subitem.url, target=subitem.target)}</li>
+                      <li>${h.link_to(subitem['title'], subitem['url'], target=subitem['target'])}</li>
                   % endif
               % endfor
             </ul>
diff --git a/tailbone/templates/messages/archive/index.mako b/tailbone/templates/messages/archive/index.mako
index 002b9e90..16a05ee2 100644
--- a/tailbone/templates/messages/archive/index.mako
+++ b/tailbone/templates/messages/archive/index.mako
@@ -3,15 +3,6 @@
 
 <%def name="title()">Message Archive</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-    destination = "Inbox";
-  </script>
-  % endif
-</%def>
-
 <%def name="context_menu_items()">
   ${parent.context_menu_items()}
   <li>${h.link_to("Go to my Message Inbox", url('messages.inbox'))}</li>
diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako
index fc046e36..39236f75 100644
--- a/tailbone/templates/messages/create.mako
+++ b/tailbone/templates/messages/create.mako
@@ -2,148 +2,26 @@
 <%inherit file="/master/create.mako" />
 <%namespace file="/messages/recipients.mako" import="message_recipients_template" />
 
-<%def name="content_title()">${parent.content_title() if not use_buefy else ''}</%def>
+<%def name="content_title()"></%def>
 
 <%def name="extra_javascript()">
   ${parent.extra_javascript()}
-  % if use_buefy:
-      ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))}
-  % else:
-  ${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))}
-  <script type="text/javascript">
-
-    var recipient_mappings = new Map([
-        <% last = len(available_recipients) %>
-        % for i, recip in enumerate(available_recipients, 1):
-            <% uuid, entry = recip %>
-            ['${uuid}', ${json.dumps(entry)|n}]${',' if i < last else ''}
-        % endfor
-    ]);
-
-    // validate message before sending
-    function validate_message_form() {
-        var form = $('#deform');
-
-        if (! form.find('input[name="set_recipients"]').val()) {
-            alert("You must specify some recipient(s) for the message.");
-            $('.set_recipients input').data('ui-tagit').tagInput.focus();
-            return false;
-        }
-
-        if (! form.find('input[name="subject"]').val()) {
-            alert("You must provide a subject for the message.");
-            form.find('input[name="subject"]').focus();
-            return false;
-        }
-
-        return true;
-    }
-
-    $(function() {
-
-        var recipients = $('.set_recipients input');
-
-        recipients.tagit({
-
-            autocomplete: {
-                delay: 0,
-                minLength: 2,
-                autoFocus: true,
-                removeConfirmation: true,
-
-                source: function(request, response) {
-                    var term = request.term.toLowerCase();
-                    var data = [];
-                    recipient_mappings.forEach(function(name, uuid) {
-                        if (!name.toLowerCase && name.name) {
-                            name = name.name;
-                        }
-                        if (name.toLowerCase().indexOf(term) >= 0) {
-                            data.push({value: uuid, label: name});
-                        }
-                    });
-                    response(data);
-                }
-            },
-
-            beforeTagAdded: ${self.before_tag_added()},
-
-            beforeTagRemoved: function(event, ui) {
-
-                // Unfortunately we're responsible for cleaning up the hidden
-                // field, since the values there do not match the tag labels.
-                var tags = recipients.tagit('assignedTags');
-                var uuid = ui.tag.data('uuid');
-                tags = tags.filter(function(element) {
-                    return element != uuid;
-                });
-                recipients.data('ui-tagit')._updateSingleTagsField(tags);
-            }
-        });
-
-        // set focus to recipients field
-        recipients.data('ui-tagit').tagInput.focus();
-    });
-
-  </script>
-  ${self.validate_message_js()}
-  % endif
-</%def>
-
-<%def name="validate_message_js()">
-  <script type="text/javascript">
-    $(function() {
-        $('#new-message').submit(function() {
-            return validate_message_form();
-        });
-    });
-  </script>
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))}
 </%def>
 
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if use_buefy:
-      <style type="text/css">
-
-        .this-page-content {
-          width: 100%;
-        }
-
-        .this-page-content .buttons {
-            margin-left: 20rem;
-        }
-
-      </style>
-  % else:
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))}
   <style type="text/css">
 
-    .recipients input {
-        min-width: 525px;
+    .this-page-content {
+      width: 100%;
     }
 
-    .subject input {
-        min-width: 540px;
-    }
-
-    .body textarea {
-        min-width: 540px;
+    .this-page-content .buttons {
+        margin-left: 20rem;
     }
 
   </style>
-  % endif
-</%def>
-
-<%def name="before_tag_added()">
-    function(event, ui) {
-
-        // Lookup the name in cached mapping, and show that on the tag, instead
-        // of the UUID.  The tagit widget should take care of keeping the
-        // hidden field in sync for us, still using the UUID.
-        var uuid = ui.tagLabel;
-        var name = recipient_mappings.get(uuid);
-        ui.tag.find('.tagit-label').html(name);
-    }
 </%def>
 
 <%def name="context_menu_items()">
@@ -154,20 +32,30 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   ${message_recipients_template()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n})
     TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n}
 
+    TailboneForm.methods.subjectKeydown = function(event) {
+
+        // do not auto-submit form when user presses enter in subject field
+        if (event.which == 13) {
+            event.preventDefault()
+
+            // set focus to msg body input if possible
+            if (this.$refs.messageBody && this.$refs.messageBody.focus) {
+                this.$refs.messageBody.focus()
+            }
+        }
+    }
+
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/inbox/index.mako b/tailbone/templates/messages/inbox/index.mako
index f88010b0..2ac24b9e 100644
--- a/tailbone/templates/messages/inbox/index.mako
+++ b/tailbone/templates/messages/inbox/index.mako
@@ -3,15 +3,6 @@
 
 <%def name="title()">Message Inbox</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-    destination = "Archive";
-  </script>
-  % endif
-</%def>
-
 <%def name="context_menu_items()">
   ${parent.context_menu_items()}
   <li>${h.link_to("Go to my Message Archive", url('messages.archive'))}</li>
diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako
index 4ded5571..eaa4b6c9 100644
--- a/tailbone/templates/messages/index.mako
+++ b/tailbone/templates/messages/index.mako
@@ -1,52 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    var destination = null;
-
-    function update_move_button() {
-        var count = $('.grid tr:not(.header) td.checkbox input:checked').length;
-        $('form[name="move-selected"] button')
-            .button('option', 'label', "Move " + count + " selected to " + destination)
-            .button('option', 'disabled', count < 1);
-    }
-
-    $(function() {
-
-        update_move_button();
-
-        $('.grid-wrapper').on('change', 'tr.header td.checkbox input', function() {
-            update_move_button();
-        });
-
-        $('.grid-wrapper').on('click', 'tr:not(.header) td.checkbox input', function() {
-            update_move_button();
-        });
-
-        $('form[name="move-selected"]').submit(function() {
-            var uuids = [];
-            $('.grid tr:not(.header) td.checkbox input:checked').each(function() {
-                uuids.push($(this).parents('tr:first').data('uuid'));
-            });
-            if (! uuids.length) {
-                return false;
-            }
-            $(this).find('[name="uuids"]').val(uuids.toString());
-            $(this).find('button')
-                .button('option', 'label', "Moving " + uuids.length + " messages to " + destination + "...")
-                .button('disable');
-        });
-
-    });
-
-  </script>
-  % endif
-</%def>
-
 <%def name="context_menu_items()">
   % if request.has_perm('messages.create'):
       <li>${h.link_to("Send a new Message", url('messages.create'))}</li>
@@ -55,37 +9,28 @@
 
 <%def name="grid_tools()">
   % if request.matched_route.name in ('messages.inbox', 'messages.archive'):
-      % if use_buefy:
-          ${h.form(url('messages.move_bulk'), **{'@submit': 'moveMessagesSubmit'})}
-          ${h.csrf_token(request)}
-          ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')}
-          ${h.hidden('uuids', v_model='selected_uuids')}
-          <b-button type="is-primary"
-                    native-type="submit"
-                    :disabled="moveMessagesSubmitting || !checkedRows.length">
-            {{ moveMessagesTextCurrent }}
-          </b-button>
-          ${h.end_form()}
-      % else:
-          ${h.form(url('messages.move_bulk'), name='move-selected')}
-          ${h.csrf_token(request)}
-          ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')}
-          ${h.hidden('uuids')}
-          <button type="submit">Move 0 selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}</button>
-          ${h.end_form()}
-      % endif
+      ${h.form(url('messages.move_bulk'), **{'@submit': 'moveMessagesSubmit'})}
+      ${h.csrf_token(request)}
+      ${h.hidden('destination', value='archive' if request.matched_route.name == 'messages.inbox' else 'inbox')}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button type="is-primary"
+                native-type="submit"
+                :disabled="moveMessagesSubmitting || !checkedRows.length">
+        {{ moveMessagesTextCurrent }}
+      </b-button>
+      ${h.end_form()}
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if request.matched_route.name in ('messages.inbox', 'messages.archive'):
-      <script type="text/javascript">
+      <script>
 
-        TailboneGridData.moveMessagesSubmitting = false
-        TailboneGridData.moveMessagesText = null
+        ${grid.vue_component}Data.moveMessagesSubmitting = false
+        ${grid.vue_component}Data.moveMessagesText = null
 
-        TailboneGrid.computed.moveMessagesTextCurrent = function() {
+        ${grid.vue_component}.computed.moveMessagesTextCurrent = function() {
             if (this.moveMessagesText) {
                 return this.moveMessagesText
             }
@@ -93,7 +38,7 @@
             return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}"
         }
 
-        TailboneGrid.methods.moveMessagesSubmit = function() {
+        ${grid.vue_component}.methods.moveMessagesSubmit = function() {
             this.moveMessagesSubmitting = true
             this.moveMessagesText = "Working, please wait..."
         }
@@ -101,6 +46,3 @@
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako
index 78caab93..36418698 100644
--- a/tailbone/templates/messages/view.mako
+++ b/tailbone/templates/messages/view.mako
@@ -1,66 +1,20 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    $(function() {
-
-        $('.field-wrapper.recipients .more').click(function() {
-            $(this).hide();
-            $(this).siblings('.everyone').css('display', 'inline-block');
-            return false;
-        });
-
-        $('.field-wrapper.recipients .everyone').click(function() {
-            $(this).hide();
-            $(this).siblings('.more').show();
-        });
-
-    });
-
-  </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if use_buefy:
-      <style type="text/css">
-        .everyone {
-            cursor: pointer;
-        }
-        .tailbone-message-body {
-            margin: 1rem auto;
-            min-height: 10rem;
-        }
-        .tailbone-message-body p {
-            margin-bottom: 1rem;
-        }
-      </style>
-  % else:
   <style type="text/css">
-    .recipients .everyone {
+    .everyone {
         cursor: pointer;
-        display: none;
     }
-    .message-tools {
-        margin-bottom: 15px;
+    .tailbone-message-body {
+        margin: 1rem auto;
+        min-height: 10rem;
     }
-    .message-body {
-        border-top: 1px solid black;
-        border-bottom: 1px solid black;
-        margin-bottom: 15px;
-        padding: 0 5em;
-        white-space: pre-line;
-    }
-    .message-body p {
-        margin-bottom: 15px;
+    .tailbone-message-body p {
+        margin-bottom: 1rem;
     }
   </style>
-  % endif
 </%def>
 
 <%def name="context_menu_items()">
@@ -86,43 +40,29 @@
 
 <%def name="message_tools()">
   % if recipient:
-      % if use_buefy:
-          <div class="buttons">
-            % if request.has_perm('messages.create'):
-                <once-button type="is-primary"
-                             tag="a" href="${url('messages.reply', uuid=instance.uuid)}"
-                             text="Reply">
-                </once-button>
-                <once-button type="is-primary"
-                             tag="a" href="${url('messages.reply_all', uuid=instance.uuid)}"
-                             text="Reply to All">
-                </once-button>
-            % endif
-            % if recipient.status == enum.MESSAGE_STATUS_INBOX:
-                <once-button type="is-primary"
-                             tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=archive"
-                             text="Move to Archive">
-                </once-button>
-            % else:
-                <once-button type="is-primary"
-                             tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=inbox"
-                             text="Move to Inbox">
-                </once-button>
-            % endif
-          </div>
-      % else:
-          <div class="message-tools">
-            % if request.has_perm('messages.create'):
-                ${h.link_to("Reply", url('messages.reply', uuid=instance.uuid), class_='button')}
-                ${h.link_to("Reply to All", url('messages.reply_all', uuid=instance.uuid), class_='button')}
-            % endif
-            % if recipient.status == enum.MESSAGE_STATUS_INBOX:
-                ${h.link_to("Move to Archive", url('messages.move', uuid=instance.uuid) + '?dest=archive', class_='button')}
-            % else:
-                ${h.link_to("Move to Inbox", url('messages.move', uuid=instance.uuid) + '?dest=inbox', class_='button')}
-            % endif
-          </div>
-      % endif
+      <div class="buttons">
+        % if request.has_perm('messages.create'):
+            <once-button type="is-primary"
+                         tag="a" href="${url('messages.reply', uuid=instance.uuid)}"
+                         text="Reply">
+            </once-button>
+            <once-button type="is-primary"
+                         tag="a" href="${url('messages.reply_all', uuid=instance.uuid)}"
+                         text="Reply to All">
+            </once-button>
+        % endif
+        % if recipient.status == enum.MESSAGE_STATUS_INBOX:
+            <once-button type="is-primary"
+                         tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=archive"
+                         text="Move to Archive">
+            </once-button>
+        % else:
+            <once-button type="is-primary"
+                         tag="a" href="${url('messages.move', uuid=instance.uuid)}?dest=inbox"
+                         text="Move to Inbox">
+            </once-button>
+        % endif
+      </div>
   % endif
 </%def>
 
@@ -132,40 +72,29 @@
 
 <%def name="page_content()">
   ${parent.page_content()}
-  % if use_buefy:
-      <br />
-      <div style="margin-left: 5rem;">
-        ${self.message_tools()}
-        <div class="tailbone-message-body">
-          ${self.message_body()}
-        </div>
-        ${self.message_tools()}
-      </div>
-  % else:
-      ${self.message_tools()}
-      <div class="message-body">
-        ${self.message_body()}
-      </div>
-      ${self.message_tools()}
-  % endif
+  <br />
+  <div style="margin-left: 5rem;">
+    ${self.message_tools()}
+    <div class="tailbone-message-body">
+      ${self.message_body()}
+    </div>
+    ${self.message_tools()}
+  </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.showingAllRecipients = false
+    ${form.vue_component}Data.showingAllRecipients = false
 
-    TailboneForm.methods.showMoreRecipients = function() {
+    ${form.vue_component}.methods.showMoreRecipients = function() {
         this.showingAllRecipients = true
     }
 
-    TailboneForm.methods.hideMoreRecipients = function() {
+    ${form.vue_component}.methods.hideMoreRecipients = function() {
         this.showingAllRecipients = false
     }
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/mobile/about.mako b/tailbone/templates/mobile/about.mako
deleted file mode 100644
index bfa55379..00000000
--- a/tailbone/templates/mobile/about.mako
+++ /dev/null
@@ -1,13 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/mobile/base.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">About ${base_meta.app_title()}</%def>
-
-<h2>${project_title} ${project_version}</h2>
-
-% for name, version in packages.items():
-    <h3>${name} ${version}</h3>
-% endfor
-
-<p>Please see <a href="https://rattailproject.org/">rattailproject.org</a> for more info.</p>
diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako
deleted file mode 100644
index c05c2100..00000000
--- a/tailbone/templates/mobile/base.mako
+++ /dev/null
@@ -1,208 +0,0 @@
-## -*- coding: utf-8 -*-
-<%namespace name="base_meta" file="/base_meta.mako" />
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
-    <title>${base_meta.global_title()} &raquo; ${self.title()}</title>
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-
-    ${self.jquery()}
-    ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js') + '?ver={}'.format(tailbone.__version__))}
-    ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js') + '?ver={}'.format(tailbone.__version__))}
-    ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.receiving.js') + '?ver={}'.format(tailbone.__version__))}
-    ${self.extra_javascript()}
-
-    ## since jquery mobile will "utterly cache" the first page which is loaded
-    ## by the client, we must make sure that is always the home page.  so if
-    ## user tries to e.g. "refresh" some other page, redirect to home page
-    % if request.matched_route.name != 'mobile.home' and request.rattail_config.getbool('tailbone', 'mobile.force_home', default=True):
-        <script type="text/javascript">
-          location.href = '${request.route_url('mobile.home')}';
-        </script>
-    % endif
-
-    % if request.rattail_config.getbool('tailbone', 'mobile.flash.autodismiss', default=True):
-        <script type="text/javascript">
-          $(document).on('pageshow', function() {
-              ## TODO: seems like this should be better somehow...
-              // remove all flash messages after 2.5 seconds
-              window.setTimeout(function() { $('.flash, .error').remove(); }, 2500);
-          });
-        </script>
-    % endif
-
-    ${self.jquery_theme()}
-    ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css') + '?ver={}'.format(tailbone.__version__))}
-    % if not request.rattail_config.production():
-    <style type="text/css">
-      .ui-page-theme-a { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); }
-    </style>
-    % endif
-    ${self.extra_styles()}
-
-  </head>
-  ${self.mobile_body()}
-</html>
-
-<%def name="mobile_body()">
-  <body>
-
-    ## note that our toolbars are *external* (in jqm-speak) by default
-
-    ${self.mobile_header()}
-
-    <div data-role="page" data-url="${self.page_url()}">
-
-      ${self.mobile_usermenu()}
-
-      ${self.mobile_page_body()}
-
-    </div><!-- page -->
-
-    ${self.mobile_footer()}
-
-  </body>
-</%def>
-
-<%def name="page_url()">${request.current_route_url()}</%def>
-
-<%def name="page_title()">${self.title()}</%def>
-
-<%def name="jquery()">
-  ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')}
-  ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')}
-</%def>
-
-<%def name="extra_javascript()"></%def>
-
-<%def name="jquery_theme()">
-  ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')}
-</%def>
-
-<%def name="extra_styles()"></%def>
-
-<%def name="mobile_header()">
-  <div data-role="header">
-    ${self.mobile_header_link()}
-    <h1>${base_meta.global_title()}</h1>
-    ${self.mobile_header_feedback()}
-  </div>
-</%def>
-
-<%def name="mobile_header_link()">
-  <% classes = 'ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ' %>
-  % if request.user:
-      ${h.link_to(request.user.get_short_name(), '#usermenu', data_role='button', data_icon='user',
-          class_=' root-user' if request.is_root else '')}
-  % elif request.matched_route.name in ('mobile.login', 'mobile.about'):
-      ${h.link_to("Home", url('mobile.home'), data_role='button', data_icon='home')}
-  % else:
-      ${h.link_to("Login", url('mobile.login'), data_role='button', data_icon='user')}
-  % endif
-</%def>
-
-<%def name="mobile_header_feedback()">
-  ${h.link_to("Feedback", '#', id='feedback-button', data_role='button', data_icon='recycle')}
-</%def>
-
-<%def name="mobile_usermenu()">
-  <div id="usermenu" data-role="panel" data-display="overlay">
-    <ul data-role="listview">
-      <li data-icon="home">${h.link_to("Home", url('mobile.home'))}</li>
-      % if request.has_perm('datasync.restart'):
-          <li>${h.link_to("DataSync", url('datasync.mobile'))}</li>
-      % endif
-      % if request.is_root:
-          <li class="root-user" data-icon="forbidden">${h.link_to("Stop being root", url('stop_root'), **{'data-ajax': 'false'})}</li>
-      % elif request.is_admin:
-          <li class="root-user" data-icon="forbidden">${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}</li>
-      % endif
-      <li data-icon="lock">${h.link_to("Logout", url('mobile.logout'), **{'data-ajax': 'false'})}</li>
-      <li data-icon="info">${h.link_to("About {}".format(capture(base_meta.app_title)), url('mobile.about'))}</li>
-    </ul>
-  </div>
-</%def>
-
-<%def name="mobile_page_body()">
-  <div role="main" class="ui-content" data-route="${request.matched_route.name}">
-
-    % if request.session.peek_flash('error'):
-        % for error in request.session.pop_flash('error'):
-            <div class="error">${error}</div>
-        % endfor
-    % endif
-
-    % if request.session.peek_flash():
-        % for msg in request.session.pop_flash():
-            <div class="flash">${msg|n}</div>
-        % endfor
-    % endif
-
-    <h2>${self.page_title()}</h2>
-
-    ${self.body()}
-
-    <div data-role="popup" data-overlay-theme="b" id="feedback-popup" class="ui-content">
-      <a href="#" data-rel="back" data-role="button" data-theme="a" data-icon="delete" data-iconpos="notext" class="ui-btn-right">Close</a>
-      ${self.mobile_feedback_form()}
-    </div>
-
-    <div data-role="popup" data-overlay-theme="b" id="feedback-thanks" class="ui-content">
-      Thank you for your feedback.
-    </div>
-
-    <div class="replacement-header">
-      ${self.mobile_header_link()}
-    </div>
-
-  </div>
-</%def>
-
-<%def name="mobile_footer()">
-  <div data-role="footer">
-    <h4>powered by ${h.link_to("Rattail", url('mobile.about'))}</h4>
-  </div>
-</%def>
-
-<%def name="mobile_feedback_form()">
-  ${h.form(url('mobile.feedback'))}
-  ${h.csrf_token(request)}
-  ${h.hidden('user', value=request.user.uuid if request.user else None)}
-
-  <p>
-    Questions, suggestions, comments, complaints, etc. <span class="red">regarding this website</span>
-    are welcome and may be submitted below.
-  </p>
-
-  <div class="field-wrapper referrer">
-    <label for="referrer">Referring URL</label>
-    <div class="field"></div>
-    ${h.hidden('referrer')}
-  </div>
-
-  % if request.user:
-      ${h.hidden('user_name', value=six.text_type(request.user))}
-  % else:
-      <div class="field-wrapper user_name">
-        <label for="user_name">Your Name</label>
-        <div class="field">
-          ${h.text('user_name')}
-        </div>
-      </div>
-  % endif
-
-  <div class="field-wrapper message">
-    <label for="message">Message</label>
-    <div class="field">
-      ${h.textarea('message', cols=45, rows=15)}
-    </div>
-  </div>
-
-  <div class="buttons" id="feedback-form-buttons">
-    <button type="button" data-inline="true" class="submit" data-theme="b">Send Note</button>
-    <button type="button" data-inline="true" class="cancel">Cancel</button>
-  </div>
-
-  ${h.end_form()}
-</%def>
diff --git a/tailbone/templates/mobile/base_internal_toolbars.mako b/tailbone/templates/mobile/base_internal_toolbars.mako
deleted file mode 100644
index 107ca928..00000000
--- a/tailbone/templates/mobile/base_internal_toolbars.mako
+++ /dev/null
@@ -1,20 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="tailbone:templates/mobile/base.mako" />
-
-<%def name="mobile_body()">
-  <body>
-
-    <div data-role="page" data-url="${self.page_url()}"${' data-rel="dialog"' if dialog else ''|n}>
-
-      ${self.mobile_usermenu()}
-
-      ${self.mobile_header()}
-
-      ${self.mobile_page_body()}
-
-      ${self.mobile_footer()}
-
-    </div><!-- page -->
-
-  </body>
-</%def>
diff --git a/tailbone/templates/mobile/batch/execute.mako b/tailbone/templates/mobile/batch/execute.mako
deleted file mode 100644
index a6c7c6ef..00000000
--- a/tailbone/templates/mobile/batch/execute.mako
+++ /dev/null
@@ -1,10 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">${index_title} &raquo; ${instance_title} &raquo; Execute</%def>
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${h.link_to(instance_title, instance_url)} &raquo; Execute</%def>
-
-<div class="form-wrapper">
-  ${form.render()|n}
-</div><!-- form-wrapper -->
diff --git a/tailbone/templates/mobile/batch/inventory/create.mako b/tailbone/templates/mobile/batch/inventory/create.mako
deleted file mode 100644
index 99c8106d..00000000
--- a/tailbone/templates/mobile/batch/inventory/create.mako
+++ /dev/null
@@ -1,6 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/create.mako" />
-
-<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; New Batch</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/mobile/batch/inventory/index.mako b/tailbone/templates/mobile/batch/inventory/index.mako
deleted file mode 100644
index 29038208..00000000
--- a/tailbone/templates/mobile/batch/inventory/index.mako
+++ /dev/null
@@ -1,6 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/index.mako" />
-
-<%def name="title()">Inventory</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/mobile/batch/inventory/view.mako b/tailbone/templates/mobile/batch/inventory/view.mako
deleted file mode 100644
index 2c8f785c..00000000
--- a/tailbone/templates/mobile/batch/inventory/view.mako
+++ /dev/null
@@ -1,24 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/batch/view.mako" />
-
-<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; ${batch.id_str}</%def>
-
-${form.render()|n}
-
-% if not batch.executed and not batch.complete:
-    <br />
-    ${h.text('upc-search', class_='inventory-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.batch.inventory.row_from_upc', uuid=batch.uuid)})}
-% endif
-
-% if master.has_rows:
-    <br />
-    ${grid.render_complete()|n}
-% endif
-
-% if not batch.executed and not batch.complete:
-    <br />
-    ${h.form(request.route_url('mobile.batch.inventory.mark_complete', uuid=batch.uuid))}
-    ${h.csrf_token(request)}
-    ${h.hidden('mark-complete', value='true')}
-    <button type="submit">Mark Batch as Complete</button>
-% endif
diff --git a/tailbone/templates/mobile/batch/inventory/view_row.mako b/tailbone/templates/mobile/batch/inventory/view_row.mako
deleted file mode 100644
index bfb06dcf..00000000
--- a/tailbone/templates/mobile/batch/inventory/view_row.mako
+++ /dev/null
@@ -1,63 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/batch/view_row.mako" />
-<%namespace file="/mobile/keypad.mako" import="keypad" />
-
-## TODO: this is broken for actual page (header) title
-<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; ${h.link_to(batch.id_str, url('mobile.batch.inventory.view', uuid=batch.uuid))} &raquo; ${row.upc.pretty()}</%def>
-
-<div class="ui-grid-a">
-  <div class="ui-block-a">
-    % if instance.product:
-        <h3>${row.brand_name or ""}</h3>
-        <h3>${row.description} ${row.size}</h3>
-        <h3>${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS</h3>
-    % else:
-        <h3>${row.description}</h3>
-    % endif
-  </div>
-  <div class="ui-block-b">
-    ${h.image(product_image_url, "product image")}
-  </div>
-</div>
-
-<p>
-  currently:&nbsp; 
-  % if uom == 'CS':
-      ${h.pretty_quantity(row.cases or 0)}
-  % else:
-      ${h.pretty_quantity(row.units or 0)}
-  % endif
-  ${uom}
-</p>
-
-% if not batch.executed and not batch.complete:
-
-    ${h.form(request.current_route_url())}
-    ${h.csrf_token(request)}
-    ${h.hidden('row', value=row.uuid)}
-    % if allow_cases:
-    ${h.hidden('cases')}
-    % endif
-    ${h.hidden('units')}
-
-    <%
-       quantity = 1
-       if allow_cases:
-           if row.cases is not None:
-               quantity = row.cases
-           elif row.units is not None:
-               quantity = row.units
-       elif row.units is not None:
-           quantity = row.units
-    %>
-    ${keypad(unit_uom, uom, quantity=quantity, allow_cases=allow_cases)}
-
-    <fieldset data-role="controlgroup" data-type="horizontal" class="inventory-actions">
-      <button type="button" class="ui-btn-inline ui-corner-all save">Save</button>
-      <button type="button" class="ui-btn-inline ui-corner-all delete" disabled="disabled">Delete</button>
-      ${h.link_to("Cancel", url('mobile.batch.inventory.view', uuid=batch.uuid), class_='ui-btn ui-btn-inline ui-corner-all')}
-    </fieldset>
-
-    ${h.end_form()}
-
-% endif
diff --git a/tailbone/templates/mobile/batch/view.mako b/tailbone/templates/mobile/batch/view.mako
deleted file mode 100644
index ff0bcc38..00000000
--- a/tailbone/templates/mobile/batch/view.mako
+++ /dev/null
@@ -1,32 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/view.mako" />
-
-${parent.body()}
-
-% if not batch.executed:
-    % if request.has_perm('{}.edit'.format(permission_prefix)):
-        % if batch.complete:
-            ${h.form(url('mobile.{}.mark_pending'.format(route_prefix), uuid=batch.uuid))}
-            ${h.csrf_token(request)}
-            ${h.hidden('mark-pending', value='true')}
-            ${h.submit('submit', "Mark Batch as Pending")}
-            ${h.end_form()}
-        % else:
-            ${h.form(url('mobile.{}.mark_complete'.format(route_prefix), uuid=batch.uuid))}
-            ${h.csrf_token(request)}
-            ${h.hidden('mark-complete', value='true')}
-            ${h.submit('submit', "Mark Batch as Complete")}
-            ${h.end_form()}
-        % endif
-    % endif
-    % if batch.complete and master.mobile_executable and request.has_perm('{}.execute'.format(permission_prefix)):
-        % if master.has_execution_options(batch):
-            ${h.link_to("Execute Batch", url('mobile.{}.execute'.format(route_prefix), uuid=batch.uuid), class_='ui-btn ui-corner-all')}
-        % else:
-            ${h.form(url('mobile.{}.execute'.format(route_prefix), uuid=batch.uuid))}
-            ${h.csrf_token(request)}
-            ${h.submit('submit', "Execute Batch")}
-            ${h.end_form()}
-        % endif
-    % endif
-% endif
diff --git a/tailbone/templates/mobile/batch/view_row.mako b/tailbone/templates/mobile/batch/view_row.mako
deleted file mode 100644
index ad729169..00000000
--- a/tailbone/templates/mobile/batch/view_row.mako
+++ /dev/null
@@ -1,4 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/view_row.mako" />
-
-${parent.body()}
diff --git a/tailbone/templates/mobile/datasync.mako b/tailbone/templates/mobile/datasync.mako
deleted file mode 100644
index 2f21a2a2..00000000
--- a/tailbone/templates/mobile/datasync.mako
+++ /dev/null
@@ -1,9 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">DataSync</%def>
-
-${h.form(url('datasync.restart'))}
-${h.csrf_token(request)}
-${h.submit('restart', "Restart DataSync Daemon", id='datasync-restart')}
-${h.end_form()}
diff --git a/tailbone/templates/mobile/grids/complete.mako b/tailbone/templates/mobile/grids/complete.mako
deleted file mode 100644
index ebb58334..00000000
--- a/tailbone/templates/mobile/grids/complete.mako
+++ /dev/null
@@ -1,7 +0,0 @@
-## -*- coding: utf-8; -*-
-
-% if grid.filterable:
-    ${grid.render_filters()|n}
-% endif
-
-${grid.render_grid()|n}
diff --git a/tailbone/templates/mobile/grids/filters_simple.mako b/tailbone/templates/mobile/grids/filters_simple.mako
deleted file mode 100644
index 1286d99a..00000000
--- a/tailbone/templates/mobile/grids/filters_simple.mako
+++ /dev/null
@@ -1,15 +0,0 @@
-## -*- coding: utf-8; -*-
-<div class="simple-filter">
-  ${h.form(request.current_route_url(_query=None), method='get')}
-
-  % for filtr in grid.iter_filters():
-      ${h.hidden('{}.verb'.format(filtr.key), value=filtr.verb)}
-      <fieldset data-role="controlgroup" data-type="horizontal">
-        % for value, label in filtr.iter_choices():
-            ${h.radio(filtr.key, value=value, label=label, checked=value == filtr.value)}
-        % endfor
-      </fieldset>
-  % endfor
-
-  ${h.end_form()}
-</div><!-- simple-filter -->
diff --git a/tailbone/templates/mobile/grids/grid.mako b/tailbone/templates/mobile/grids/grid.mako
deleted file mode 100644
index b7b029b5..00000000
--- a/tailbone/templates/mobile/grids/grid.mako
+++ /dev/null
@@ -1,36 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<ul data-role="listview">
-  ${grid.make_webhelpers_grid()}
-</ul>
-
-##   <table data-role="table" class="ui-responsive table-stroke">
-##     <thead>
-##       <tr>
-##         % for column in grid.iter_visible_columns():
-##             ${grid.column_header(column)}
-##         % endfor
-##       </tr>
-##     </thead>
-##     <tbody>
-##       % for i, row in enumerate(grid.iter_rows(), 1):
-##           <tr>
-##             % for column in grid.iter_visible_columns():
-##                 <td>${grid.render_cell(row, column)}</td>
-##             % endfor
-##           </tr>
-##       % endfor
-##     </tbody>
-##   </table>
-
-% if grid.pageable and grid.pager:
-    <br />
-    <div data-role="controlgroup" data-type="horizontal">
-      ${grid.pager.pager('$link_first $link_previous $link_next $link_last',
-          symbol_first='<< first', symbol_last='last >>',
-          symbol_previous='< prev', symbol_next='next >',
-          link_attr={'class': 'ui-btn ui-corner-all'},
-          curpage_attr={'class': 'ui-btn ui-corner-all'},
-          dotdot_attr={'class': 'ui-btn ui-corner-all'})|n}
-    </div>
-% endif
diff --git a/tailbone/templates/mobile/home.mako b/tailbone/templates/mobile/home.mako
deleted file mode 100644
index 1daafd86..00000000
--- a/tailbone/templates/mobile/home.mako
+++ /dev/null
@@ -1,12 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/mobile/base.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">Home</%def>
-
-<%def name="page_title()"></%def>
-
-<div style="text-align: center;">
-  ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)), id='logo', width=300)}
-  <h3>Welcome to ${base_meta.app_title()}</h3>
-</div>
diff --git a/tailbone/templates/mobile/keypad.mako b/tailbone/templates/mobile/keypad.mako
deleted file mode 100644
index 38cb03da..00000000
--- a/tailbone/templates/mobile/keypad.mako
+++ /dev/null
@@ -1,41 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<%def name="keypad(unit_uom, selected_uom, quantity=1, allow_cases=True)">
-  <div class="quantity-keypad-thingy" data-changed="false">
-
-    <table>
-      <tbody>
-        <tr>
-          <td>${h.link_to("7", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to("8", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to("9", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-        </tr>
-        <tr>
-          <td>${h.link_to("4", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to("5", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to("6", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-        </tr>
-        <tr>
-          <td>${h.link_to("1", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to("2", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to("3", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-        </tr>
-        <tr>
-          <td>${h.link_to("0", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to(".", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-          <td>${h.link_to("Del", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
-        </tr>
-      </tbody>
-    </table>
-
-    <fieldset data-role="controlgroup" data-type="horizontal">
-      <button type="button" class="ui-btn-active keypad-quantity">${h.pretty_quantity(1 if quantity is None else quantity)}</button>
-      <button type="button" disabled="disabled">&nbsp;</button>
-      % if allow_cases:
-      ${h.radio('keypad-uom', value='CS', checked=selected_uom == 'CS', label="CS")}
-      % endif
-      ${h.radio('keypad-uom', value=unit_uom, checked=selected_uom == unit_uom, label=unit_uom)}
-    </fieldset>
-
-  </div>
-</%def>
diff --git a/tailbone/templates/mobile/login.mako b/tailbone/templates/mobile/login.mako
deleted file mode 100644
index 5a5efb9f..00000000
--- a/tailbone/templates/mobile/login.mako
+++ /dev/null
@@ -1,7 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/mobile/base.mako" />
-<%namespace file="/login.mako" import="login_form" />
-
-<%def name="title()">Login</%def>
-
-${login_form()}
diff --git a/tailbone/templates/mobile/master/create.mako b/tailbone/templates/mobile/master/create.mako
deleted file mode 100644
index 9bcca732..00000000
--- a/tailbone/templates/mobile/master/create.mako
+++ /dev/null
@@ -1,8 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">New ${model_title}</%def>
-
-<div class="form-wrapper">
-  ${form.render()|n}
-</div><!-- form-wrapper -->
diff --git a/tailbone/templates/mobile/master/create_row.mako b/tailbone/templates/mobile/master/create_row.mako
deleted file mode 100644
index 7b5dae0c..00000000
--- a/tailbone/templates/mobile/master/create_row.mako
+++ /dev/null
@@ -1,6 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/create.mako" />
-
-<%def name="title()">New ${model_title} Row</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/mobile/master/edit.mako b/tailbone/templates/mobile/master/edit.mako
deleted file mode 100644
index 3c13a8e4..00000000
--- a/tailbone/templates/mobile/master/edit.mako
+++ /dev/null
@@ -1,10 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">${index_title} &raquo; ${instance_title} &raquo; Edit</%def>
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${h.link_to(instance_title, instance_url)} &raquo; Edit</%def>
-
-<div class="form-wrapper">
-  ${form.render()|n}
-</div><!-- form-wrapper -->
diff --git a/tailbone/templates/mobile/master/edit_row.mako b/tailbone/templates/mobile/master/edit_row.mako
deleted file mode 100644
index 93eb12e3..00000000
--- a/tailbone/templates/mobile/master/edit_row.mako
+++ /dev/null
@@ -1,17 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/edit.mako" />
-
-<%def name="title()">${index_title} &raquo; ${parent_title} &raquo; ${instance_title} &raquo; Edit</%def>
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${h.link_to(parent_title, parent_url)} &raquo; ${h.link_to(instance_title, instance_url)} &raquo; Edit</%def>
-
-<div class="form-wrapper">
-  ${form.render()|n}
-</div><!-- form-wrapper -->
-
-% if master.mobile_rows_deletable and request.has_perm('{}.delete_row'.format(permission_prefix)):
-    ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=parent_instance.uuid, row_uuid=row.uuid))}
-    ${h.csrf_token(request)}
-    ${h.submit('submit', "Delete this Row")}
-    ${h.end_form()}
-% endif
diff --git a/tailbone/templates/mobile/master/index.mako b/tailbone/templates/mobile/master/index.mako
deleted file mode 100644
index f54ac2ae..00000000
--- a/tailbone/templates/mobile/master/index.mako
+++ /dev/null
@@ -1,17 +0,0 @@
-## -*- coding: utf-8; -*-
-## ##############################################################################
-## 
-## Default master 'index' template for mobile.  Features a somewhat abbreviated
-## data table and (hopefully) exposes a way to filter and sort the data, etc.
-## 
-## ##############################################################################
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">${index_title}</%def>
-
-% if master.mobile_creatable and request.has_perm('{}.create'.format(permission_prefix)):
-    ${h.link_to("New {}".format(model_title), url('mobile.{}.create'.format(route_prefix)), class_='ui-btn ui-corner-all')}
-    <br />
-% endif
-
-${grid.render_complete()|n}
diff --git a/tailbone/templates/mobile/master/view.mako b/tailbone/templates/mobile/master/view.mako
deleted file mode 100644
index 9f00d8af..00000000
--- a/tailbone/templates/mobile/master/view.mako
+++ /dev/null
@@ -1,48 +0,0 @@
-## -*- coding: utf-8; -*-
-## ##############################################################################
-## 
-## Default master 'view' template for mobile.  Features a basic field list, and
-## links to edit/delete the object when appropriate.
-## 
-## ##############################################################################
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">${index_title} &raquo; ${instance_title}</%def>
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${instance_title}</%def>
-
-${form.render()|n}
-
-% if master.has_rows:
-
-    % if master.mobile_rows_creatable and master.rows_creatable_for(instance):
-        ## TODO: this seems like a poor choice of names? what are we really testing for here?
-        % if master.mobile_rows_creatable_via_browse:
-            <% add_title = "Add Record" if add_item_title is Undefined else add_item_title %>
-            ${h.link_to(add_title, url('mobile.{}.create_row'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')}
-        % endif
-    % endif
-    % if master.mobile_rows_quickable and master.rows_quickable_for(instance):
-        <% placeholder = '' if quick_entry_placeholder is Undefined else quick_entry_placeholder %>
-        ${h.form(url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid))}
-        ${h.csrf_token(request)}
-        % if quick_row_autocomplete:
-            <div class="field autocomplete quick-row" data-url="${quick_row_autocomplete_url}">
-              ${h.hidden('quick_entry')}
-              ${h.text('quick_row_autocomplete_text', placeholder=placeholder, autocomplete='off', data_type='search')}
-              <ul data-role="listview" data-inset="true" data-filter="true" data-input="#quick_row_autocomplete_text"></ul>
-              <button type="button" style="display: none;">Change</button>
-            </div>
-        % else:
-            ${h.text('quick_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid), 'data-wedge': 'true' if quick_row_keyboard_wedge else 'false'})}
-        % endif
-        ${h.end_form()}
-    % endif
-
-    <br />
-    ${grid.render_complete()|n}
-% endif
-
-% if master.mobile_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
-    ${h.link_to("Edit This", url('mobile.{}.edit'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')}
-% endif
diff --git a/tailbone/templates/mobile/master/view_row.mako b/tailbone/templates/mobile/master/view_row.mako
deleted file mode 100644
index 29a014e8..00000000
--- a/tailbone/templates/mobile/master/view_row.mako
+++ /dev/null
@@ -1,19 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/view.mako" />
-
-<%def name="title()">${index_title} &raquo; ${parent_title} &raquo; ${instance_title}</%def>
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${h.link_to(parent_title, parent_url)} &raquo; ${instance_title}</%def>
-
-${form.render()|n}
-
-% if master.mobile_rows_editable and instance_editable and request.has_perm('{}.edit_row'.format(permission_prefix)):
-  ${h.link_to("Edit", url('mobile.{}.edit_row'.format(route_prefix), uuid=instance.batch_uuid, row_uuid=instance.uuid), class_='ui-btn')}
-% endif
-
-% if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)):
-    ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=parent_instance.uuid, row_uuid=row.uuid))}
-    ${h.csrf_token(request)}
-    ${h.submit('submit', "Delete this Row")}
-    ${h.end_form()}
-% endif
diff --git a/tailbone/templates/mobile/ordering/create.mako b/tailbone/templates/mobile/ordering/create.mako
deleted file mode 100644
index ae292269..00000000
--- a/tailbone/templates/mobile/ordering/create.mako
+++ /dev/null
@@ -1,31 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">${index_title} &raquo; New Batch</%def>
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; New Batch</%def>
-
-${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')}
-${h.csrf_token(request)}
-
-<div class="field-wrapper vendor">
-  % if vendor_use_autocomplete:
-      <div class="field autocomplete" data-url="${url('vendors.autocomplete')}">
-        ${h.hidden('vendor')}
-        ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', data_type='search')}
-        <ul data-role="listview" data-inset="true" data-filter="true" data-input="#new-purchasing-batch-vendor-text"></ul>
-        <button type="button" style="display: none;">Change Vendor</button>
-      </div>
-  % else:
-      <div class="field-row">
-        <label for="vendor">Vendor</label>
-        <div class="field">
-          ${h.select('vendor', None, vendor_options)}
-        </div>
-      </div>
-  % endif
-</div>
-
-<br />
-${h.submit('submit', "Make Batch")}
-${h.end_form()}
diff --git a/tailbone/templates/mobile/ordering/create_row.mako b/tailbone/templates/mobile/ordering/create_row.mako
deleted file mode 100644
index 79d83630..00000000
--- a/tailbone/templates/mobile/ordering/create_row.mako
+++ /dev/null
@@ -1,6 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/create_row.mako" />
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${h.link_to(instance_title, instance_url)} &raquo; Add Item</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/mobile/ordering/new_product.mako b/tailbone/templates/mobile/ordering/new_product.mako
deleted file mode 100644
index 79d83630..00000000
--- a/tailbone/templates/mobile/ordering/new_product.mako
+++ /dev/null
@@ -1,6 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/create_row.mako" />
-
-<%def name="page_title()">${h.link_to(index_title, index_url)} &raquo; ${h.link_to(instance_title, instance_url)} &raquo; Add Item</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/mobile/products/index.mako b/tailbone/templates/mobile/products/index.mako
deleted file mode 100644
index 01cb8320..00000000
--- a/tailbone/templates/mobile/products/index.mako
+++ /dev/null
@@ -1,17 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/index.mako" />
-
-% if master.mobile_creatable and request.has_perm('{}.create'.format(permission_prefix)):
-    ${h.link_to("New {}".format(model_title), url('mobile.{}.create'.format(route_prefix)), class_='ui-btn ui-corner-all')}
-% endif
-
-% if quick_lookup:
-
-    ${h.form(url('mobile.{}.quick_lookup'.format(route_prefix)))}
-    ${h.csrf_token(request)}
-    ${h.text('quick_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_lookup'.format(route_prefix)), 'data-wedge': 'true' if quick_lookup_keyboard_wedge else 'false'})}
-    ${h.end_form()}
-
-% else: ## not quick_only
-    ${grid.render_complete()|n}
-% endif
diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako
deleted file mode 100644
index 97cb132d..00000000
--- a/tailbone/templates/mobile/receiving/create.mako
+++ /dev/null
@@ -1,85 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/base.mako" />
-
-<%def name="title()">Receiving &raquo; New Batch</%def>
-
-<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; New Batch</%def>
-
-${h.form(form.action_url, class_='ui-filterable', name='new-receiving-batch')}
-${h.csrf_token(request)}
-
-% if phase == 1:
-
-    % if vendor_use_autocomplete:
-        <div class="field-wrapper vendor">
-          <div class="field autocomplete" data-url="${url('vendors.autocomplete')}">
-            ${h.hidden('vendor')}
-            ${h.text('new-receiving-batch-vendor-text', placeholder="Vendor name", autocomplete='off', **{'data-type': 'search'})}
-            <ul data-role="listview" data-inset="true" data-filter="true" data-input="#new-receiving-batch-vendor-text"></ul>
-            <button type="button" style="display: none;">Change Vendor</button>
-          </div>
-        </div>
-    % else:
-        <div class="field-row">
-          <label for="vendor">Vendor</label>
-          <div class="field">
-            ${h.select('vendor', None, vendor_options)}
-          </div>
-        </div>
-    % endif
-
-    <br />
-
-    <div id="new-receiving-types" style="display: none;">
-
-      ${h.hidden('workflow')}
-      ${h.hidden('phase', value='1')}
-
-      % if master.allow_from_po:
-          <button type="button" class="start-receiving" data-workflow="from_po">Receive from PO</button>
-      % endif
-
-      % if master.allow_from_scratch:
-          <button type="button" class="start-receiving" data-workflow="from_scratch">Receive from Scratch</button>
-      % endif
-
-      % if master.allow_truck_dump:
-          <button type="button" class="start-receiving" data-workflow="truck_dump">Receive Truck Dump</button>
-      % endif
-
-    </div>
-
-% else: ## phase 2
-
-    ${h.hidden('workflow')}
-    ${h.hidden('phase', value='2')}
-
-    <div class="field-wrapper vendor">
-      <label>Vendor</label>
-      <div class="field">
-        ${h.hidden('vendor', value=vendor.uuid)}
-        ${vendor}
-      </div>
-    </div>
-
-    % if purchases:
-        ${h.hidden(purchase_order_fieldname, class_='purchase-order-field')}
-        <p>Please choose a Purchase Order to receive:</p>
-        <ul data-role="listview" data-inset="true">
-          % for key, purchase in purchases:
-              <li data-key="${key}">${h.link_to(purchase, '#')}</li>
-          % endfor
-        </ul>
-    % else:
-        <p>(no eligible purchases found)</p>
-    % endif
-
-    % if master.allow_from_scratch:
-        <button type="button" class="start-receiving" data-workflow="from_scratch">Receive from Scratch</button>
-    % endif
-
-    ${h.link_to("Cancel", url('mobile.{}'.format(route_prefix)), class_='ui-btn ui-corner-all')}
-
-% endif
-
-${h.end_form()}
diff --git a/tailbone/templates/mobile/receiving/receive_row.mako b/tailbone/templates/mobile/receiving/receive_row.mako
deleted file mode 100644
index 7987e9de..00000000
--- a/tailbone/templates/mobile/receiving/receive_row.mako
+++ /dev/null
@@ -1,151 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/view_row.mako" />
-<%namespace file="/mobile/keypad.mako" import="keypad" />
-
-<%def name="title()">Receiving &raquo; ${batch.id_str} &raquo; ${master.render_product_key_value(row)}</%def>
-
-<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} &raquo; ${master.render_product_key_value(row)}</%def>
-
-
-<div${' class="ui-grid-a"' if product_image_url else ''|n}>
-  <div class="ui-block-a"${'' if instance.product else ' style="background-color: red;"'|n}>
-    % if instance.product:
-        <h3>${instance.brand_name or ""}</h3>
-        <h3>${instance.description} ${instance.size or ''}</h3>
-        % if allow_cases:
-            <h3>1 CS = ${h.pretty_quantity(row.case_quantity or 1)} ${unit_uom}</h3>
-        % endif
-    % else:
-        <h3>${instance.description}</h3>
-    % endif
-  </div>
-  % if product_image_url:
-    <div class="ui-block-b">
-      ${h.image(product_image_url, "product image")}
-    </div>
-  % endif
-</div>
-
-<table${'' if instance.product else ' style="background-color: red;"'|n}>
-  <tbody>
-    % if batch.order_quantities_known:
-        <tr>
-          <td>shipped</td>
-          <td>
-            % if allow_cases:
-                ${h.pretty_quantity(row.cases_shipped or 0)} /
-            % endif
-            ${h.pretty_quantity(row.units_shipped or 0)}
-          </td>
-        </tr>
-    % endif
-    <tr>
-      <td>received</td>
-      <td>
-        % if allow_cases:
-            ${h.pretty_quantity(row.cases_received or 0)} /
-        % endif
-        ${h.pretty_quantity(row.units_received or 0)}
-      </td>
-    </tr>
-    <tr>
-      <td>damaged</td>
-      <td>
-        % if allow_cases:
-            ${h.pretty_quantity(row.cases_damaged or 0)} /
-        % endif
-        ${h.pretty_quantity(row.units_damaged or 0)}
-      </td>
-    </tr>
-    % if allow_expired:
-        <tr>
-          <td>expired</td>
-          <td>
-            % if allow_cases:
-                ${h.pretty_quantity(row.cases_expired or 0)} /
-            % endif
-            ${h.pretty_quantity(row.units_expired or 0)}
-          </td>
-        </tr>
-    % endif
-  </tbody>
-</table>
-
-% if request.session.peek_flash('receiving-warning'):
-    % for error in request.session.pop_flash('receiving-warning'):
-        <div class="receiving-warning">${error}</div>
-    % endfor
-% endif
-
-% if not batch.executed and not batch.complete:
-
-    ${h.form(request.current_route_url(), class_='receiving-update')}
-    ${h.csrf_token(request)}
-    ${h.hidden('row', value=row.uuid)}
-    ${h.hidden('cases')}
-    ${h.hidden('units')}
-
-    ## only show quick-receive if we have an identifiable product
-    % if quick_receive and instance.product:
-        % if quick_receive_all:
-            <button type="button" class="quick-receive" data-quantity="${quick_receive_quantity}" data-uom="${quick_receive_uom}">${quick_receive_text}</button>
-        % elif allow_cases:
-            <button type="button" class="quick-receive" data-quantity="1" data-uom="CS">Receive 1 CS</button>
-            <div>
-              ## TODO: probably should make these optional / configurable
-              <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="1" data-uom="EA">1 EA</button>
-              <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="3" data-uom="EA">3 EA</button>
-              <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="6" data-uom="EA">6 EA</button>
-            </div>
-            <br />
-        % else:
-            <button type="button" class="quick-receive" data-quantity="1" data-uom="${unit_uom}">Receive 1 ${unit_uom}</button>
-        % endif
-    % endif
-
-    ${keypad(unit_uom, uom, allow_cases=allow_cases)}
-
-    <table>
-      <tbody>
-        <tr>
-          <td>
-            <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-mode">
-              ${h.radio('mode', value='received', label="received", checked=True)}
-              ${h.radio('mode', value='damaged', label="damaged")}
-              % if allow_expired:
-                  ${h.radio('mode', value='expired', label="expired")}
-              % endif
-            </fieldset>
-          </td>
-        </tr>
-        <tr id="expiration-row" style="display: none;">
-          <td>
-            <div style="padding:10px 20px;">
-              <label for="expiration_date">Expiration Date</label>
-              <input name="expiration_date" type="date" value="" placeholder="YYYY-MM-DD" />
-            </div>
-          </td>
-        </tr>
-        <tr>
-          <td>
-            <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-actions">
-              <button type="button" data-action="add" class="ui-btn-inline ui-corner-all">Add</button>
-              <button type="button" data-action="subtract" class="ui-btn-inline ui-corner-all">Subtract</button>
-              ## <button type="button" data-action="clear" class="ui-btn-inline ui-corner-all ui-state-disabled">Clear</button>
-            </fieldset>
-          </td>
-        </tr>
-      </tbody>
-    </table>
-
-    ${h.hidden('quick_receive', value='false')}
-    ${h.end_form()}
-
-    % if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)):
-        ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='receiving-update')}
-        ${h.csrf_token(request)}
-        ${h.submit('submit', "Delete this Row")}
-        ${h.end_form()}
-    % endif
-
-% endif
diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako
deleted file mode 100644
index 53d8820f..00000000
--- a/tailbone/templates/mobile/receiving/view_row.mako
+++ /dev/null
@@ -1,151 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/mobile/master/view_row.mako" />
-<%namespace file="/mobile/keypad.mako" import="keypad" />
-
-<%def name="title()">Receiving &raquo; ${batch.id_str} &raquo; ${master.render_product_key_value(row)}</%def>
-
-<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} &raquo; ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} &raquo; ${master.render_product_key_value(row)}</%def>
-
-
-<div${' class="ui-grid-a"' if product_image_url else ''|n}>
-  <div class="ui-block-a"${'' if instance.product else ' style="background-color: red;"'|n}>
-    % if instance.product:
-        <h3>${instance.brand_name or ""}</h3>
-        <h3>${instance.description} ${instance.size or ''}</h3>
-        % if allow_cases:
-            <h3>1 CS = ${h.pretty_quantity(row.case_quantity or 1)} ${unit_uom}</h3>
-        % endif
-    % else:
-        <h3>${instance.description}</h3>
-    % endif
-  </div>
-  % if product_image_url:
-    <div class="ui-block-b">
-      ${h.image(product_image_url, "product image")}
-    </div>
-  % endif
-</div>
-
-<table${'' if instance.product else ' style="background-color: red;"'|n}>
-  <tbody>
-    % if batch.order_quantities_known:
-        <tr>
-          <td>ordered</td>
-          <td>
-            % if allow_cases:
-                ${h.pretty_quantity(row.cases_ordered or 0)} /
-            % endif
-            ${h.pretty_quantity(row.units_ordered or 0)}
-          </td>
-        </tr>
-    % endif
-    <tr>
-      <td>received</td>
-      <td>
-        % if allow_cases:
-            ${h.pretty_quantity(row.cases_received or 0)} /
-        % endif
-        ${h.pretty_quantity(row.units_received or 0)}
-      </td>
-    </tr>
-    <tr>
-      <td>damaged</td>
-      <td>
-        % if allow_cases:
-            ${h.pretty_quantity(row.cases_damaged or 0)} /
-        % endif
-        ${h.pretty_quantity(row.units_damaged or 0)}
-      </td>
-    </tr>
-    % if allow_expired:
-        <tr>
-          <td>expired</td>
-          <td>
-            % if allow_cases:
-                ${h.pretty_quantity(row.cases_expired or 0)} /
-            % endif
-            ${h.pretty_quantity(row.units_expired or 0)}
-          </td>
-        </tr>
-    % endif
-  </tbody>
-</table>
-
-% if request.session.peek_flash('receiving-warning'):
-    % for error in request.session.pop_flash('receiving-warning'):
-        <div class="receiving-warning">${error}</div>
-    % endfor
-% endif
-
-% if not batch.executed and not batch.complete:
-
-    ${h.form(request.current_route_url(), class_='receiving-update')}
-    ${h.csrf_token(request)}
-    ${h.hidden('row', value=row.uuid)}
-    ${h.hidden('cases')}
-    ${h.hidden('units')}
-
-    ## only show quick-receive if we have an identifiable product
-    % if quick_receive and instance.product:
-        % if quick_receive_all:
-            <button type="button" class="quick-receive" data-quantity="${quick_receive_quantity}" data-uom="${quick_receive_uom}">${quick_receive_text}</button>
-        % elif allow_cases:
-            <button type="button" class="quick-receive" data-quantity="1" data-uom="CS">Receive 1 CS</button>
-            <div>
-              ## TODO: probably should make these optional / configurable
-              <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="1" data-uom="EA">1 EA</button>
-              <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="3" data-uom="EA">3 EA</button>
-              <button type="button" class="quick-receive ui-btn ui-btn-inline ui-corner-all" data-quantity="6" data-uom="EA">6 EA</button>
-            </div>
-            <br />
-        % else:
-            <button type="button" class="quick-receive" data-quantity="1" data-uom="${unit_uom}">Receive 1 ${unit_uom}</button>
-        % endif
-    % endif
-
-    ${keypad(unit_uom, uom, allow_cases=allow_cases)}
-
-    <table>
-      <tbody>
-        <tr>
-          <td>
-            <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-mode">
-              ${h.radio('mode', value='received', label="received", checked=True)}
-              ${h.radio('mode', value='damaged', label="damaged")}
-              % if allow_expired:
-                  ${h.radio('mode', value='expired', label="expired")}
-              % endif
-            </fieldset>
-          </td>
-        </tr>
-        <tr id="expiration-row" style="display: none;">
-          <td>
-            <div style="padding:10px 20px;">
-              <label for="expiration_date">Expiration Date</label>
-              <input name="expiration_date" type="date" value="" placeholder="YYYY-MM-DD" />
-            </div>
-          </td>
-        </tr>
-        <tr>
-          <td>
-            <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-actions">
-              <button type="button" data-action="add" class="ui-btn-inline ui-corner-all">Add</button>
-              <button type="button" data-action="subtract" class="ui-btn-inline ui-corner-all">Subtract</button>
-              ## <button type="button" data-action="clear" class="ui-btn-inline ui-corner-all ui-state-disabled">Clear</button>
-            </fieldset>
-          </td>
-        </tr>
-      </tbody>
-    </table>
-
-    ${h.hidden('quick_receive', value='false')}
-    ${h.end_form()}
-
-    % if master.mobile_rows_deletable and master.row_deletable(row) and request.has_perm('{}.delete_row'.format(permission_prefix)):
-        ${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='receiving-update')}
-        ${h.csrf_token(request)}
-        ${h.submit('submit', "Delete this Row")}
-        ${h.end_form()}
-    % endif
-
-% endif
diff --git a/tailbone/templates/multi_file_upload.mako b/tailbone/templates/multi_file_upload.mako
new file mode 100644
index 00000000..e78de194
--- /dev/null
+++ b/tailbone/templates/multi_file_upload.mako
@@ -0,0 +1,60 @@
+## -*- coding: utf-8; -*-
+
+<%def name="render_template()">
+  <script type="text/x-template" id="multi-file-upload-template">
+    <section>
+      <b-field class="file">
+        <b-upload name="upload" multiple drag-drop expanded
+                  v-model="files">
+          <section class="section">
+            <div class="content has-text-centered">
+              <p>
+                <b-icon pack="fas" icon="upload" size="is-large"></b-icon>
+              </p>
+              <p>Drop your files here or click to upload</p>
+            </div>
+          </section>
+        </b-upload>
+      </b-field>
+
+      <div class="tags" style="max-width: 40rem;">
+        <span v-for="(file, index) in files" :key="index" class="tag is-primary">
+          {{file.name}}
+          <button class="delete is-small" type="button"
+                  @click="deleteFile(index)">
+          </button>
+        </span>
+      </div>
+    </section>
+  </script>
+</%def>
+
+<%def name="declare_vars()">
+  <script type="text/javascript">
+
+    let MultiFileUpload = {
+        template: '#multi-file-upload-template',
+        methods: {
+
+            deleteFile(index) {
+                this.files.splice(index, 1);
+            },
+        },
+    }
+
+    let MultiFileUploadData = {
+        files: [],
+    }
+
+  </script>
+</%def>
+
+<%def name="make_component()">
+  <script type="text/javascript">
+
+    MultiFileUpload.data = function() { return MultiFileUploadData }
+
+    Vue.component('multi-file-upload', MultiFileUpload)
+
+  </script>
+</%def>
diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako
new file mode 100644
index 00000000..dc505c42
--- /dev/null
+++ b/tailbone/templates/ordering/configure.mako
@@ -0,0 +1,74 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Workflows</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Users can only choose from the workflows enabled below.
+    </p>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Scratch
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Order File
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Vendors</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow ordering for <span class="has-text-weight-bold">any</span> vendor
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Order Parsers</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Only the selected file parsers will be exposed to users.
+    </p>
+
+    % for Parser in order_parsers:
+        <b-field message="${Parser.key}">
+          <b-checkbox name="order_parser_${Parser.key}"
+                      v-model="orderParsers['${Parser.key}']"
+                      native-value="true"
+                      @input="settingsNeedSaved = true">
+            ${Parser.title}
+          </b-checkbox>
+        </b-field>
+    % endfor
+
+  </div>
+
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/ordering/create.mako b/tailbone/templates/ordering/create.mako
index aeedf523..8f2d5e27 100644
--- a/tailbone/templates/ordering/create.mako
+++ b/tailbone/templates/ordering/create.mako
@@ -1,80 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  ${self.func_show_mode()}
-  <script type="text/javascript">
-
-    var purchases_field = '${purchases_field}';
-    var purchases = null; // TODO: where is this used?
-
-    function vendor_selected(uuid, name) {
-##         var mode = $('.mode select').val();
-##         if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING} || mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) {
-##             var purchases = $('.purchase_uuid select');
-##             purchases.empty();
-## 
-##             var data = {'vendor_uuid': uuid, 'mode': mode};
-##             $.get('${url('purchases.batch.eligible_purchases')}', data, function(data) {
-##                 if (data.error) {
-##                     alert(data.error);
-##                 } else {
-##                     $.each(data.purchases, function(i, purchase) {
-##                         purchases.append($('<option value="' + purchase.key + '">' + purchase.display + '</option>'));
-##                     });
-##                 }
-##             });
-## 
-##             // TODO: apparently refresh doesn't work right?
-##             // http://stackoverflow.com/a/10280078
-##             // purchases.selectmenu('refresh');
-##             purchases.selectmenu('destroy').selectmenu();
-##         }
-    }
-
-    function vendor_cleared() {
-        var purchases = $('.purchase_uuid select');
-        purchases.empty();
-
-        // TODO: apparently refresh doesn't work right?
-        // http://stackoverflow.com/a/10280078
-        // purchases.selectmenu('refresh');
-        purchases.selectmenu('destroy').selectmenu();
-    }
-
-    $(function() {
-
-        $('.field-wrapper.mode select').selectmenu({
-            change: function(event, ui) {
-                show_mode(ui.item.value);
-            }
-        });
-
-        show_mode(${enum.PURCHASE_BATCH_MODE_ORDERING});
-
-    });
-
-  </script>
-</%def>
-
-<%def name="func_show_mode()">
-  <script type="text/javascript">
-
-    // TODO: mode is presumably null here..
-    function show_mode(mode) {
-        $('.field-wrapper.store_uuid').show();
-        $('.field-wrapper.' + purchases_field).hide();
-        $('.field-wrapper.department_uuid').show();
-        $('.field-wrapper.buyer_uuid').show();
-        $('.field-wrapper.date_ordered').show();
-        $('.field-wrapper.date_received').hide();
-        $('.field-wrapper.po_number').show();
-        $('.field-wrapper.invoice_date').hide();
-        $('.field-wrapper.invoice_number').hide();
-    }
-
-  </script>
-</%def>
+## TODO: deprecate / remove
 
 ${parent.body()}
diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako
index 9d2b7247..34a6085f 100644
--- a/tailbone/templates/ordering/view.mako
+++ b/tailbone/templates/ordering/view.mako
@@ -13,4 +13,406 @@
   % endif
 </%def>
 
-${parent.body()}
+<%def name="render_row_grid_tools()">
+  ${parent.render_row_grid_tools()}
+  % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
+      <ordering-scanner numeric-only>
+      </ordering-scanner>
+  % endif
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
+      <script type="text/x-template" id="ordering-scanner-template">
+        <div>
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="play"
+                    @click="startScanning()">
+            Start Scanning
+          </b-button>
+          <b-modal :active.sync="showScanningDialog"
+                   :can-cancel="false">
+            <div class="card">
+              <div class="card-content">
+                <section style="min-height: 400px;">
+                  <div class="columns">
+
+                    <div class="column">
+                      <b-field grouped>
+
+                        <numeric-input v-if="numericOnly"
+                                       v-model="itemEntry"
+                                       allow-enter
+                                       placeholder="Enter UPC"
+                                       icon-pack="fas"
+                                       icon="fas fa-search"
+                                       ref="itemEntryInput"
+                                       :disabled="currentRow"
+                                       @keydown.native="itemEntryKeydown">
+                        </numeric-input>
+
+                        <b-input v-if="!numericOnly"
+                                 v-model="itemEntry"
+                                 placeholder="Enter UPC"
+                                 icon-pack="fas"
+                                 icon="fas fa-search"
+                                 ref="itemEntryInput"
+                                 :disabled="currentRow">
+                        </b-input>
+
+                        <b-button @click="fetchEntry()"
+                                  :disabled="currentRow">
+                          Fetch
+                        </b-button>
+
+                      </b-field>
+
+                      <div v-if="currentRow">
+                        <b-field grouped>
+
+                          <b-field label="${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}" horizontal>
+                            <numeric-input v-model="currentRow.cases_ordered"
+                                           ref="casesInput"
+                                           @keydown.native="casesKeydown"
+                                           style="width: 60px; margin-right: 1rem;">
+                            </numeric-input>
+                          </b-field>
+
+                          <b-field :label="currentRow.unit_of_measure_display" horizontal>
+                            <numeric-input v-model="currentRow.units_ordered"
+                                           ref="unitsInput"
+                                           @keydown.native="unitsKeydown"
+                                           style="width: 60px;">
+                            </numeric-input>
+                          </b-field>
+
+                        </b-field>
+
+                        <p class="block has-text-weight-bold">
+                          1 ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}
+                          = {{ currentRow.case_quantity || '??' }}
+                          {{ currentRow.unit_of_measure_display }}
+                        </p>
+
+                        <p class="block has-text-weight-bold">
+                          {{ currentRow.po_case_cost_display || '$?.??' }}
+                          per ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]};
+                          {{ currentRow.po_unit_cost_display || '$?.??' }}
+                          per {{ currentRow.unit_of_measure_display }}
+                        </p>
+
+                        <p class="block has-text-weight-bold">
+                          Total is
+                          {{ totalCostDisplay }}
+                        </p>
+
+                        <div class="buttons">
+                          <b-button type="is-primary"
+                                    icon-pack="fas"
+                                    icon-left="save"
+                                    @click="saveCurrentRow()">
+                            Save
+                          </b-button>
+                          <b-button @click="cancelCurrentRow()">
+                            Cancel
+                          </b-button>
+                        </div>
+                      </div>
+
+                    </div>
+
+                    <div class="column is-three-fifths">
+                      <div v-if="currentRow">
+
+                        <b-field label="UPC" horizontal>
+                          {{ currentRow.upc_display }}
+                        </b-field>
+
+                        <b-field label="Brand" horizontal>
+                          {{ currentRow.brand_name }}
+                        </b-field>
+
+                        <b-field label="Description" horizontal>
+                          {{ currentRow.description }}
+                        </b-field>
+
+                        <b-field label="Size" horizontal>
+                          {{ currentRow.size }}
+                        </b-field>
+
+                        <b-field label="Reg. Price" horizontal>
+                          {{ currentRow.product_price_display }}
+                        </b-field>
+
+                        <div class="buttons">
+                          <img :src="currentRow.image_url"></img>
+                          <b-button v-if="currentRow.product_url"
+                                    type="is-primary"
+                                    tag="a" :href="currentRow.product_url"
+                                    target="_blank">
+                            View Full Product
+                          </b-button>
+                        </div>
+                      </div>
+                    </div>
+
+                  </div> <!-- columns -->
+                </section>
+
+                <div class="level">
+                  <div class="level-left">
+                  </div>
+                  <div class="level-right">
+                    <div class="level-item buttons">
+                      <once-button type="is-primary"
+                                   @click="stopScanning()"
+                                   text="Stop Scanning"
+                                   icon-left="stop"
+                                   :disabled="currentRow"
+                                   :title="currentRow ? 'Please save or cancel first' : null">
+                      </once-button>
+                    </div>
+                  </div>
+                </div>
+
+              </div> <!-- card-content -->
+            </div>
+          </b-modal>
+        </div>
+      </script>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
+      <script>
+
+        let OrderingScanner = {
+            template: '#ordering-scanner-template',
+            props: {
+                numericOnly: Boolean,
+            },
+            data() {
+                return {
+                    showScanningDialog: false,
+                    itemEntry: null,
+                    fetching: false,
+                    currentRow: null,
+                    saving: false,
+
+                    ## TODO: should find a better way to handle CSRF token
+                    csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
+                }
+            },
+            computed: {
+
+                totalUnits() {
+                    let cases = parseFloat(this.currentRow.cases_ordered || 0)
+                    let units = parseFloat(this.currentRow.units_ordered || 0)
+                    if (cases) {
+                        units += cases * (this.currentRow.case_quantity || 1)
+                    }
+                    return units
+                },
+
+                totalUnitsDisplay() {
+                    let cases = parseFloat(this.currentRow.cases_ordered || 0)
+                    let units = parseFloat(this.currentRow.units_ordered || 0)
+                    let casesTotal = ""
+                    if (cases) {
+                        casesTotal = cases.toString() + " ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}"
+                    }
+                    let unitsTotal = ""
+                    if (units) {
+                        unitsTotal = units.toString() + " " + this.currentRow.unit_of_measure_display
+                    }
+                    if (casesTotal.length && unitsTotal.length) {
+                        return casesTotal + " + " + unitsTotal
+                    } else if (casesTotal.length) {
+                        return casesTotal
+                    } else if (unitsTotal.length) {
+                        return unitsTotal
+                    }
+                    return "??"
+                },
+
+                totalCost() {
+                    if (this.currentRow.po_case_cost === null
+                        && this.currentRow.po_unit_cost === null) {
+                        return null
+                    }
+                    let cases = parseFloat(this.currentRow.cases_ordered || 0)
+                    let units = parseFloat(this.currentRow.units_ordered || 0)
+                    let total = cases * this.currentRow.po_case_cost
+                    total += units * this.currentRow.po_unit_cost
+                    return total
+                },
+
+                totalCostDisplay() {
+                    if (this.totalCost === null) {
+                        return '$?.??'
+                    }
+                    return '$' + this.totalCost.toFixed(2)
+                },
+            },
+            methods: {
+
+                startScanning() {
+                    this.showScanningDialog = true
+                    this.$nextTick(() => {
+                        this.$refs.itemEntryInput.focus()
+                    })
+                },
+
+                itemEntryKeydown(event) {
+                    if (event.which == 13) {
+                        this.fetchEntry()
+                    }
+                },
+
+                fetchEntry() {
+                    if (this.fetching) {
+                        return
+                    }
+                    if (!this.itemEntry) {
+                        return
+                    }
+
+                    this.fetching = true
+
+                    let url = '${url('{}.scanning_entry'.format(route_prefix), uuid=batch.uuid)}'
+
+                    let params = {
+                        entry: this.itemEntry,
+                    }
+
+                    let headers = {
+                        ## TODO: should find a better way to handle CSRF token
+                        'X-CSRF-TOKEN': this.csrftoken,
+                    }
+
+                    ## TODO: should find a better way to handle CSRF token
+                    this.$http.post(url, params, {headers: headers}).then(({ data }) => {
+                        if (data.error) {
+                            this.$buefy.toast.open({
+                                message: "Fetch failed:  " + data.error,
+                                type: 'is-danger',
+                                duration: 4000, // 4 seconds
+                            })
+                        } else {
+                            this.currentRow = data.row
+                            this.$nextTick(() => {
+                                this.$refs.casesInput.focus()
+                            })
+                        }
+                        this.fetching = false
+                    }, response => {
+                        this.$buefy.toast.open({
+                            message: "Fetch failed:  (unknown error)",
+                            type: 'is-danger',
+                            duration: 4000, // 4 seconds
+                        })
+                        this.fetching = false
+                    })
+                },
+
+                casesKeydown(event) {
+                    if (event.which == 13) {
+                        this.$refs.unitsInput.focus()
+                    } else if (event.which == 27) {
+                        this.cancelCurrentRow()
+                    }
+                },
+
+                unitsKeydown(event) {
+                    if (event.which == 13) {
+                        this.saveCurrentRow()
+                    } else if (event.which == 27) {
+                        this.cancelCurrentRow()
+                    }
+                },
+
+                saveCurrentRow() {
+                    if (this.saving) {
+                        return
+                    }
+
+                    this.saving = true
+
+                    let url = '${url('{}.scanning_update'.format(route_prefix), uuid=batch.uuid)}'
+
+                    let params = {
+                        row_uuid: this.currentRow.uuid,
+                        cases_ordered: this.currentRow.cases_ordered,
+                        units_ordered: this.currentRow.units_ordered,
+                    }
+
+                    let headers = {
+                        ## TODO: should find a better way to handle CSRF token
+                        'X-CSRF-TOKEN': this.csrftoken,
+                    }
+
+                    ## TODO: should find a better way to handle CSRF token
+                    this.$http.post(url, params, {headers: headers}).then(({ data }) => {
+                        if (data.error) {
+                            this.$buefy.toast.open({
+                                message: "Save failed:  " + data.error,
+                                type: 'is-danger',
+                                duration: 4000, // 4 seconds
+                            })
+                        } else {
+                            this.$buefy.toast.open({
+                                message: "Item was saved",
+                                type: 'is-success',
+                            })
+                            this.itemEntry = null
+                            this.currentRow = null
+                            this.$nextTick(() => {
+                                this.$refs.itemEntryInput.focus()
+                            })
+                        }
+                        this.saving = false
+                    }, response => {
+                        this.$buefy.toast.open({
+                            message: "Save failed:  (unknown error)",
+                            type: 'is-danger',
+                            duration: 4000, // 4 seconds
+                        })
+                        this.saving = false
+                    })
+
+                },
+
+                cancelCurrentRow() {
+                    this.itemEntry = null
+                    this.currentRow = null
+                    this.$buefy.toast.open({
+                        message: "Edit was cancelled",
+                        type: 'is-warning',
+                    })
+                    this.$nextTick(() => {
+                        this.$refs.itemEntryInput.focus()
+                    })
+                },
+
+                stopScanning() {
+                    location.reload()
+                },
+            }
+        }
+
+      </script>
+  % endif
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
+      <script>
+        Vue.component('ordering-scanner', OrderingScanner)
+      </script>
+  % endif
+</%def>
diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako
index 97b1b51b..eb2077e7 100644
--- a/tailbone/templates/ordering/worksheet.mako
+++ b/tailbone/templates/ordering/worksheet.mako
@@ -3,63 +3,6 @@
 
 <%def name="title()">Ordering Worksheet</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))}
-  <script type="text/javascript">
-
-    var submitting = false;
-
-    $(function() {
-
-        $('.order-form td.current-order input').focus(function(event) {
-            $(this).parents('tr:first').addClass('active');
-        });
-
-        $('.order-form td.current-order input').blur(function(event) {
-            $(this).parents('tr:first').removeClass('active');
-        });
-
-        $('.order-form td.current-order input').keydown(function(event) {
-            if (key_allowed(event) || key_modifies(event)) {
-                return true;
-            }
-            if (event.which == 13) {
-                if (! submitting) {
-                    submitting = true;
-                    var row = $(this).parents('tr:first');
-                    var form = $('#item-update-form');
-                    form.find('[name="product_uuid"]').val(row.data('uuid'));
-                    form.find('[name="cases_ordered"]').val(row.find('input[name^="cases_ordered_"]').val() || '0');
-                    form.find('[name="units_ordered"]').val(row.find('input[name^="units_ordered_"]').val() || '0');
-                    $.post(form.attr('action'), form.serialize(), function(data) {
-                        if (data.error) {
-                            alert(data.error);
-                        } else {
-                            if (data.row_cases_ordered || data.row_units_ordered) {
-                                row.find('input[name^="cases_ordered_"]').val(data.row_cases_ordered);
-                                row.find('input[name^="units_ordered_"]').val(data.row_units_ordered);
-                                row.find('td.po-total').html(data.row_po_total_calculated);
-                            } else {
-                                row.find('input[name^="cases_ordered_"]').val('');
-                                row.find('input[name^="units_ordered_"]').val('');
-                                row.find('td.po-total').html('');
-                            }
-                            $('.po-total .field').html(data.batch_po_total_calculated);
-                        }
-                        submitting = false;
-                    });
-                }
-            }
-            return false;
-        });
-
-    });
-  </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
@@ -130,7 +73,7 @@
   <div class="grid">
     <table class="order-form">
       <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %>
-      % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''):
+      % for department in sorted(departments.values(), key=lambda d: d.name if d else ''):
           <thead>
             <tr>
               <th class="department" colspan="${column_count}">Department
@@ -141,7 +84,7 @@
                 % endif
               </th>
             </tr>
-            % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''):
+            % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''):
                 <tr>
                   <th class="subdepartment" colspan="${column_count}">Subdepartment
                     % if subdepartment.number or subdepartment.name:
@@ -187,10 +130,7 @@
               <tbody>
                 % for i, cost in enumerate(subdepartment._order_costs, 1):
                     <tr data-uuid="${cost.product_uuid}" class="${'even' if i % 2 == 0 else 'odd'}"
-                        % if use_buefy:
-                        :class="{active: activeUUID == '${cost.uuid}'}"
-                        % endif
-                        >
+                        :class="{active: activeUUID == '${cost.uuid}'}">
                       ${self.order_form_row(cost)}
                       % for data in history:
                           <td class="scratch_pad">
@@ -216,34 +156,21 @@
                       % endfor
                       % if not ignore_cases:
                           <td class="current-order">
-                            % if use_buefy:
-                                <numeric-input v-model="worksheet.cost_${cost.uuid}_cases"
-                                               @focus="activeUUID = '${cost.uuid}'; $event.target.select()"
-                                               @blur="activeUUID = null"
-                                               @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')">
-                                </numeric-input>
-                            % else:
-                                ${h.text('cases_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None)}
-                            % endif
-                          </td>
-                      % endif
-                      <td class="current-order">
-                        % if use_buefy:
-                            <numeric-input v-model="worksheet.cost_${cost.uuid}_units"
+                            <numeric-input v-model="worksheet.cost_${cost.uuid}_cases"
                                            @focus="activeUUID = '${cost.uuid}'; $event.target.select()"
                                            @blur="activeUUID = null"
                                            @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')">
                             </numeric-input>
-                        % else:
-                            ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)}
-                        % endif
-                      </td>
-                      ## TODO: should not fall back to po_total
-                      % if use_buefy:
-                          <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td>
-                      % else:
-                          <td class="po-total">${'${:0,.2f}'.format(cost._batchrow.po_total_calculated or cost._batchrow.po_total or 0) if cost._batchrow else ''}</td>
+                          </td>
                       % endif
+                      <td class="current-order">
+                        <numeric-input v-model="worksheet.cost_${cost.uuid}_units"
+                                       @focus="activeUUID = '${cost.uuid}'; $event.target.select()"
+                                       @blur="activeUUID = null"
+                                       @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')">
+                        </numeric-input>
+                      </td>
+                      <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td>
                       ${self.extra_td(cost)}
                     </tr>
                 % endfor
@@ -269,60 +196,11 @@
 </%def>
 
 <%def name="page_content()">
-  % if use_buefy:
-      <ordering-worksheet></ordering-worksheet>
-  % else:
-      <div class="form-wrapper">
-
-        <div class="field-wrapper">
-          <label>Vendor</label>
-          <div class="field">${h.link_to(vendor, url('vendors.view', uuid=vendor.uuid))}</div>
-        </div>
-
-        <div class="field-wrapper">
-          <label>Vendor Email</label>
-          <div class="field">${vendor.email or ''}</div>
-        </div>
-
-        <div class="field-wrapper">
-          <label>Vendor Fax</label>
-          <div class="field">${vendor.fax_number or ''}</div>
-        </div>
-
-        <div class="field-wrapper">
-          <label>Vendor Contact</label>
-          <div class="field">${vendor.contact or ''}</div>
-        </div>
-
-        <div class="field-wrapper">
-          <label>Vendor Phone</label>
-          <div class="field">${vendor.phone or ''}</div>
-        </div>
-
-        ${self.extra_vendor_fields()}
-
-        <div class="field-wrapper po-total">
-          <label>PO Total</label>
-          ## TODO: should not fall back to po_total
-          <div class="field">$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}</div>
-        </div>
-
-      </div><!-- form-wrapper -->
-
-      ${self.order_form_grid()}
-
-      ${h.form(url('ordering.worksheet_update', uuid=batch.uuid), id='item-update-form', style='display: none;')}
-      ${h.csrf_token(request)}
-      ${h.hidden('product_uuid')}
-      ${h.hidden('cases_ordered')}
-      ${h.hidden('units_ordered')}
-      ${h.end_form()}
-  % endif
+  <ordering-worksheet></ordering-worksheet>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="ordering-worksheet-template">
     <div>
       <div class="form-wrapper">
@@ -360,11 +238,7 @@
       ${self.order_form_grid()}
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+  <script>
 
     const OrderingWorksheet = {
         template: '#ordering-worksheet-template',
@@ -376,7 +250,7 @@
                 submitting: false,
 
                 ## TODO: should find a better way to handle CSRF token
-                csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+                csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
             }
         },
         methods: {
@@ -419,14 +293,12 @@
         },
     }
 
-    Vue.component('ordering-worksheet', OrderingWorksheet)
-
   </script>
 </%def>
 
-
-##############################
-## page body
-##############################
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('ordering-worksheet', OrderingWorksheet)
+  </script>
+</%def>
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako
index 2d8227d4..43b0a266 100644
--- a/tailbone/templates/page.mako
+++ b/tailbone/templates/page.mako
@@ -1,10 +1,50 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/base.mako" />
 
-<%def name="context_menu_items()"></%def>
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${self.render_vue_template_this_page()}
+</%def>
 
-<%def name="page_content()"></%def>
+<%def name="render_vue_template_this_page()">
+  ## DEPRECATED; called for back-compat
+  ${self.render_this_page_template()}
+</%def>
 
+<%def name="render_this_page_template()">
+  <script type="text/x-template" id="this-page-template">
+    <div>
+      ## DEPRECATED; called for back-compat
+      ${self.render_this_page()}
+    </div>
+  </script>
+  <script>
+
+    const ThisPage = {
+        template: '#this-page-template',
+        mixins: [SimpleRequestMixin],
+        props: {
+            configureFieldsHelp: Boolean,
+        },
+        computed: {},
+        watch: {},
+        methods: {
+
+            changeContentTitle(newTitle) {
+                this.$emit('change-content-title', newTitle)
+            },
+        },
+    }
+
+    const ThisPageData = {
+        ## TODO: should find a better way to handle CSRF token
+        csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
+    }
+
+  </script>
+</%def>
+
+## DEPRECATED; remains for back-compat
 <%def name="render_this_page()">
   <div style="display: flex;">
 
@@ -12,64 +52,55 @@
       ${self.page_content()}
     </div>
 
+    ## DEPRECATED; remains for back-compat
     <ul id="context-menu">
       ${self.context_menu_items()}
     </ul>
-
   </div>
 </%def>
 
-<%def name="render_this_page_template()">
-  <script type="text/x-template" id="this-page-template">
-    <div>
-      ${self.render_this_page()}
-    </div>
-  </script>
+## nb. this is the canonical block for page content!
+<%def name="page_content()"></%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="context_menu_items()">
+  % if context_menu_list_items is not Undefined:
+      % for item in context_menu_list_items:
+          <li>${item}</li>
+      % endfor
+  % endif
 </%def>
 
-<%def name="declare_this_page_vars()">
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
 
-    let ThisPage = {
-        template: '#this-page-template',
-        computed: {},
-        methods: {},
-    }
-
-    let ThisPageData = {
-        ## TODO: should find a better way to handle CSRF token
-        csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
-    }
-
-  </script>
+  ## DEPRECATED; called for back-compat
+  ${self.declare_this_page_vars()}
+  ${self.modify_this_page_vars()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
-</%def>
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
 
-<%def name="finalize_this_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
+  ## DEPRECATED; called for back-compat
+  ${self.make_this_page_component()}
 </%def>
 
 <%def name="make_this_page_component()">
-  ${self.declare_this_page_vars()}
-  ${self.modify_this_page_vars()}
   ${self.finalize_this_page_vars()}
-
-  <script type="text/javascript">
-
+  <script>
     ThisPage.data = function() { return ThisPageData }
-
     Vue.component('this-page', ThisPage)
-
+    <% request.register_component('this-page', 'ThisPage') %>
   </script>
 </%def>
 
+##############################
+## DEPRECATED
+##############################
 
-% if use_buefy:
-    ${self.render_this_page_template()}
-    ${self.make_this_page_component()}
-% else:
-    ${self.render_this_page()}
-% endif
+<%def name="declare_this_page_vars()"></%def>
+
+<%def name="modify_this_page_vars()"></%def>
+
+<%def name="finalize_this_page_vars()"></%def>
diff --git a/tailbone/templates/page_help.mako b/tailbone/templates/page_help.mako
new file mode 100644
index 00000000..ea86c6da
--- /dev/null
+++ b/tailbone/templates/page_help.mako
@@ -0,0 +1,310 @@
+## -*- coding: utf-8; -*-
+
+<%def name="render_template()">
+  <script type="text/x-template" id="page-help-template">
+    <div>
+
+      % if help_url or help_markdown:
+
+          % if request.use_oruga:
+              <o-button icon-left="question-circle"
+                        % if help_markdown:
+                        @click="displayInit()"
+                        % elif help_url:
+                        tag="a" href="${help_url}"
+                        target="_blank"
+                        % endif
+                        >
+                Help
+              </o-button>
+
+              % if can_edit_help:
+                  ## TODO: this dropdown is duplicated, below
+                  <o-dropdown position="bottom-left"
+                              ## TODO: why does click not work here?!
+                              :triggers="['click', 'hover']">
+                    <template #trigger>
+                      <o-button>
+                        <o-icon icon="cog" />
+                      </o-button>
+                    </template>
+                    <o-dropdown-item label="Edit Page Help"
+                                     @click="configureInit()" />
+                    <o-dropdown-item label="Edit Fields Help"
+                                     @click="configureFieldsInit()" />
+                  </o-dropdown>
+              % endif
+
+          % else:
+              ## buefy
+              <b-field>
+                <p class="control">
+                  <b-button icon-pack="fas"
+                            icon-left="question-circle"
+                            % if help_markdown:
+                            @click="displayInit()"
+                            % elif help_url:
+                            tag="a" href="${help_url}"
+                            target="_blank"
+                            % endif
+                            >
+                    Help
+                  </b-button>
+                </p>
+                % if can_edit_help:
+                    ## TODO: this dropdown is duplicated, below
+                    <b-dropdown aria-role="list"  position="is-bottom-left">
+                      <template #trigger="{ active }">
+                        <b-button>
+                          <span><i class="fa fa-cog"></i></span>
+                        </b-button>
+                      </template>
+                      <b-dropdown-item aria-role="listitem"
+                                       @click="configureInit()">
+                        Edit Page Help
+                      </b-dropdown-item>
+                      <b-dropdown-item aria-role="listitem"
+                                       @click="configureFieldsInit()">
+                        Edit Fields Help
+                      </b-dropdown-item>
+                    </b-dropdown>
+                % endif
+              </b-field>
+          % endif:
+
+      % elif can_edit_help:
+
+          ## TODO: this dropdown is duplicated, above
+          % if request.use_oruga:
+              <o-dropdown position="bottom-left"
+                          ## TODO: why does click not work here?!
+                          :triggers="['click', 'hover']">
+                <template #trigger>
+                  <o-button>
+                    <o-icon icon="question-circle" />
+                    <o-icon icon="cog" />
+                  </o-button>
+                </template>
+                <o-dropdown-item label="Edit Page Help"
+                                 @click="configureInit()" />
+                <o-dropdown-item label="Edit Fields Help"
+                                 @click="configureFieldsInit()" />
+              </o-dropdown>
+          % else:
+              <b-field>
+                <p class="control">
+                  <b-dropdown aria-role="list"  position="is-bottom-left">
+                    <template #trigger>
+                      <b-button>
+                        % if request.use_oruga:
+                            <o-icon icon="question-circle" />
+                            <o-icon icon="cog" />
+                        % else:
+                        <span><i class="fa fa-question-circle"></i></span>
+                        <span><i class="fa fa-cog"></i></span>
+                        % endif
+                      </b-button>
+                    </template>
+                    <b-dropdown-item aria-role="listitem"
+                                     @click="configureInit()">
+                      Edit Page Help
+                    </b-dropdown-item>
+                    <b-dropdown-item aria-role="listitem"
+                                     @click="configureFieldsInit()">
+                      Edit Fields Help
+                    </b-dropdown-item>
+                  </b-dropdown>
+                </p>
+              </b-field>
+          % endif
+      % endif
+
+      % if help_markdown:
+          <${b}-modal has-modal-card
+                      % if request.use_oruga:
+                      v-model:active="displayShowDialog"
+                      % else:
+                      :active.sync="displayShowDialog"
+                      % endif
+                      >
+            <div class="modal-card">
+
+              <header class="modal-card-head">
+                <p class="modal-card-title">${index_title}</p>
+              </header>
+
+              <section class="modal-card-body">
+                ${h.render_markdown(help_markdown)}
+              </section>
+
+              <footer class="modal-card-foot">
+                % if help_url:
+                    <b-button type="is-primary"
+                              icon-pack="fas"
+                              icon-left="external-link-alt"
+                              tag="a" href="${help_url}"
+                              target="_blank">
+                      More Info
+                    </b-button>
+                % endif
+                <b-button @click="displayShowDialog = false">
+                  Close
+                </b-button>
+              </footer>
+            </div>
+          </${b}-modal>
+      % endif
+
+      % if can_edit_help:
+
+          <${b}-modal has-modal-card
+                      % if request.use_oruga:
+                      v-model:active="configureShowDialog"
+                      % else:
+                      :active.sync="configureShowDialog"
+                      % endif
+                      >
+            <div class="modal-card"
+                 % if request.use_oruga:
+                 style="margin: auto;"
+                 % endif
+                 >
+
+              <header class="modal-card-head">
+                <p class="modal-card-title">Configure Help</p>
+              </header>
+
+              <section class="modal-card-body">
+
+                <p class="block">
+                  This help info applies to all views with the current
+                  route prefix.
+                </p>
+
+                <b-field grouped>
+
+                  <b-field label="Route Prefix">
+                    <span>${route_prefix}</span>
+                  </b-field>
+
+                  <b-field label="URL Prefix">
+                    <span>${master.get_url_prefix()}</span>
+                  </b-field>
+
+                </b-field>
+
+                <b-field label="Help Link (URL)">
+                  <b-input v-model="helpURL"
+                           ref="helpURL"
+                           expanded>
+                  </b-input>
+                </b-field>
+
+                <b-field label="Help Text (Markdown)">
+                  <b-input v-model="markdownText"
+                           type="textarea" rows="8"
+                           expanded>
+                  </b-input>
+                </b-field>
+
+              </section>
+
+              <footer class="modal-card-foot">
+                <b-button @click="configureShowDialog = false">
+                  Cancel
+                </b-button>
+                <b-button type="is-primary"
+                          @click="configureSave()"
+                          :disabled="configureSaving"
+                          icon-pack="fas"
+                          icon-left="save">
+                  {{ configureSaving ? "Working, please wait..." : "Save" }}
+                </b-button>
+              </footer>
+            </div>
+          </${b}-modal>
+
+      % endif
+
+    </div>
+  </script>
+</%def>
+
+<%def name="declare_vars()">
+  <script type="text/javascript">
+
+    let PageHelp = {
+
+        template: '#page-help-template',
+        mixins: [FormPosterMixin],
+
+        methods: {
+
+            displayInit() {
+                this.displayShowDialog = true
+            },
+
+            % if can_edit_help:
+            configureInit() {
+                this.configureShowDialog = true
+                this.$nextTick(() => {
+                    this.$refs.helpURL.focus()
+                })
+            },
+
+            configureFieldsInit() {
+                this.$emit('configure-fields-help')
+                this.$buefy.toast.open({
+                    message: "Please see the gear icon next to configurable fields",
+                    type: 'is-info',
+                    duration: 4000, // 4 seconds
+                })
+            },
+
+            configureSave() {
+                this.configureSaving = true
+                let url = '${url('{}.edit_help'.format(route_prefix))}'
+                let params = {
+                    help_url: this.helpURL,
+                    markdown_text: this.markdownText,
+                }
+                this.submitForm(url, params, response => {
+                    this.configureShowDialog = false
+                    this.$buefy.toast.open({
+                        message: "Info was saved; please refresh page to see changes.",
+                        type: 'is-info',
+                        duration: 4000, // 4 seconds
+                    })
+                    this.configureSaving = false
+                }, response => {
+                    this.configureSaving = false
+                })
+            },
+            % endif
+        },
+    }
+
+    let PageHelpData = {
+        displayShowDialog: false,
+
+        % if can_edit_help:
+        configureShowDialog: false,
+        configureSaving: false,
+        helpURL: ${json.dumps(help_url or None)|n},
+        markdownText: ${json.dumps(help_markdown or None)|n},
+        % endif
+    }
+
+  </script>
+</%def>
+
+<%def name="make_component()">
+  <script type="text/javascript">
+
+    PageHelp.data = function() { return PageHelpData }
+
+    Vue.component('page-help', PageHelp)
+    <% request.register_component('page-help', 'PageHelp') %>
+
+  </script>
+</%def>
diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako
new file mode 100644
index 00000000..257432dc
--- /dev/null
+++ b/tailbone/templates/people/configure.mako
@@ -0,0 +1,61 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">General</h3>
+  <div class="block" style="padding-left: 2rem; width: 50%;">
+
+    <b-field message="If set, grid links are to Personal tab of Profile view.">
+      <b-checkbox name="rattail.people.straight_to_profile"
+                  v-model="simpleSettings['rattail.people.straight_to_profile']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Link directly to Profile when applicable
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="Allows quick profile lookup using e.g. customer number.">
+      <b-checkbox name="rattail.people.expose_quickie_search"
+                  v-model="simpleSettings['rattail.people.expose_quickie_search']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show "quickie search" lookup
+      </b-checkbox>
+    </b-field>
+
+    <b-field label="People Handler"
+             message="Leave blank for default handler.">
+      <b-input name="rattail.people.handler"
+               v-model="simpleSettings['rattail.people.handler']"
+               @input="settingsNeedSaved = true"
+               expanded />
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Profile View</h3>
+  <div class="block" style="padding-left: 2rem; width: 50%;">
+
+    <b-field>
+      <b-checkbox name="tailbone.people.profile.expose_members"
+                  v-model="simpleSettings['tailbone.people.profile.expose_members']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show tab for Member Accounts
+      </b-checkbox>
+    </b-field>
+    <b-field>
+      <b-checkbox name="tailbone.people.profile.expose_transactions"
+                  v-model="simpleSettings['tailbone.people.profile.expose_transactions']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show tab for Customer POS Transactions
+      </b-checkbox>
+    </b-field>
+
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako
new file mode 100644
index 00000000..cd6fddf1
--- /dev/null
+++ b/tailbone/templates/people/index.mako
@@ -0,0 +1,102 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="grid_tools()">
+
+  % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
+      <b-button @click="showMergeRequest()"
+                icon-pack="fas"
+                icon-left="object-ungroup"
+                :disabled="checkedRows.length != 2">
+        Request Merge
+      </b-button>
+      <b-modal has-modal-card
+               :active.sync="mergeRequestShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Request Merge of 2 People</p>
+          </header>
+
+          <section class="modal-card-body">
+            <b-table :data="mergeRequestRows"
+                     striped hoverable>
+              <b-table-column field="customer_number"
+                              label="Customer #"
+                              v-slot="props">
+                <span v-html="props.row.customer_number"></span>
+              </b-table-column>
+              <b-table-column field="first_name"
+                              label="First Name"
+                              v-slot="props">
+                <span v-html="props.row.first_name"></span>
+              </b-table-column>
+              <b-table-column field="last_name"
+                              label="Last Name"
+                              v-slot="props">
+                <span v-html="props.row.last_name"></span>
+              </b-table-column>
+            </b-table>
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="mergeRequestShowDialog = false">
+              Cancel
+            </b-button>
+            ${h.form(url('{}.request_merge'.format(route_prefix)), **{'@submit': 'submitMergeRequest'})}
+            ${h.csrf_token(request)}
+            ${h.hidden('removing_uuid', **{':value': 'mergeRequestRemovingUUID'})}
+            ${h.hidden('keeping_uuid', **{':value': 'mergeRequestKeepingUUID'})}
+            <b-button type="is-primary"
+                      native-type="submit"
+                      :disabled="mergeRequestSubmitting">
+              {{ mergeRequestSubmitText }}
+            </b-button>
+            ${h.end_form()}
+          </footer>
+        </div>
+      </b-modal>
+  % endif
+
+  ${parent.grid_tools()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
+
+        ${grid.vue_component}Data.mergeRequestShowDialog = false
+        ${grid.vue_component}Data.mergeRequestRows = []
+        ${grid.vue_component}Data.mergeRequestSubmitText = "Submit Merge Request"
+        ${grid.vue_component}Data.mergeRequestSubmitting = false
+
+        ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() {
+            if (this.mergeRequestRows.length) {
+                return this.mergeRequestRows[0].uuid
+            }
+            return null
+        }
+
+        ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() {
+            if (this.mergeRequestRows.length) {
+                return this.mergeRequestRows[1].uuid
+            }
+            return null
+        }
+
+        ${grid.vue_component}.methods.showMergeRequest = function() {
+            this.mergeRequestRows = this.checkedRows
+            this.mergeRequestShowDialog = true
+        }
+
+        ${grid.vue_component}.methods.submitMergeRequest = function() {
+            this.mergeRequestSubmitting = true
+            this.mergeRequestSubmitText = "Working, please wait..."
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako
new file mode 100644
index 00000000..e2db1476
--- /dev/null
+++ b/tailbone/templates/people/merge-requests/view.mako
@@ -0,0 +1,36 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="page_content()">
+  ${parent.page_content()}
+  % if not instance.merged and request.has_perm('people.merge'):
+      ${h.form(url('people.merge'), **{'@submit': 'submitMergeForm'})}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', value=','.join([instance.removing_uuid, instance.keeping_uuid]))}
+      <b-button type="is-primary"
+                native-type="submit"
+                :disabled="mergeFormSubmitting"
+                icon-pack="fas"
+                icon-left="object-ungroup">
+        {{ mergeFormButtonText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if not instance.merged and request.has_perm('people.merge'):
+      <script>
+
+        ThisPageData.mergeFormButtonText = "Perform Merge"
+        ThisPageData.mergeFormSubmitting = false
+
+        ThisPage.methods.submitMergeForm = function() {
+            this.mergeFormButtonText = "Working, please wait..."
+            this.mergeFormSubmitting = true
+        }
+
+      </script>
+  % endif
+</%def>
diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako
index 50804392..15c669fa 100644
--- a/tailbone/templates/people/view.mako
+++ b/tailbone/templates/people/view.mako
@@ -2,22 +2,13 @@
 <%inherit file="/master/view.mako" />
 <%namespace file="/util.mako" import="view_profiles_helper" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy and not instance.users and request.has_perm('users.create'):
-      <script type="text/javascript">
-        ## TODO: should do this differently for Buefy themes
-        $(function() {
-            $('#make-user').click(function() {
-                if (confirm("Really make a user account for this person?")) {
-                    % if not use_buefy:
-                    disable_button(this);
-                    % endif
-                    $('form[name="make-user-form"]').submit();
-                }
-            });
-        });
-      </script>
+<%def name="page_content()">
+  ${parent.page_content()}
+  % if not instance.users and request.has_perm('users.create'):
+      ${h.form(url('people.make_user'), ref='makeUserForm')}
+      ${h.csrf_token(request)}
+      ${h.hidden('person_uuid', value=instance.uuid)}
+      ${h.end_form()}
   % endif
 </%def>
 
@@ -26,17 +17,17 @@
   ${view_profiles_helper([instance])}
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
-    <tailbone-form v-on:make-user="makeUser"></tailbone-form>
+    <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}>
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneForm.methods.clickMakeUser = function(event) {
+    ${form.vue_component}.methods.clickMakeUser = function(event) {
         this.$emit('make-user')
     }
 
@@ -48,21 +39,3 @@
 
   </script>
 </%def>
-
-<%def name="page_content()">
-  ${parent.page_content()}
-  % if not instance.users and request.has_perm('users.create'):
-      % if use_buefy:
-          ${h.form(url('people.make_user'), ref='makeUserForm')}
-      % else:
-          ${h.form(url('people.make_user'), name='make-user-form')}
-      % endif
-      ${h.csrf_token(request)}
-      ${h.hidden('person_uuid', value=instance.uuid)}
-      ${h.end_form()}
-  % endif
-</%def>
-
-
-${parent.body()}
-
diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index 07448b73..6ca5a84c 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -1,402 +1,3256 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
-
-    ## NOTE: we must delay activation of accordions, otherwise they do not
-    ## seem to "resize" correctly
-    var customer_accordion_activated = false;
-    var user_accordion_activated = false;
-
-    $(function() {
-        $('#profile-tabs').tabs({
-            activate: function(event, ui) {
-                ## activate accordion, first time tab is activated
-                if (ui.newPanel.selector == '#customer-tab') {
-                    if (! customer_accordion_activated) {
-                        $('#customers-accordion').accordion();
-                        customer_accordion_activated = true;
-                    }
-                } else if (ui.newPanel.selector == '#user-tab') {
-                    if (! user_accordion_activated) {
-                        $('#users-accordion').accordion();
-                        user_accordion_activated = true;
-                    }
-                }
-            }
-        });
-    });
-  </script>
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+    .card.personal {
+        margin-bottom: 1rem;
+    }
+    .field.is-horizontal .field-label .label {
+        white-space: nowrap;
+        min-width: 10rem;
+    }
+  </style>
 </%def>
 
-<div id="profile-tabs">
-  <ul>
-    <li><a href="#personal-tab">Personal</a></li>
-    <li><a href="#customer-tab">Customer</a></li>
-    <li><a href="#employee-tab">Employee</a></li>
-    <li><a href="#user-tab">User</a></li>
-  </ul>
+<%def name="content_title()">
+  ${dynamic_content_title or str(instance)}
+</%def>
 
-  <div id="personal-tab">
+<%def name="render_instance_header_title_extras()">
+  % if request.has_perm('people_profile.view_versions'):
+      <div class="level-item" style="margin-left: 2rem;">
+        <b-button v-if="!viewingHistory"
+                  icon-pack="fas"
+                  icon-left="history"
+                  @click="viewHistory()">
+          View History
+        </b-button>
+        <div v-if="viewingHistory"
+             class="buttons">
+          <b-button icon-pack="fas"
+                    icon-left="user"
+                    @click="viewingHistory = false">
+            View Profile
+          </b-button>
+          <b-button icon-pack="fas"
+                    icon-left="redo"
+                    @click="refreshHistory()"
+                    :disabled="gettingRevisions">
+            {{ gettingRevisions ? "Working, please wait..." : "Refresh History" }}
+          </b-button>
+        </div>
+      </div>
+  % endif
+</%def>
 
+<%def name="page_content()">
+  <profile-info @change-content-title="changeContentTitle"
+                % if request.has_perm('people_profile.view_versions'):
+                :viewing-history="viewingHistory"
+                :getting-revisions="gettingRevisions"
+                :revisions="revisions"
+                :revision-version-map="revisionVersionMap"
+                % endif
+                >
+  </profile-info>
+</%def>
+
+<%def name="render_this_page_component()">
+  ## TODO: should override this in a cleaner way!  too much duplicate code w/ parent template
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+             :configure-fields-help="configureFieldsHelp"
+             % endif
+             % if request.has_perm('people_profile.view_versions'):
+             :viewing-history="viewingHistory"
+             :getting-revisions="gettingRevisions"
+             :revisions="revisions"
+             :revision-version-map="revisionVersionMap"
+             % endif
+             >
+  </this-page>
+</%def>
+
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
+<%def name="render_personal_name_card()">
+  <div class="card personal"
+        ## nb. hack to force refresh for vue3
+       :key="refreshPersonalCard">
+    <header class="card-header">
+      <p class="card-header-title">Name</p>
+    </header>
+    <div class="card-content">
+      <div class="content">
+        <div style="display: flex; justify-content: space-between;">
+          <div style="flex-grow: 1; margin-right: 1rem;">
+
+            <b-field horizontal label="First Name">
+              <span>{{ person.first_name }}</span>
+            </b-field>
+
+            % if use_preferred_first_name:
+                <b-field horizontal label="Preferred First Name">
+                  <span>{{ person.preferred_first_name }}</span>
+                </b-field>
+            % endif
+
+            <b-field horizontal label="Middle Name">
+              <span>{{ person.middle_name }}</span>
+            </b-field>
+
+            <b-field horizontal label="Last Name">
+              <span>{{ person.last_name }}</span>
+            </b-field>
+
+          </div>
+          % if request.has_perm('people_profile.edit_person'):
+              <div v-if="editNameAllowed()">
+                <b-button type="is-primary"
+                          @click="editNameInit()"
+                          icon-pack="fas"
+                          icon-left="edit">
+                  Edit Name
+                </b-button>
+              </div>
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editNameShowDialog"
+                          % else:
+                              :active.sync="editNameShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Edit Name</p>
+                  </header>
+
+                  <section class="modal-card-body">
+
+                    % if use_preferred_first_name:
+                        <b-field grouped>
+                          <b-field label="First Name">
+                            <b-input v-model.trim="editNameFirst"
+                                     :maxlength="maxLengths.person_first_name || null" />
+                          </b-field>
+                          <b-field label="Preferred First Name" expanded>
+                            <b-input v-model.trim="editNameFirstPreferred"
+                                     :maxlength="maxLengths.person_preferred_first_name || null">
+                            </b-input>
+                          </b-field>
+                        </b-field>
+                    % else:
+                        <b-field label="First Name">
+                          <b-input v-model.trim="editNameFirst"
+                                   :maxlength="maxLengths.person_first_name || null"
+                                   expanded />
+                        </b-field>
+                    % endif
+
+                    <b-field label="Middle Name">
+                      <b-input v-model.trim="editNameMiddle"
+                               :maxlength="maxLengths.person_middle_name || null"
+                               expanded />
+                    </b-field>
+                    <b-field label="Last Name">
+                      <b-input v-model.trim="editNameLast"
+                               :maxlength="maxLengths.person_last_name || null"
+                               expanded />
+                    </b-field>
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button type="is-primary"
+                              @click="editNameSave()"
+                              :disabled="editNameSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ editNameSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                    <b-button @click="editNameShowDialog = false">
+                      Cancel
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
+        </div>
+      </div>
+    </div>
+  </div>
+</%def>
+
+<%def name="render_personal_address_card()">
+  <div class="card personal"
+       ## nb. hack to force refresh for vue3
+       :key="refreshAddressCard">
+    <header class="card-header">
+      <p class="card-header-title">Address</p>
+    </header>
+    <div class="card-content">
+      <div class="content">
+        <div style="display: flex; justify-content: space-between;">
+          <div style="flex-grow: 1; margin-right: 1rem;">
+
+            <b-field horizontal label="Street 1">
+              <span>{{ person.address ? person.address.street : null }}</span>
+            </b-field>
+
+            <b-field horizontal label="Street 2">
+              <span>{{ person.address ? person.address.street2 : null }}</span>
+            </b-field>
+
+            <b-field horizontal label="City">
+              <span>{{ person.address ? person.address.city : null }}</span>
+            </b-field>
+
+            <b-field horizontal label="State">
+              <span>{{ person.address ? person.address.state : null }}</span>
+            </b-field>
+
+            <b-field horizontal label="Zipcode">
+              <span>{{ person.address ? person.address.zipcode : null }}</span>
+            </b-field>
+
+            <b-field v-if="person.address && person.address.invalid"
+                     horizontal label="Invalid"
+                     class="has-text-danger">
+              <span>Yes</span>
+            </b-field>
+
+          </div>
+          % if request.has_perm('people_profile.edit_person'):
+              <b-button type="is-primary"
+                        @click="editAddressInit()"
+                        icon-pack="fas"
+                        icon-left="edit">
+                Edit Address
+              </b-button>
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editAddressShowDialog"
+                          % else:
+                              :active.sync="editAddressShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Edit Address</p>
+                  </header>
+
+                  <section class="modal-card-body">
+
+                    <b-field label="Street 1" expanded>
+                      <b-input v-model.trim="editAddressStreet1"
+                               :maxlength="maxLengths.address_street || null"
+                               expanded />
+                    </b-field>
+
+                    <b-field label="Street 2" expanded>
+                      <b-input v-model.trim="editAddressStreet2"
+                               :maxlength="maxLengths.address_street2 || null"
+                               expanded />
+                    </b-field>
+
+                    <b-field label="Zipcode">
+                      <b-input v-model.trim="editAddressZipcode"
+                               :maxlength="maxLengths.address_zipcode || null"
+                               expanded />
+                    </b-field>
+
+                    <b-field grouped>
+                      <b-field label="City">
+                        <b-input v-model.trim="editAddressCity"
+                                 :maxlength="maxLengths.address_city || null">
+                        </b-input>
+                      </b-field>
+                      <b-field label="State">
+                        <b-input v-model.trim="editAddressState"
+                                 :maxlength="maxLengths.address_state || null">
+                        </b-input>
+                      </b-field>
+                    </b-field>
+
+                    <b-field label="Invalid">
+                      <b-checkbox v-model="editAddressInvalid"
+                                  type="is-danger">
+                      </b-checkbox>
+                    </b-field>
+
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <once-button type="is-primary"
+                                 @click="editAddressSave()"
+                                 :disabled="editAddressSaveDisabled"
+                                 icon-left="save"
+                                 text="Save">
+                    </once-button>
+                    <b-button @click="editAddressShowDialog = false">
+                      Cancel
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
+        </div>
+      </div>
+    </div>
+  </div>
+</%def>
+
+<%def name="render_personal_phone_card()">
+  <div class="card personal">
+    <header class="card-header">
+      <p class="card-header-title">Phone(s)</p>
+    </header>
+    <div class="card-content">
+      <div class="content">
+
+        <b-notification v-if="person.invalid_phone_number"
+                        type="is-warning"
+                        has-icon icon-pack="fas"
+                        :closable="false">
+          We appear to have an invalid phone number on file for this person.
+        </b-notification>
+
+        % if request.has_perm('people_profile.edit_person'):
+            <div class="has-text-right">
+              <b-button type="is-primary"
+                        icon-pack="fas"
+                        icon-left="plus"
+                        @click="addPhoneInit()">
+                Add Phone
+              </b-button>
+            </div>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="editPhoneShowDialog"
+                        % else:
+                            :active.sync="editPhoneShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">
+                    {{ editPhoneUUID ? "Edit" : "Add" }} Phone
+                  </p>
+                </header>
+
+                <section class="modal-card-body">
+
+                  <b-field label="Type">
+                    <b-select v-model="editPhoneType">
+                      <option v-for="option in phoneTypeOptions"
+                              :key="option.value"
+                              :value="option.value">
+                        {{ option.label }}
+                      </option>
+                    </b-select>
+                  </b-field>
+
+                  <b-field label="Number">
+                    <b-input v-model.trim="editPhoneNumber"
+                             ref="editPhoneInput"
+                             expanded />
+                  </b-field>
+
+                  <b-field label="Preferred?">
+                    <b-checkbox v-model="editPhonePreferred">
+                    </b-checkbox>
+                  </b-field>
+
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="editPhoneSave()"
+                            :disabled="editPhoneSaveDisabled"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ editPhoneSaving ? "Working..." : "Save" }}
+                  </b-button>
+                  <b-button @click="editPhoneShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+        % endif
+
+        <${b}-table :data="person.phones">
+
+          <${b}-table-column field="preference"
+                          label="Preferred"
+                          v-slot="props">
+            {{ props.row.preferred ? "Yes" : "" }}
+          </${b}-table-column>
+
+          <${b}-table-column field="type"
+                          label="Type"
+                          v-slot="props">
+            {{ props.row.type }}
+          </${b}-table-column>
+
+          <${b}-table-column field="number"
+                          label="Number"
+                          v-slot="props">
+            {{ props.row.number }}
+          </${b}-table-column>
+
+          % if request.has_perm('people_profile.edit_person'):
+          <${b}-table-column label="Actions"
+                          v-slot="props">
+            <a href="#" @click.prevent="editPhoneInit(props.row)"
+               % if not request.use_oruga:
+                   class="grid-action"
+               % endif
+               >
+              % if request.use_oruga:
+                  <span class="icon-text">
+                    <o-icon icon="edit" />
+                    <span>Edit</span>
+                  </span>
+              % else:
+                  <i class="fas fa-edit"></i>
+                  Edit
+              % endif
+            </a>
+            <a href="#" @click.prevent="deletePhoneInit(props.row)"
+               % if request.use_oruga:
+                   class="has-text-danger"
+               % else:
+                   class="grid-action has-text-danger"
+               % endif
+               >
+              % if request.use_oruga:
+                  <span class="icon-text">
+                    <o-icon icon="trash" />
+                    <span>Delete</span>
+                  </span>
+              % else:
+                  <i class="fas fa-trash"></i>
+                  Delete
+              % endif
+            </a>
+            <a v-if="!props.row.preferred"
+               href="#" @click.prevent="preferPhoneInit(props.row)"
+               % if not request.use_oruga:
+                   class="grid-action"
+               % endif
+               >
+              % if request.use_oruga:
+                  <span class="icon-text">
+                    <o-icon icon="star" />
+                    <span>Set Preferred</span>
+                  </span>
+              % else:
+                  <i class="fas fa-star"></i>
+                  Set Preferred
+              % endif
+            </a>
+          </${b}-table-column>
+          % endif
+
+        </${b}-table>
+
+        % if request.has_perm('people_profile.edit_person'):
+
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="deletePhoneShowDialog"
+                        % else:
+                            :active.sync="deletePhoneShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Delete Phone</p>
+                </header>
+
+                <section class="modal-card-body">
+                  <p class="block">Really delete this phone number?</p>
+                  <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p>
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button type="is-danger"
+                            @click="deletePhoneSave()"
+                            :disabled="deletePhoneSaving"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }}
+                  </b-button>
+                  <b-button @click="deletePhoneShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="preferPhoneShowDialog"
+                        % else:
+                            :active.sync="preferPhoneShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Set Preferred Phone</p>
+                </header>
+
+                <section class="modal-card-body">
+                  <p class="block">Really make this the preferred phone number?</p>
+                  <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p>
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="preferPhoneSave()"
+                            :disabled="preferPhoneSaving"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }}
+                  </b-button>
+                  <b-button @click="preferPhoneShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+        % endif
+      </div>
+    </div>
+  </div>
+</%def>
+
+<%def name="render_personal_email_card()">
+  <div class="card personal">
+    <header class="card-header">
+      <p class="card-header-title">Email(s)</p>
+    </header>
+    <div class="card-content">
+      <div class="content">
+
+        % if request.has_perm('people_profile.edit_person'):
+            <div class="has-text-right">
+              <b-button type="is-primary"
+                        icon-pack="fas"
+                        icon-left="plus"
+                        @click="addEmailInit()">
+                Add Email
+              </b-button>
+            </div>
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="editEmailShowDialog"
+                        % else:
+                            :active.sync="editEmailShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">
+                    {{ editEmailUUID ? "Edit" : "Add" }} Email
+                  </p>
+                </header>
+
+                <section class="modal-card-body">
+
+                  <b-field label="Type">
+                    <b-select v-model="editEmailType">
+                      <option v-for="option in emailTypeOptions"
+                              :key="option.value"
+                              :value="option.value">
+                        {{ option.label }}
+                      </option>
+                    </b-select>
+                  </b-field>
+
+                  <b-field label="Address">
+                    <b-input v-model.trim="editEmailAddress"
+                             ref="editEmailInput"
+                             expanded />
+                  </b-field>
+
+                  <b-field v-if="!editEmailUUID"
+                           label="Preferred?">
+                    <b-checkbox v-model="editEmailPreferred">
+                    </b-checkbox>
+                  </b-field>
+
+                  <b-field v-if="editEmailUUID"
+                           label="Invalid?">
+                    <b-checkbox v-model="editEmailInvalid"
+                                :type="editEmailInvalid ? 'is-danger': null">
+                    </b-checkbox>
+                  </b-field>
+
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="editEmailSave()"
+                            :disabled="editEmailSaveDisabled"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ editEmailSaving ? "Working, please wait..." : "Save" }}
+                  </b-button>
+                  <b-button @click="editEmailShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+        % endif
+
+        <${b}-table :data="person.emails">
+
+          <${b}-table-column field="preference"
+                          label="Preferred"
+                          v-slot="props">
+            {{ props.row.preferred ? "Yes" : "" }}
+          </${b}-table-column>
+
+          <${b}-table-column field="type"
+                          label="Type"
+                          v-slot="props">
+            {{ props.row.type }}
+          </${b}-table-column>
+
+          <${b}-table-column field="address"
+                          label="Address"
+                          v-slot="props">
+            {{ props.row.address }}
+          </${b}-table-column>
+
+          <${b}-table-column field="invalid"
+                          label="Invalid?"
+                          v-slot="props">
+            <span v-if="props.row.invalid" class="has-text-danger has-text-weight-bold">Invalid</span>
+          </${b}-table-column>
+
+          % if request.has_perm('people_profile.edit_person'):
+              <${b}-table-column label="Actions"
+                              v-slot="props">
+                <a href="#" @click.prevent="editEmailInit(props.row)"
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
+                   >
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="edit" />
+                        <span>Edit</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-edit"></i>
+                      Edit
+                  % endif
+                </a>
+                <a href="#" @click.prevent="deleteEmailInit(props.row)"
+                   % if request.use_oruga:
+                       class="has-text-danger"
+                   % else:
+                       class="grid-action has-text-danger"
+                   % endif
+                   >
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="trash" />
+                        <span>Delete</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-trash"></i>
+                      Delete
+                  % endif
+                </a>
+                <a v-if="!props.row.preferred"
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
+                   href="#" @click.prevent="preferEmailInit(props.row)">
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="star" />
+                        <span>Set Preferred</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-star"></i>
+                      Set Preferred
+                  % endif
+                </a>
+              </${b}-table-column>
+          % endif
+
+        </${b}-table>
+
+        % if request.has_perm('people_profile.edit_person'):
+
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="deleteEmailShowDialog"
+                        % else:
+                            :active.sync="deleteEmailShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Delete Email</p>
+                </header>
+
+                <section class="modal-card-body">
+                  <p class="block">Really delete this email address?</p>
+                  <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p>
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button type="is-danger"
+                            @click="deleteEmailSave()"
+                            :disabled="deleteEmailSaving"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }}
+                  </b-button>
+                  <b-button @click="deleteEmailShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="preferEmailShowDialog"
+                        % else:
+                            :active.sync="preferEmailShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Set Preferred Email</p>
+                </header>
+
+                <section class="modal-card-body">
+                  <p class="block">Really make this the preferred email address?</p>
+                  <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p>
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button type="is-primary"
+                            @click="preferEmailSave()"
+                            :disabled="preferEmailSaving"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }}
+                  </b-button>
+                  <b-button @click="preferEmailShowDialog = false">
+                    Cancel
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
+
+        % endif
+      </div>
+    </div>
+  </div>
+</%def>
+
+<%def name="render_personal_tab_cards()">
+  ${self.render_personal_name_card()}
+  ${self.render_personal_address_card()}
+  ${self.render_personal_phone_card()}
+  ${self.render_personal_email_card()}
+</%def>
+
+<%def name="render_personal_tab_template()">
+  <script type="text/x-template" id="personal-tab-template">
     <div style="display: flex; justify-content: space-between;">
 
-      <div>
-
-        <div class="field-wrapper first_name">
-          <div class="field-row">
-            <label>First Name</label>
-            <div class="field">
-              ${person.first_name}
-            </div>
-          </div>
-        </div>
-
-        <div class="field-wrapper middle_name">
-          <div class="field-row">
-            <label>Middle Name</label>
-            <div class="field">
-              ${person.middle_name}
-            </div>
-          </div>
-        </div>
-
-        <div class="field-wrapper last_name">
-          <div class="field-row">
-            <label>Last Name</label>
-            <div class="field">
-              ${person.last_name}
-            </div>
-          </div>
-        </div>
-
-        <div class="field-wrapper street">
-          <div class="field-row">
-            <label>Street 1</label>
-            <div class="field">
-              ${person.address.street if person.address else ''}
-            </div>
-          </div>
-        </div>
-
-        <div class="field-wrapper street2">
-          <div class="field-row">
-            <label>Street 2</label>
-            <div class="field">
-              ${person.address.street2 if person.address else ''}
-            </div>
-          </div>
-        </div>
-
-        <div class="field-wrapper city">
-          <div class="field-row">
-            <label>City</label>
-            <div class="field">
-              ${person.address.city if person.address else ''}
-            </div>
-          </div>
-        </div>
-
-        <div class="field-wrapper state">
-          <div class="field-row">
-            <label>State</label>
-            <div class="field">
-              ${person.address.state if person.address else ''}
-            </div>
-          </div>
-        </div>
-
-        <div class="field-wrapper zipcode">
-          <div class="field-row">
-            <label>Zipcode</label>
-            <div class="field">
-              ${person.address.zipcode if person.address else ''}
-            </div>
-          </div>
-        </div>
-
-        % if person.phones:
-            % for phone in person.phones:
-                <div class="field-wrapper">
-                  <div class="field-row">
-                    <label>Phone Number</label>
-                    <div class="field">
-                      ${phone.number} (type: ${phone.type})
-                    </div>
-                  </div>
-                </div>
-            % endfor
-        % else:
-            <div class="field-wrapper">
-              <div class="field-row">
-                <label>Phone Number</label>
-                <div class="field">
-                  (none on file)
-                </div>
-              </div>
-            </div>
-        % endif
-
-        % if person.emails:
-            % for email in person.emails:
-                <div class="field-wrapper">
-                  <div class="field-row">
-                    <label>Email Address</label>
-                    <div class="field">
-                      ${email.address} (type: ${email.type})
-                    </div>
-                  </div>
-                </div>
-            % endfor
-        % else:
-            <div class="field-wrapper">
-              <div class="field-row">
-                <label>Email Address</label>
-                <div class="field">
-                  (none on file)
-                </div>
-              </div>
-            </div>
-        % endif
-
+      <div style="flex-grow: 1; margin-right: 1rem;">
+        ${self.render_personal_tab_cards()}
       </div>
 
       <div>
         % if request.has_perm('people.view'):
-            ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')}
+            <b-button tag="a" :href="person.view_url">
+              View Person
+            </b-button>
+        % endif
+      </div>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
+    </div>
+  </script>
+</%def>
+
+<%def name="render_personal_tab()">
+  <${b}-tab-item label="Personal"
+                 value="personal"
+                 % if not request.use_oruga:
+                     icon-pack="fas"
+                 % endif
+                 :icon="tabchecks.personal ? 'check' : null">
+    <personal-tab ref="tab_personal"
+                  :person="person"
+                  @profile-changed="profileChanged"
+                  :phone-type-options="phoneTypeOptions"
+                  :email-type-options="emailTypeOptions"
+                  :max-lengths="maxLengths">
+    </personal-tab>
+  </${b}-tab-item>
+</%def>
+
+% if expose_members:
+<%def name="render_member_tab_template()">
+  <script type="text/x-template" id="member-tab-template">
+    <div>
+      % if max_one_member:
+          <p class="block">
+            TODO: UI not yet implemented for "max one member per person"
+          </p
+
+      % else:
+          ## nb. multiple members allowed per person
+          <div v-if="members.length">
+
+            <div style="display: flex; justify-content: space-between;">
+              <p>{{ person.display_name }} has <strong>{{ members.length }}</strong> member account{{ members.length == 1 ? '' : 's' }}</p>
+            </div>
+
+            <br />
+            <${b}-collapse v-for="member in members"
+                           :key="member.uuid"
+                           class="panel"
+                           :open="members.length == 1">
+
+              <template #trigger="props">
+                <div class="panel-heading"
+                     role="button"
+                     style="cursor: pointer;">
+
+                  ## TODO: for some reason buefy will "reuse" the icon
+                  ## element in such a way that its display does not
+                  ## refresh.  so to work around that, we use different
+                  ## structure for the two icons, so buefy is forced to
+                  ## re-draw
+
+                  <b-icon v-if="props.open"
+                          pack="fas"
+                          icon="caret-down" />
+
+                  <span v-if="!props.open">
+                    <b-icon pack="fas"
+                            icon="caret-right" />
+                  </span>
+
+                  &nbsp;
+                  <strong>{{ member._key }} - {{ member.display }}</strong>
+                </div>
+              </template>
+
+              <div class="panel-block">
+                <div style="display: flex; justify-content: space-between; width: 100%;">
+                  <div style="flex-grow: 1;">
+
+                    <b-field horizontal label="${member_key_label}">
+                      {{ member._key }}
+                    </b-field>
+
+                    <b-field horizontal label="Account Holder">
+                      <a v-if="member.person_uuid != person.uuid"
+                         :href="member.view_profile_url">
+                        {{ member.person_display_name }}
+                      </a>
+                      <span v-if="member.person_uuid == person.uuid">
+                        {{ member.person_display_name }}
+                      </span>
+                    </b-field>
+
+                    <b-field horizontal label="Membership Type">
+                      <a v-if="member.view_membership_type_url"
+                         :href="member.view_membership_type_url">
+                        {{ member.membership_type_name }}
+                      </a>
+                      <span v-if="!member.view_membership_type_url">
+                        {{ member.membership_type_name }}
+                      </span>
+                    </b-field>
+
+                    <b-field horizontal label="Active">
+                      {{ member.active ? "Yes" : "No" }}
+                    </b-field>
+
+                    <b-field horizontal label="Joined">
+                      {{ member.joined }}
+                    </b-field>
+
+                    <b-field horizontal label="Withdrew"
+                             v-if="member.withdrew">
+                      {{ member.withdrew }}
+                    </b-field>
+
+                    <b-field horizontal label="Equity Total">
+                      {{ member.equity_total_display }}
+                    </b-field>
+
+                  </div>
+                  <div class="buttons" style="align-items: start;">
+
+                    <b-button v-for="link in member.external_links"
+                              :key="link.url"
+                              type="is-primary"
+                              tag="a" :href="link.url" target="_blank"
+                              icon-pack="fas"
+                              icon-left="external-link-alt">
+                      {{ link.label }}
+                    </b-button>
+
+                    % if request.has_perm('members.view'):
+                        <b-button tag="a" :href="member.view_url">
+                          View Member
+                        </b-button>
+                    % endif
+
+                  </div>
+                </div>
+              </div>
+            </${b}-collapse>
+          </div>
+
+          <div v-if="!members.length">
+            <p>{{ person.display_name }} does not have a member account.</p>
+          </div>
+      % endif
+
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
+    </div>
+  </script>
+</%def>
+
+<%def name="render_member_tab()">
+  <${b}-tab-item label="Member"
+              value="member"
+              icon-pack="fas"
+              :icon="tabchecks.member ? 'check' : null">
+    <member-tab ref="tab_member"
+                :person="person"
+                @profile-changed="profileChanged"
+                :phone-type-options="phoneTypeOptions">
+    </member-tab>
+  </${b}-tab-item>
+</%def>
+% endif
+
+<%def name="render_customer_tab_template()">
+  <script type="text/x-template" id="customer-tab-template">
+    <div>
+      <div v-if="customers.length">
+
+        <div style="display: flex; justify-content: space-between;">
+          <p>{{ person.display_name }} has <strong>{{ customers.length }}</strong> customer account{{ customers.length == 1 ? '' : 's' }}</p>
+        </div>
+
+        <br />
+        <${b}-collapse v-for="customer in customers"
+                       :key="customer.uuid"
+                       class="panel"
+                       :open="customers.length == 1">
+
+          <template #trigger="props">
+            <div class="panel-heading"
+                 role="button"
+                 style="cursor: pointer;">
+
+              ## TODO: for some reason buefy will "reuse" the icon
+              ## element in such a way that its display does not
+              ## refresh.  so to work around that, we use different
+              ## structure for the two icons, so buefy is forced to
+              ## re-draw
+
+              <b-icon v-if="props.open"
+                      pack="fas"
+                      icon="caret-down" />
+
+              <span v-if="!props.open">
+                <b-icon pack="fas"
+                        icon="caret-right" />
+              </span>
+
+              &nbsp;
+              <strong>{{ customer._key }} - {{ customer.name }}</strong>
+            </div>
+          </template>
+
+          <div class="panel-block">
+            <div style="display: flex; justify-content: space-between; width: 100%;">
+              <div style="flex-grow: 1;">
+
+                <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}">
+                  {{ customer._key }}
+                </b-field>
+
+                <b-field horizontal label="Account Name">
+                  {{ customer.name }}
+                </b-field>
+
+                % if expose_customer_shoppers:
+                    <b-field horizontal label="Shoppers">
+                      <ul>
+                        <li v-for="shopper in customer.shoppers"
+                            :key="shopper.uuid">
+                          <a v-if="shopper.person_uuid != person.uuid"
+                             :href="shopper.view_profile_url">
+                            {{ shopper.display_name }}
+                          </a>
+                          <span v-if="shopper.person_uuid == person.uuid">
+                            {{ shopper.display_name }}
+                          </span>
+                        </li>
+                      </ul>
+                    </b-field>
+                % endif
+
+                % if expose_customer_people:
+                    <b-field horizontal label="People">
+                      <ul>
+                        <li v-for="p in customer.people"
+                            :key="p.uuid">
+                          <a v-if="p.uuid != person.uuid"
+                             :href="p.view_profile_url">
+                            {{ p.display_name }}
+                          </a>
+                          <span v-if="p.uuid == person.uuid">
+                            {{ p.display_name }}
+                          </span>
+                        </li>
+                      </ul>
+                    </b-field>
+                % endif
+
+                <b-field horizontal label="Address"
+                         v-for="address in customer.addresses"
+                         :key="address.uuid">
+                  {{ address.display }}
+                </b-field>
+
+              </div>
+              <div class="buttons" style="align-items: start;">
+
+                <b-button v-for="link in customer.external_links"
+                          :key="link.url"
+                          type="is-primary"
+                          tag="a" :href="link.url" target="_blank"
+                          icon-pack="fas"
+                          icon-left="external-link-alt">
+                  {{ link.label }}
+                </b-button>
+
+                % if request.has_perm('customers.view'):
+                    <b-button tag="a" :href="customer.view_url">
+                      View Customer
+                    </b-button>
+                % endif
+
+              </div>
+            </div>
+          </div>
+        </${b}-collapse>
+      </div>
+
+      <div v-if="!customers.length">
+        <p>{{ person.display_name }} does not have a customer account.</p>
+      </div>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
+    </div>
+  </script>
+</%def>
+
+<%def name="render_customer_tab()">
+  <${b}-tab-item label="Customer"
+              value="customer"
+              icon-pack="fas"
+              :icon="tabchecks.customer ? 'check' : null">
+    <customer-tab ref="tab_customer"
+                  :person="person"
+                  @profile-changed="profileChanged">
+    </customer-tab>
+  </${b}-tab-item>
+</%def>
+
+<%def name="render_shopper_tab_template()">
+  <script type="text/x-template" id="shopper-tab-template">
+    <div>
+      <div v-if="shoppers.length">
+
+        <div style="display: flex; justify-content: space-between;">
+          <p>{{ person.display_name }} is shopper for <strong>{{ shoppers.length }}</strong> customer account{{ shoppers.length == 1 ? '' : 's' }}</p>
+        </div>
+
+        <br />
+        <b-collapse v-for="shopper in shoppers"
+                    :key="shopper.uuid"
+                    class="panel"
+                    :open="shoppers.length == 1">
+
+          <div slot="trigger"
+               slot-scope="props"
+               class="panel-heading"
+               role="button">
+            <b-icon pack="fas"
+                    icon="caret-right">
+            </b-icon>
+            <strong>{{ shopper.customer_key }} - {{ shopper.customer_name }}</strong>
+          </div>
+
+          <div class="panel-block">
+            <div style="display: flex; justify-content: space-between; width: 100%;">
+              <div style="flex-grow: 1;">
+
+                <b-field horizontal label="${customer_key_label}">
+                  {{ shopper.customer_key }}
+                </b-field>
+
+                <b-field horizontal label="Account Name">
+                  {{ shopper.customer_name }}
+                </b-field>
+
+                <b-field horizontal label="Account Holder">
+                  <span v-if="!shopper.account_holder_view_profile_url">
+                    {{ shopper.account_holder_name }}
+                  </span>
+                  <a v-if="shopper.account_holder_view_profile_url"
+                     :href="shopper.account_holder_view_profile_url">
+                    {{ shopper.account_holder_name }}
+                  </a>
+                </b-field>
+
+              </div>
+  ##             <div class="buttons" style="align-items: start;">
+  ##               ${self.render_shopper_panel_buttons(shopper)}
+  ##             </div>
+            </div>
+          </div>
+        </b-collapse>
+      </div>
+
+      <div v-if="!shoppers.length">
+        <p>{{ person.display_name }} is not a shopper.</p>
+      </div>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
+    </div>
+  </script>
+</%def>
+
+<%def name="render_shopper_tab()">
+  <${b}-tab-item label="Shopper"
+              value="shopper"
+              icon-pack="fas"
+              :icon="tabchecks.shopper ? 'check' : null">
+    <shopper-tab ref="tab_shopper"
+                 :person="person"
+                 @profile-changed="profileChanged">
+    </shopper-tab>
+  </${b}-tab-item>
+</%def>
+
+<%def name="render_employee_tab_template()">
+  <script type="text/x-template" id="employee-tab-template">
+    <div>
+      <div style="display: flex; justify-content: space-between;">
+
+        <div style="flex-grow: 1;">
+
+          <div v-if="employee.uuid">
+
+            <div :key="refreshEmployeeCard">
+              <b-field horizontal label="Employee ID">
+                <div class="level">
+                  <div class="level-left">
+                    <div class="level-item">
+                      <span>{{ employee.id }}</span>
+                    </div>
+                    % if request.has_perm('employees.edit'):
+                    <div class="level-item">
+                      <b-button type="is-primary"
+                                icon-pack="fas"
+                                icon-left="edit"
+                                @click="editEmployeeIdInit()">
+                        Edit ID
+                      </b-button>
+                      <${b}-modal has-modal-card
+                                  % if request.use_oruga:
+                                  v-model:active="editEmployeeIdShowDialog"
+                                  % else:
+                                  :active.sync="editEmployeeIdShowDialog"
+                                  % endif
+                                  >
+                        <div class="modal-card">
+
+                          <header class="modal-card-head">
+                            <p class="modal-card-title">Employee ID</p>
+                          </header>
+
+                          <section class="modal-card-body">
+                            <b-field label="Employee ID">
+                              <b-input v-model="editEmployeeIdValue"></b-input>
+                            </b-field>
+                          </section>
+
+                          <footer class="modal-card-foot">
+                            <b-button @click="editEmployeeIdShowDialog = false">
+                              Cancel
+                            </b-button>
+                            <b-button type="is-primary"
+                                      icon-pack="fas"
+                                      icon-left="save"
+                                      :disabled="editEmployeeIdSaving"
+                                      @click="editEmployeeIdSave()">
+                              {{ editEmployeeIdSaving ? "Working, please wait..." : "Save" }}
+                            </b-button>
+                          </footer>
+                        </div>
+                      </${b}-modal>
+                    </div>
+                    % endif
+                  </div>
+                </div>
+              </b-field>
+
+              <b-field horizontal label="Employee Status">
+                <span>{{ employee.status_display }}</span>
+              </b-field>
+
+              <b-field horizontal label="Start Date">
+                <span>{{ employee.start_date }}</span>
+              </b-field>
+
+              <b-field horizontal label="End Date">
+                <span>{{ employee.end_date }}</span>
+              </b-field>
+            </div>
+
+            <br />
+            <p><strong>Employee History</strong></p>
+            <br />
+
+            <${b}-table :data="employeeHistory">
+
+              <${b}-table-column field="start_date"
+                              label="Start Date"
+                              v-slot="props">
+                {{ props.row.start_date }}
+              </${b}-table-column>
+
+              <${b}-table-column field="end_date"
+                              label="End Date"
+                              v-slot="props">
+                {{ props.row.end_date }}
+              </${b}-table-column>
+
+              % if request.has_perm('people_profile.edit_employee_history'):
+                  <${b}-table-column field="actions"
+                                  label="Actions"
+                                  v-slot="props">
+                    <a href="#" @click.prevent="editEmployeeHistoryInit(props.row)">
+                      % if request.use_oruga:
+                          <span class="icon-text">
+                            <o-icon icon="edit" />
+                            <span>Edit</span>
+                          </span>
+                      % else:
+                          <i class="fas fa-edit"></i>
+                          Edit
+                      % endif
+                    </a>
+                  </${b}-table-column>
+              % endif
+
+            </${b}-table>
+
+          </div>
+
+          <p v-if="!employee.uuid">
+            ${person} is not an employee.
+          </p>
+
+        </div>
+
+        <div style="display: flex; gap: 0.75rem;">
+
+          % if request.has_perm('people_profile.toggle_employee'):
+
+              <b-button v-if="!employee.current"
+                        type="is-primary"
+                        @click="startEmployeeInit()">
+                ${person} is now an Employee
+              </b-button>
+
+              <b-button v-if="employee.current"
+                        type="is-primary"
+                        @click="stopEmployeeInit()">
+                ${person} is no longer an Employee
+              </b-button>
+
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="startEmployeeShowDialog"
+                          % else:
+                              :active.sync="startEmployeeShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Employee Start</p>
+                  </header>
+
+                  <section class="modal-card-body">
+                    <b-field label="Employee Number">
+                      <b-input v-model="startEmployeeID"></b-input>
+                    </b-field>
+                    <b-field label="Start Date">
+                      <tailbone-datepicker v-model="startEmployeeStartDate"
+                                           ref="startEmployeeStartDate" />
+                    </b-field>
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button @click="startEmployeeShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="startEmployeeSave()"
+                              :disabled="startEmployeeSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ startEmployeeSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="stopEmployeeShowDialog"
+                          % else:
+                              :active.sync="stopEmployeeShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Employee End</p>
+                  </header>
+
+                  <section class="modal-card-body">
+                    <b-field label="End Date"
+                             :type="stopEmployeeEndDate ? null : 'is-danger'">
+                      <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker>
+                    </b-field>
+                    <b-field label="Revoke Internal App Access">
+                      <b-checkbox v-model="stopEmployeeRevokeAccess">
+                      </b-checkbox>
+                    </b-field>
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button @click="stopEmployeeShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="stopEmployeeSave()"
+                              :disabled="stopEmployeeSaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
+
+          % if request.has_perm('people_profile.edit_employee_history'):
+              <${b}-modal has-modal-card
+                          % if request.use_oruga:
+                              v-model:active="editEmployeeHistoryShowDialog"
+                          % else:
+                              :active.sync="editEmployeeHistoryShowDialog"
+                          % endif
+                          >
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Edit Employee History</p>
+                  </header>
+
+                  <section class="modal-card-body">
+                    <b-field label="Start Date">
+                      <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker>
+                    </b-field>
+                    <b-field label="End Date">
+                      <tailbone-datepicker v-model="editEmployeeHistoryEndDate"
+                                           :disabled="!editEmployeeHistoryEndDateRequired">
+                      </tailbone-datepicker>
+                    </b-field>
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button @click="editEmployeeHistoryShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="editEmployeeHistorySave()"
+                              :disabled="editEmployeeHistorySaveDisabled"
+                              icon-pack="fas"
+                              icon-left="save">
+                      {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }}
+                    </b-button>
+                  </footer>
+                </div>
+              </${b}-modal>
+          % endif
+
+          <div style="display: flex; flex-direction: column; align-items: right; gap: 0.75rem;">
+
+            <b-button v-for="link in employee.external_links"
+                      :key="link.url"
+                      type="is-primary"
+                      tag="a" :href="link.url" target="_blank"
+                      icon-pack="fas"
+                      icon-left="external-link-alt">
+              {{ link.label }}
+            </b-button>
+
+            % if request.has_perm('employees.view'):
+                <b-button v-if="employee.view_url"
+                          tag="a" :href="employee.view_url">
+                  View Employee
+                </b-button>
+            % endif
+
+          </div>
+        </div>
+
+      </div>
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
+    </div>
+  </script>
+</%def>
+
+<%def name="render_employee_tab()">
+  <${b}-tab-item label="Employee"
+              value="employee"
+              icon-pack="fas"
+              :icon="tabchecks.employee ? 'check' : null">
+    <employee-tab ref="tab_employee"
+                  :person="person"
+                  @profile-changed="profileChanged">
+    </employee-tab>
+  </${b}-tab-item>
+</%def>
+
+<%def name="render_notes_tab_template()">
+  <script type="text/x-template" id="notes-tab-template">
+    <div>
+
+      % if request.has_perm('people_profile.add_note'):
+          <b-button type="is-primary"
+                    class="control"
+                    @click="addNoteInit()"
+                    icon-pack="fas"
+                    icon-left="plus">
+            Add Note
+          </b-button>
+      % endif
+
+      <${b}-table :data="notes">
+
+        <${b}-table-column field="note_type"
+                        label="Type"
+                        v-slot="props">
+          {{ props.row.note_type_display }}
+        </${b}-table-column>
+
+        <${b}-table-column field="subject"
+                        label="Subject"
+                        v-slot="props">
+          {{ props.row.subject }}
+        </${b}-table-column>
+
+        <${b}-table-column field="text"
+                        label="Text"
+                        v-slot="props">
+          {{ props.row.text }}
+        </${b}-table-column>
+
+        <${b}-table-column field="created"
+                        label="Created"
+                        v-slot="props">
+          <span v-html="props.row.created_display"></span>
+        </${b}-table-column>
+
+        <${b}-table-column field="created_by"
+                        label="Created By"
+                        v-slot="props">
+          {{ props.row.created_by_display }}
+        </${b}-table-column>
+
+        % if request.has_any_perm('people_profile.edit_note', 'people_profile.delete_note'):
+            <${b}-table-column label="Actions"
+                            v-slot="props">
+              % if request.has_perm('people_profile.edit_note'):
+                  <a href="#" @click.prevent="editNoteInit(props.row)">
+                    % if request.use_oruga:
+                        <span class="icon-text">
+                          <o-icon icon="edit" />
+                          <span>Edit</span>
+                        </span>
+                    % else:
+                        <i class="fas fa-edit"></i>
+                        Edit
+                    % endif
+                  </a>
+              % endif
+              % if request.has_perm('people_profile.delete_note'):
+                  <a href="#" @click.prevent="deleteNoteInit(props.row)"
+                     class="has-text-danger">
+                    % if request.use_oruga:
+                        <span class="icon-text">
+                          <o-icon icon="trash" />
+                          <span>Delete</span>
+                        </span>
+                    % else:
+                        <i class="fas fa-trash"></i>
+                        Delete
+                    % endif
+
+                  </a>
+              % endif
+            </${b}-table-column>
+        % endif
+
+      </${b}-table>
+
+      % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'):
+          <${b}-modal has-modal-card
+                      % if request.use_oruga:
+                          v-model:active="editNoteShowDialog"
+                      % else:
+                          :active.sync="editNoteShowDialog"
+                      % endif
+                      >
+
+            <div class="modal-card">
+
+              <header class="modal-card-head">
+                <p class="modal-card-title">
+                  {{ editNoteUUID ? (editNoteDelete ? "Delete" : "Edit") : "New" }} Note
+                </p>
+              </header>
+
+              <section class="modal-card-body">
+
+                <b-field label="Type"
+                         :type="!editNoteDelete && !editNoteType ? 'is-danger' : null">
+                  <b-select v-model="editNoteType"
+                            :disabled="editNoteUUID">
+                    <option v-for="option in noteTypeOptions"
+                            :key="option.value"
+                            :value="option.value">
+                      {{ option.label }}
+                    </option>
+                  </b-select>
+                </b-field>
+
+                <b-field label="Subject">
+                  <b-input v-model.trim="editNoteSubject"
+                           :disabled="editNoteDelete"
+                           expanded>
+                  </b-input>
+                </b-field>
+
+                <b-field label="Text">
+                  <b-input v-model.trim="editNoteText"
+                           type="textarea"
+                           :disabled="editNoteDelete"
+                           expanded>
+                  </b-input>
+                </b-field>
+
+                <b-notification v-if="editNoteDelete"
+                                type="is-danger"
+                                :closable="false">
+                  Are you sure you wish to delete this note?
+                </b-notification>
+
+              </section>
+
+              <footer class="modal-card-foot">
+                <b-button :type="editNoteDelete ? 'is-danger' : 'is-primary'"
+                          @click="editNoteSave()"
+                          :disabled="editNoteSaving || (!editNoteDelete && !editNoteType)"
+                          icon-pack="fas"
+                          icon-left="save">
+                  {{ editNoteSaving ? "Working..." : (editNoteDelete ? "Delete" : "Save") }}
+                </b-button>
+                <b-button @click="editNoteShowDialog = false">
+                  Cancel
+                </b-button>
+              </footer>
+
+            </div>
+          </${b}-modal>
+      % endif
+
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
+    </div>
+  </script>
+</%def>
+
+<%def name="render_notes_tab()">
+  <${b}-tab-item label="Notes"
+              value="notes"
+              icon-pack="fas"
+              :icon="tabchecks.notes ? 'check' : null">
+    <notes-tab ref="tab_notes"
+               :person="person"
+               @profile-changed="profileChanged">
+    </notes-tab>
+  </${b}-tab-item>
+</%def>
+
+% if expose_transactions:
+
+    <%def name="render_transactions_tab_template()">
+      <script type="text/x-template" id="transactions-tab-template">
+        <div>
+          <transactions-grid
+            ref="transactionsGrid"
+             />
+        </div>
+      </script>
+    </%def>
+
+    <%def name="render_transactions_tab()">
+      <${b}-tab-item label="Transactions"
+                     value="transactions"
+                     % if not request.use_oruga:
+                         icon-pack="fas"
+                     % endif
+                     icon="bars">
+        <transactions-tab ref="tab_transactions"
+                          :person="person"
+                          @profile-changed="profileChanged" />
+      </${b}-tab-item>
+    </%def>
+
+% endif
+
+
+<%def name="render_user_tab_template()">
+  <script type="text/x-template" id="user-tab-template">
+    <div>
+      <div v-if="users.length">
+
+        <p>{{ person.display_name }} has <strong>{{ users.length }}</strong> user account{{ users.length == 1 ? '' : 's' }}</p>
+        <br />
+        <div id="users-accordion">
+
+          <${b}-collapse v-for="user in users"
+                      :key="user.uuid"
+                      class="panel">
+
+            <template #trigger="props">
+              <div class="panel-heading"
+                   role="button"
+                   style="cursor: pointer;">
+
+                ## TODO: for some reason buefy will "reuse" the icon
+                ## element in such a way that its display does not
+                ## refresh.  so to work around that, we use different
+                ## structure for the two icons, so buefy is forced to
+                ## re-draw
+
+                <b-icon v-if="props.open"
+                        pack="fas"
+                        icon="caret-down" />
+
+                <span v-if="!props.open">
+                  <b-icon pack="fas"
+                          icon="caret-right" />
+                </span>
+
+                &nbsp;
+                <strong>{{ user.username }}</strong>
+              </div>
+            </template>
+
+            <div class="panel-block">
+              <div style="display: flex; justify-content: space-between; width: 100%;">
+
+                <div style="flex-grow: 1;">
+                  <b-field horizontal label="Username">
+                    {{ user.username }}
+                  </b-field>
+                  <b-field horizontal label="Active">
+                    {{ user.active ? "Yes" : "No" }}
+                  </b-field>
+                </div>
+
+                <div>
+                  % if request.has_perm('users.view'):
+                      <b-button tag="a" :href="user.view_url">
+                        View User
+                      </b-button>
+                  % endif
+                </div>
+
+              </div>
+            </div>
+          </${b}-collapse>
+        </div>
+      </div>
+
+      <div v-if="!users.length"
+           style="display: flex; justify-content: space-between;">
+
+        <p>{{ person.display_name }} does not have a user account.</p>
+
+        % if request.has_perm('users.create'):
+            <b-button type="primary"
+                      icon-pack="fas"
+                      icon-left="plus"
+                      @click="createUserInit()">
+              Create User
+            </b-button>
+
+            <${b}-modal has-modal-card
+                        % if request.use_oruga:
+                            v-model:active="createUserShowDialog"
+                        % else:
+                            :active.sync="createUserShowDialog"
+                        % endif
+                        >
+              <div class="modal-card">
+
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Create User</p>
+                </header>
+
+                <section class="modal-card-body">
+                  <b-field label="Person">
+                    <span>{{ person.display_name }}</span>
+                  </b-field>
+                  <b-field label="Username">
+                    <b-input v-model="createUserUsername"
+                             ref="username" />
+                  </b-field>
+                  <b-field label="Active">
+                    <b-checkbox v-model="createUserActive" />
+                  </b-field>
+                </section>
+
+                <footer class="modal-card-foot">
+                  <b-button @click="createUserShowDialog = false">
+                    Cancel
+                  </b-button>
+                  <b-button type="is-primary"
+                            @click="createUserSave()"
+                            :disabled="createUserSaveDisabled"
+                            icon-pack="fas"
+                            icon-left="save">
+                    {{ createUserSaving ? "Working, please wait..." : "Save" }}
+                  </b-button>
+                </footer>
+              </div>
+            </${b}-modal>
         % endif
       </div>
 
+      % if request.use_oruga:
+          <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading>
+      % else:
+          <b-loading :active.sync="refreshingTab" :is-full-page="false"></b-loading>
+      % endif
     </div>
-  </div><!-- personal-tab -->
+  </script>
+</%def>
 
-  <div id="customer-tab">
-    % if person.customers:
-        <p>${person} is associated with ${len(person.customers)} customer account(s)</p>
-        <br />
-        <div id="customers-accordion">
-          % for customer in person.customers:
-              <h3>${customer.id} - ${customer.name}</h3>
-              <div>
+<%def name="render_user_tab()">
+  <${b}-tab-item label="User"
+              value="user"
+              icon-pack="fas"
+              :icon="tabchecks.user ? 'check' : null">
+    <user-tab ref="tab_user"
+              :person="person"
+              @profile-changed="profileChanged">
+    </user-tab>
+  </${b}-tab-item>
+</%def>
+
+<%def name="render_profile_tabs()">
+  ${self.render_personal_tab()}
+
+  % if expose_members:
+      ${self.render_member_tab()}
+  % endif
+
+  ${self.render_customer_tab()}
+  % if expose_customer_shoppers:
+      ${self.render_shopper_tab()}
+  % endif
+  ${self.render_employee_tab()}
+  ${self.render_notes_tab()}
+  % if expose_transactions:
+      ${self.render_transactions_tab()}
+  % endif
+  ${self.render_user_tab()}
+</%def>
+
+<%def name="render_profile_info_extra_buttons()"></%def>
+
+<%def name="render_profile_info_template()">
+  <script type="text/x-template" id="profile-info-template">
+    <div>
+
+      ${self.render_profile_info_extra_buttons()}
+
+      <${b}-tabs v-model="activeTab"
+                 % if request.has_perm('people_profile.view_versions'):
+                     v-show="!viewingHistory"
+                 % endif
+                 % if request.use_oruga:
+                     type="boxed"
+                     @change="activeTabChanged"
+                 % else:
+                     type="is-boxed"
+                     @input="activeTabChanged"
+                 % endif
+                 >
+        ${self.render_profile_tabs()}
+      </${b}-tabs>
+
+      % if request.has_perm('people_profile.view_versions'):
+
+          ${revisions_grid.render_table_element(data_prop='revisions',
+                                                show_footer=True,
+                                                vshow='viewingHistory',
+                                                loading='gettingRevisions')|n}
+
+          <${b}-modal
+            % if request.use_oruga:
+                v-model:active="showingRevisionDialog"
+            % else:
+                :active.sync="showingRevisionDialog"
+            % endif
+            >
+
+            <div class="card">
+              <div class="card-content">
 
                 <div style="display: flex; justify-content: space-between;">
 
                   <div>
-
-                    <div class="field-wrapper id">
-                      <div class="field-row">
-                        <label>ID</label>
-                        <div class="field">
-                          ${customer.id or ''}
-                        </div>
-                      </div>
-                    </div>
-
-                    <div class="field-wrapper name">
-                      <div class="field-row">
-                        <label>Name</label>
-                        <div class="field">
-                          ${customer.name}
-                        </div>
-                      </div>
-                    </div>
-
-                    % if customer.phones:
-                        % for phone in customer.phones:
-                            <div class="field-wrapper">
-                              <div class="field-row">
-                                <label>Phone Number</label>
-                                <div class="field">
-                                  ${phone.number} (type: ${phone.type})
-                                </div>
-                              </div>
-                            </div>
-                        % endfor
-                    % else:
-                        <div class="field-wrapper">
-                          <div class="field-row">
-                            <label>Phone Number</label>
-                            <div class="field">
-                              (none on file)
-                            </div>
-                          </div>
-                        </div>
-                    % endif
-
-                    % if customer.emails:
-                        % for email in customer.emails:
-                            <div class="field-wrapper">
-                              <div class="field-row">
-                                <label>Email Address</label>
-                                <div class="field">
-                                  ${email.address} (type: ${email.type})
-                                </div>
-                              </div>
-                            </div>
-                        % endfor
-                    % else:
-                        <div class="field-wrapper">
-                          <div class="field-row">
-                            <label>Email Address</label>
-                            <div class="field">
-                              (none on file)
-                            </div>
-                          </div>
-                        </div>
-                    % endif
-
+                    <b-field horizontal label="Changed">
+                      <div v-html="revision.changed"></div>
+                    </b-field>
+                    <b-field horizontal label="Changed by">
+                      <div v-html="revision.changed_by"></div>
+                    </b-field>
+                    <b-field horizontal label="IP Address">
+                      <div v-html="revision.remote_addr"></div>
+                    </b-field>
+                    <b-field horizontal label="Comment">
+                      <div v-html="revision.comment"></div>
+                    </b-field>
+                    <b-field horizontal label="TXN ID">
+                      <div v-html="revision.txnid"></div>
+                    </b-field>
                   </div>
 
                   <div>
-                    % if request.has_perm('customers.view'):
-                        ${h.link_to("View Customer", url('customers.view', uuid=customer.uuid), class_='button')}
+                    <div>
+                      <b-button @click="viewPrevRevision()"
+                                :disabled="!revision.prev_txnid">
+                        &laquo; Prev
+                      </b-button>
+                      <b-button @click="viewNextRevision()"
+                                :disabled="!revision.next_txnid">
+                        &raquo; Next
+                      </b-button>
+                    </div>
+                    <br />
+                    <b-button @click="toggleVersionFields()">
+                      {{ revisionShowAllFields ? "Show Diffs Only" : "Show All Fields" }}
+                    </b-button>
+                  </div>
+
+                </div>
+
+                <br />
+
+                <div v-for="version in revision.versions"
+                     :key="version.key">
+
+                  <p class="block has-text-weight-bold">
+                    {{ version.model_title }}
+                  </p>
+
+                  <table class="diff monospace is-size-7"
+                         :class="version.diff_class">
+                    <thead>
+                      <tr>
+                        <th>field name</th>
+                        <th>old value</th>
+                        <th>new value</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr v-for="field in version.fields"
+                          :key="field"
+                          :class="{diff: version.values[field].after != version.values[field].before}"
+                          v-show="revisionShowAllFields || version.values[field].after != version.values[field].before">
+                        <td class="field">{{ field }}</td>
+                        <td class="old-value" v-html="version.values[field].before"></td>
+                        <td class="new-value" v-html="version.values[field].after"></td>
+                      </tr>
+                    </tbody>
+                  </table>
+
+                  <br />
+                </div>
+
+              </div>
+            </div>
+          </${b}-modal>
+      % endif
+
+    </div>
+  </script>
+  <script>
+
+    let ProfileInfoData = {
+        activeTab: location.hash ? location.hash.substring(1) : 'personal',
+        tabchecks: ${json.dumps(tabchecks or {})|n},
+        today: '${rattail_app.today()}',
+        profileLastChanged: Date.now(),
+        person: ${json.dumps(person_data or {})|n},
+        phoneTypeOptions: ${json.dumps(phone_type_options or [])|n},
+        emailTypeOptions: ${json.dumps(email_type_options or [])|n},
+        maxLengths: ${json.dumps(max_lengths or {})|n},
+
+        % if request.has_perm('people_profile.view_versions'):
+            loadingRevisions: false,
+            showingRevisionDialog: false,
+            revision: {},
+            revisionShowAllFields: false,
+        % endif
+    }
+
+    let ProfileInfo = {
+        template: '#profile-info-template',
+        props: {
+            % if request.has_perm('people_profile.view_versions'):
+                viewingHistory: Boolean,
+                gettingRevisions: Boolean,
+                revisions: Array,
+                revisionVersionMap: null,
+            % endif
+        },
+        computed: {},
+        mounted() {
+
+            // auto-refresh whichever tab is shown first
+            ## TODO: how to not assume 'personal' is the default tab?
+            let tab = this.$refs['tab_' + (this.activeTab || 'personal')]
+            if (tab && tab.refreshTab) {
+                tab.refreshTab()
+            }
+        },
+        methods: {
+
+            profileChanged(data) {
+                this.$emit('change-content-title', data.person.dynamic_content_title)
+                this.person = data.person
+                this.tabchecks = data.tabchecks
+                this.profileLastChanged = Date.now()
+            },
+
+            activeTabChanged(value) {
+                location.hash = value
+                this.refreshTabIfNeeded(value)
+                this.activeTabChangedExtra(value)
+            },
+
+            refreshTabIfNeeded(key) {
+                // TODO: this is *always* refreshing, should be more selective (?)
+                let tab = this.$refs['tab_' + key]
+                if (tab && tab.refreshIfNeeded) {
+                    tab.refreshIfNeeded(this.profileLastChanged)
+                }
+            },
+
+            activeTabChangedExtra(value) {},
+
+            % if request.has_perm('people_profile.view_versions'):
+
+                viewRevision(row) {
+                    this.revision = this.revisionVersionMap[row.txnid]
+                    this.showingRevisionDialog = true
+                },
+
+                viewPrevRevision() {
+                    let txnid = this.revision.prev_txnid
+                    this.revision = this.revisionVersionMap[txnid]
+                },
+
+                viewNextRevision() {
+                    let txnid = this.revision.next_txnid
+                    this.revision = this.revisionVersionMap[txnid]
+                },
+
+                toggleVersionFields() {
+                    this.revisionShowAllFields = !this.revisionShowAllFields
+                },
+
+            % endif
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="declare_personal_tab_vars()">
+  <script type="text/javascript">
+
+    let PersonalTabData = {
+        % if hasattr(master, 'profile_tab_personal'):
+        refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}',
+        % endif
+
+        // nb. hack to force refresh for vue3
+        refreshPersonalCard: 1,
+        refreshAddressCard: 1,
+
+        % if request.has_perm('people_profile.edit_person'):
+            editNameShowDialog: false,
+            editNameFirst: null,
+            % if use_preferred_first_name:
+                editNameFirstPreferred: null,
+            % endif
+            editNameMiddle: null,
+            editNameLast: null,
+            editNameSaving: false,
+
+            editAddressShowDialog: false,
+            editAddressStreet1: null,
+            editAddressStreet2: null,
+            editAddressCity: null,
+            editAddressState: null,
+            editAddressZipcode: null,
+            editAddressInvalid: false,
+
+            editPhoneShowDialog: false,
+            editPhoneUUID: null,
+            editPhoneType: null,
+            editPhoneNumber: null,
+            editPhonePreferred: false,
+            editPhoneSaving: false,
+
+            deletePhoneShowDialog: false,
+            deletePhoneUUID: null,
+            deletePhoneNumber: null,
+            deletePhoneSaving: false,
+
+            preferPhoneShowDialog: false,
+            preferPhoneUUID: null,
+            preferPhoneNumber: null,
+            preferPhoneSaving: false,
+
+            editEmailShowDialog: false,
+            editEmailUUID: null,
+            editEmailType: null,
+            editEmailAddress: null,
+            editEmailPreferred: null,
+            editEmailInvalid: false,
+            editEmailSaving: false,
+
+            deleteEmailShowDialog: false,
+            deleteEmailUUID: null,
+            deleteEmailAddress: null,
+            deleteEmailSaving: false,
+
+            preferEmailShowDialog: false,
+            preferEmailUUID: null,
+            preferEmailAddress: null,
+            preferEmailSaving: false,
+
+        % endif
+    }
+
+    let PersonalTab = {
+        template: '#personal-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+            phoneTypeOptions: Array,
+            emailTypeOptions: Array,
+            maxLengths: Object,
+        },
+        computed: {
+
+            % if request.has_perm('people_profile.edit_person'):
+
+                editNameSaveDisabled: function() {
+                    if (this.editNameSaving) {
+                        return true
+                    }
+                    if (!this.editNameFirst || !this.editNameLast) {
+                        return true
+                    }
+                    return false
+                },
+
+                editAddressSaveDisabled: function() {
+                    // TODO: should require anything here?
+                    return false
+                },
+
+                editPhoneSaveDisabled: function() {
+                    if (this.editPhoneSaving) {
+                        return true
+                    }
+                    if (!this.editPhoneType) {
+                        return true
+                    }
+                    if (!this.editPhoneNumber) {
+                        return true
+                    }
+                    return false
+                },
+
+                editEmailSaveDisabled: function() {
+                    if (this.editEmailSaving) {
+                        return true
+                    }
+                    if (!this.editEmailType) {
+                        return true
+                    }
+                    if (!this.editEmailAddress) {
+                        return true
+                    }
+                    return false
+                },
+
+            % endif
+        },
+        methods: {
+
+            // refreshTabSuccess(response) {},
+
+            % if request.has_perm('people_profile.edit_person'):
+
+                editNameAllowed() {
+                    return true
+                },
+
+                editNameInit() {
+                    this.editNameFirst = this.person.first_name
+                    % if use_preferred_first_name:
+                        this.editNameFirstPreferred = this.person.preferred_first_name
                     % endif
-                  </div>
+                    this.editNameMiddle = this.person.middle_name
+                    this.editNameLast = this.person.last_name
+                    this.editNameShowDialog = true
+                },
 
-                </div>
+                editNameSave() {
+                    this.editNameSaving = true
+                    let url = '${url('people.profile_edit_name', uuid=person.uuid)}'
+                    let params = {
+                        first_name: this.editNameFirst,
+                        % if use_preferred_first_name:
+                            preferred_first_name: this.editNameFirstPreferred,
+                        % endif
+                        middle_name: this.editNameMiddle,
+                        last_name: this.editNameLast,
+                    }
 
-              </div>
-          % endfor
-        </div>
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editNameShowDialog = false
+                        this.refreshTab()
+                        this.editNameSaving = false
+                        // nb. hack to force refresh for vue3
+                        this.refreshPersonalCard += 1
+                    }, response => {
+                        this.editNameSaving = false
+                    })
+                },
 
-    % else:
-        <p>${person} has never been a customer.</p>
-    % endif
-  </div><!-- customer-tab -->
+                editAddressInit() {
+                    let address = this.person.address
+                    this.editAddressStreet1 = address ? address.street : null
+                    this.editAddressStreet2 = address ? address.street2 : null
+                    this.editAddressCity = address ? address.city : null
+                    this.editAddressState = address ? address.state : null
+                    this.editAddressZipcode = address ? address.zipcode : null
+                    this.editAddressInvalid = address ? address.invalid : false
+                    this.editAddressShowDialog = true
+                },
 
-  <div id="employee-tab">
-    % if employee:
-        <div style="display: flex; justify-content: space-between;">
+                editAddressSave() {
+                    let url = '${url('people.profile_edit_address', uuid=person.uuid)}'
+                    let params = {
+                        street: this.editAddressStreet1,
+                        street2: this.editAddressStreet2,
+                        city: this.editAddressCity,
+                        state: this.editAddressState,
+                        zipcode: this.editAddressZipcode,
+                        invalid: this.editAddressInvalid,
+                    }
 
-          <div>
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editAddressShowDialog = false
+                        this.refreshTab()
+                        // nb. hack to force refresh for vue3
+                        this.refreshAddressCard += 1
+                    })
+                },
 
-            <div class="field-wrapper id">
-              <div class="field-row">
-                <label>ID</label>
-                <div class="field">
-                  ${employee.id or ''}
-                </div>
-              </div>
-            </div>
+                addPhoneInit() {
+                    this.editPhoneInit({
+                        uuid: null,
+                        type: 'Home',
+                        number: null,
+                        preferred: false,
+                    })
+                },
 
-            <div class="field-wrapper display_name">
-              <div class="field-row">
-                <label>Display Name</label>
-                <div class="field">
-                  ${employee.display_name or ''}
-                </div>
-              </div>
-            </div>
+                editPhoneInit(phone) {
+                    this.editPhoneUUID = phone.uuid
+                    this.editPhoneType = phone.type
+                    this.editPhoneNumber = phone.number
+                    this.editPhonePreferred = phone.preferred
+                    this.editPhoneShowDialog = true
+                    this.$nextTick(function() {
+                        this.$refs.editPhoneInput.focus()
+                    })
+                },
 
-            <div class="field-wrapper status">
-              <div class="field-row">
-                <label>Status</label>
-                <div class="field">
-                  ${enum.EMPLOYEE_STATUS.get(employee.status, '')}
-                </div>
-              </div>
-            </div>
+                editPhoneSave() {
+                    this.editPhoneSaving = true
+
+                    let url
+                    let params = {
+                        phone_number: this.editPhoneNumber,
+                        phone_type: this.editPhoneType,
+                        phone_preferred: this.editPhonePreferred,
+                    }
+
+                    // nb. create or update
+                    if (this.editPhoneUUID) {
+                        url = '${url('people.profile_update_phone', uuid=person.uuid)}'
+                        params.phone_uuid = this.editPhoneUUID
+                    } else {
+                        url = '${url('people.profile_add_phone', uuid=person.uuid)}'
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editPhoneShowDialog = false
+                        this.editPhoneSaving = false
+                        this.refreshTab()
+                    }, response => {
+                        this.editPhoneSaving = false
+                    })
+                },
+
+                deletePhoneInit(phone) {
+                    this.deletePhoneUUID = phone.uuid
+                    this.deletePhoneNumber = phone.number
+                    this.deletePhoneShowDialog = true
+                },
+
+                deletePhoneSave() {
+                    this.deletePhoneSaving = true
+                    let url = '${url('people.profile_delete_phone', uuid=person.uuid)}'
+                    let params = {
+                        phone_uuid: this.deletePhoneUUID,
+                    }
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
+                        this.deletePhoneShowDialog = false
+                        this.deletePhoneSaving = false
+                    }, response => {
+                        this.deletePhoneSaving = false
+                    })
+                },
+
+                preferPhoneInit(phone) {
+                    this.preferPhoneUUID = phone.uuid
+                    this.preferPhoneNumber = phone.number
+                    this.preferPhoneShowDialog = true
+                },
+
+                preferPhoneSave() {
+                    this.preferPhoneSaving = true
+                    let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}'
+                    let params = {
+                        phone_uuid: this.preferPhoneUUID,
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
+                        this.preferPhoneShowDialog = false
+                        this.preferPhoneSaving = false
+                    }, response => {
+                        this.preferPhoneSaving = false
+                    })
+                },
+
+                addEmailInit() {
+                    this.editEmailInit({
+                        uuid: null,
+                        type: 'Home',
+                        address: null,
+                        invalid: false,
+                        preferred: false,
+                    })
+                },
+
+                editEmailInit(email) {
+                    this.editEmailUUID = email.uuid
+                    this.editEmailType = email.type
+                    this.editEmailAddress = email.address
+                    this.editEmailInvalid = email.invalid
+                    this.editEmailPreferred = email.preferred
+                    this.editEmailShowDialog = true
+                    this.$nextTick(function() {
+                        this.$refs.editEmailInput.focus()
+                    })
+                },
+
+                editEmailSave() {
+                    this.editEmailSaving = true
+
+                    let url = null
+                    let params = {
+                        email_address: this.editEmailAddress,
+                        email_type: this.editEmailType,
+                    }
+
+                    if (this.editEmailUUID) {
+                        url = '${url('people.profile_update_email', uuid=person.uuid)}'
+                        params.email_uuid = this.editEmailUUID
+                        params.email_invalid = this.editEmailInvalid
+                    } else {
+                        url = '${url('people.profile_add_email', uuid=person.uuid)}'
+                        params.email_preferred = this.editEmailPreferred
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editEmailShowDialog = false
+                        this.editEmailSaving = false
+                        this.refreshTab()
+                    }, response => {
+                        this.editEmailSaving = false
+                    })
+                },
+
+                deleteEmailInit(email) {
+                    this.deleteEmailUUID = email.uuid
+                    this.deleteEmailAddress = email.address
+                    this.deleteEmailShowDialog = true
+                },
+
+                deleteEmailSave() {
+                    this.deleteEmailSaving = true
+                    let url = '${url('people.profile_delete_email', uuid=person.uuid)}'
+                    let params = {
+                        email_uuid: this.deleteEmailUUID,
+                    }
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
+                        this.deleteEmailShowDialog = false
+                        this.deleteEmailSaving = false
+                    }, response => {
+                        this.deleteEmailSaving = false
+                    })
+                },
+
+                preferEmailInit(email) {
+                    this.preferEmailUUID = email.uuid
+                    this.preferEmailAddress = email.address
+                    this.preferEmailShowDialog = true
+                },
+
+                preferEmailSave() {
+                    this.preferEmailSaving = true
+                    let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}'
+                    let params = {
+                        email_uuid: this.preferEmailUUID,
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.refreshTab()
+                        this.preferEmailShowDialog = false
+                        this.preferEmailSaving = false
+                    }, response => {
+                        this.preferEmailSaving = false
+                    })
+                },
+
+            % endif
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_personal_tab_component()">
+  ${self.declare_personal_tab_vars()}
+  <script type="text/javascript">
+
+    PersonalTab.data = function() { return PersonalTabData }
+    Vue.component('personal-tab', PersonalTab)
+    <% request.register_component('personal-tab', 'PersonalTab') %>
+
+  </script>
+</%def>
+
+% if expose_members:
+<%def name="declare_member_tab_vars()">
+  <script type="text/javascript">
+
+    let MemberTabData = {
+        refreshTabURL: '${url('people.profile_tab_member', uuid=person.uuid)}',
+        % if max_one_member:
+            member: {},
+        % else:
+            members: [],
+        % endif
+    }
+
+    let MemberTab = {
+        template: '#member-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+            phoneTypeOptions: Array,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                % if max_one_member:
+                    this.member = response.data.member
+                % else:
+                    this.members = response.data.members
+                % endif
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_member_tab_component()">
+  ${self.declare_member_tab_vars()}
+  <script type="text/javascript">
+
+    MemberTab.data = function() { return MemberTabData }
+    Vue.component('member-tab', MemberTab)
+    <% request.register_component('member-tab', 'MemberTab') %>
+
+  </script>
+</%def>
+% endif
+
+<%def name="declare_customer_tab_vars()">
+  <script type="text/javascript">
+
+    let CustomerTabData = {
+        % if hasattr(master, 'profile_tab_customer'):
+        refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}',
+        % endif
+        customers: [],
+    }
+
+    let CustomerTab = {
+        template: '#customer-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.customers = response.data.customers
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_customer_tab_component()">
+  ${self.declare_customer_tab_vars()}
+  <script type="text/javascript">
+
+    CustomerTab.data = function() { return CustomerTabData }
+    Vue.component('customer-tab', CustomerTab)
+    <% request.register_component('customer-tab', 'CustomerTab') %>
+
+  </script>
+</%def>
+
+<%def name="declare_shopper_tab_vars()">
+  <script type="text/javascript">
+
+    let ShopperTabData = {
+        refreshTabURL: '${url('people.profile_tab_shopper', uuid=person.uuid)}',
+        shoppers: [],
+    }
+
+    let ShopperTab = {
+        template: '#shopper-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.shoppers = response.data.shoppers
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_shopper_tab_component()">
+  ${self.declare_shopper_tab_vars()}
+  <script type="text/javascript">
+
+    ShopperTab.data = function() { return ShopperTabData }
+    Vue.component('shopper-tab', ShopperTab)
+    <% request.register_component('shopper-tab', 'ShopperTab') %>
+
+  </script>
+</%def>
+
+<%def name="declare_employee_tab_vars()">
+  <script type="text/javascript">
+
+    let EmployeeTabData = {
+        % if hasattr(master, 'profile_tab_employee'):
+        refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}',
+        % endif
+        employee: {},
+        employeeHistory: [],
+
+        // nb. hack to force refresh for vue3
+        refreshEmployeeCard: 1,
+
+        % if request.has_perm('employees.edit'):
+            editEmployeeIdShowDialog: false,
+            editEmployeeIdValue: null,
+            editEmployeeIdSaving: false,
+        % endif
+
+        % if request.has_perm('people_profile.toggle_employee'):
+            startEmployeeShowDialog: false,
+            startEmployeeID: null,
+            startEmployeeStartDate: null,
+            startEmployeeSaving: false,
+
+            stopEmployeeShowDialog: false,
+            stopEmployeeEndDate: null,
+            stopEmployeeRevokeAccess: false,
+            stopEmployeeSaving: false,
+        % endif
+
+        % if request.has_perm('people_profile.edit_employee_history'):
+            editEmployeeHistoryShowDialog: false,
+            editEmployeeHistoryUUID: null,
+            editEmployeeHistoryStartDate: null,
+            editEmployeeHistoryEndDate: null,
+            editEmployeeHistoryEndDateRequired: false,
+            editEmployeeHistorySaving: false,
+        % endif
+    }
+
+    let EmployeeTab = {
+        template: '#employee-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+        computed: {
+
+            % if request.has_perm('people_profile.toggle_employee'):
+
+                startEmployeeSaveDisabled() {
+                    if (this.startEmployeeSaving) {
+                        return true
+                    }
+                    if (!this.startEmployeeStartDate) {
+                        return true
+                    }
+                    return false
+                },
+
+                stopEmployeeSaveDisabled() {
+                    if (this.stopEmployeeSaving) {
+                        return true
+                    }
+                    if (!this.stopEmployeeEndDate) {
+                        return true
+                    }
+                    return false
+                },
 
-            % if employee.phones:
-                % for phone in employee.phones:
-                    <div class="field-wrapper">
-                      <div class="field-row">
-                        <label>Phone Number</label>
-                        <div class="field">
-                          ${phone.number} (type: ${phone.type})
-                        </div>
-                      </div>
-                    </div>
-                % endfor
-            % else:
-                <div class="field-wrapper">
-                  <div class="field-row">
-                    <label>Phone Number</label>
-                    <div class="field">
-                      (none on file)
-                    </div>
-                  </div>
-                </div>
             % endif
 
-            % if employee.emails:
-                % for email in employee.emails:
-                    <div class="field-wrapper">
-                      <div class="field-row">
-                        <label>Email Address</label>
-                        <div class="field">
-                          ${email.address} (type: ${email.type})
-                        </div>
-                      </div>
-                    </div>
-                % endfor
-            % else:
-                <div class="field-wrapper">
-                  <div class="field-row">
-                    <label>Email Address</label>
-                    <div class="field">
-                      (none on file)
-                    </div>
-                  </div>
-                </div>
+            % if request.has_perm('people_profile.edit_employee_history'):
+
+                editEmployeeHistorySaveDisabled() {
+                    if (this.editEmployeeHistorySaving) {
+                        return true
+                    }
+                    if (!this.editEmployeeHistoryStartDate) {
+                        return true
+                    }
+                    if (this.editEmployeeHistoryEndDateRequired && !this.editEmployeeHistoryEndDate) {
+                        return true
+                    }
+                    return false
+                },
+
             % endif
 
-          </div>
+        },
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.employee = response.data.employee
+                // nb. hack to force refresh for vue3
+                this.refreshEmployeeCard += 1
+                this.employeeHistory = response.data.employee_history
+            },
+
+            % if request.has_perm('employees.edit'):
+
+                editEmployeeIdInit() {
+                    this.editEmployeeIdValue = this.employee.id
+                    this.editEmployeeIdShowDialog = true
+                },
+
+                editEmployeeIdSave() {
+                    this.editEmployeeIdSaving = true
+                    let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}'
+                    let params = {
+                        'employee_id': this.editEmployeeIdValue || null,
+                    }
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editEmployeeIdShowDialog = false
+                        this.editEmployeeIdSaving = false
+                        this.refreshTab()
+                    }, response => {
+                        this.editEmployeeIdSaving = false
+                    })
+                },
 
-          <div>
-            % if request.has_perm('employees.view'):
-                ${h.link_to("View Employee", url('employees.view', uuid=employee.uuid), class_='button')}
             % endif
-          </div>
 
-        </div>
+            % if request.has_perm('people_profile.toggle_employee'):
 
-    % else:
-        <p>${person} has never been an employee.</p>
-    % endif
-  </div><!-- employee-tab -->
+                startEmployeeInit() {
+                    this.startEmployeeID = this.employee.id || null
+                    this.startEmployeeStartDate = null
+                    this.startEmployeeShowDialog = true
+                },
 
-  <div id="user-tab">
-    % if person.users:
-        <p>${person} is associated with ${len(person.users)} user account(s)</p>
-        <br />
-        <div id="users-accordion">
-          % for user in person.users:
-              <h3>${user.username}</h3>
-              <div>
+                startEmployeeSave() {
+                    this.startEmployeeSaving = true
+                    const url = '${url('people.profile_start_employee', uuid=person.uuid)}'
+                    const params = {
+                        id: this.startEmployeeID,
+                        % if request.use_oruga:
+                            start_date: this.$refs.startEmployeeStartDate.formatDate(this.startEmployeeStartDate),
+                        % else:
+                            start_date: this.startEmployeeStartDate,
+                        % endif
+                    }
 
-                <div style="display: flex; justify-content: space-between;">
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.startEmployeeShowDialog = false
+                        this.refreshTab()
+                        this.startEmployeeSaving = false
+                    }, response => {
+                        this.startEmployeeSaving = false
+                    })
+                },
 
-                  <div>
+                stopEmployeeInit() {
+                    this.stopEmployeeEndDate = null
+                    this.stopEmployeeRevokeAccess = false
+                    this.stopEmployeeShowDialog = true
+                },
 
-                    <div class="field-wrapper id">
-                      <div class="field-row">
-                        <label>Username</label>
-                        <div class="field">
-                          ${user.username}
-                        </div>
-                      </div>
-                    </div>
+                stopEmployeeSave() {
+                    this.stopEmployeeSaving = true
+                    const url = '${url('people.profile_end_employee', uuid=person.uuid)}'
+                    const params = {
+                        % if request.use_oruga:
+                            end_date: this.$refs.startEmployeeStartDate.formatDate(this.stopEmployeeEndDate),
+                        % else:
+                            end_date: this.stopEmployeeEndDate,
+                        % endif
+                        revoke_access: this.stopEmployeeRevokeAccess,
+                    }
 
-                  </div>
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.stopEmployeeShowDialog = false
+                        this.stopEmployeeSaving = false
+                        this.refreshTab()
+                    }, response => {
+                        this.stopEmployeeSaving = false
+                    })
+                },
 
-                  <div>
-                    % if request.has_perm('users.view'):
-                        ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')}
+            % endif
+
+            % if request.has_perm('people_profile.edit_employee_history'):
+
+                editEmployeeHistoryInit(row) {
+                    this.editEmployeeHistoryUUID = row.uuid
+                    this.editEmployeeHistoryStartDate = row.start_date
+                    this.editEmployeeHistoryEndDate = row.end_date
+                    this.editEmployeeHistoryEndDateRequired = !!row.end_date
+                    this.editEmployeeHistoryShowDialog = true
+                },
+
+                editEmployeeHistorySave() {
+                    this.editEmployeeHistorySaving = true
+                    let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}'
+                    let params = {
+                        uuid: this.editEmployeeHistoryUUID,
+                        % if request.use_oruga:
+                            start_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryStartDate),
+                            end_date: this.$refs.startEmployeeStartDate.formatDate(this.editEmployeeHistoryEndDate),
+                        % else:
+                            start_date: this.editEmployeeHistoryStartDate,
+                            end_date: this.editEmployeeHistoryEndDate,
+                        % endif
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editEmployeeHistoryShowDialog = false
+                        this.refreshTab()
+                        this.editEmployeeHistorySaving = false
+                    }, response => {
+                        this.editEmployeeHistorySaving = false
+                    })
+                },
+
+            % endif
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_employee_tab_component()">
+  ${self.declare_employee_tab_vars()}
+  <script type="text/javascript">
+
+    EmployeeTab.data = function() { return EmployeeTabData }
+    Vue.component('employee-tab', EmployeeTab)
+    <% request.register_component('employee-tab', 'EmployeeTab') %>
+
+  </script>
+</%def>
+
+<%def name="declare_notes_tab_vars()">
+  <script type="text/javascript">
+
+    let NotesTabData = {
+        % if hasattr(master, 'profile_tab_notes'):
+        refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}',
+        % endif
+        notes: [],
+        noteTypeOptions: [],
+
+        % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'):
+            editNoteShowDialog: false,
+            editNoteUUID: null,
+            editNoteDelete: false,
+            editNoteType: null,
+            editNoteSubject: null,
+            editNoteText: null,
+            editNoteSaving: false,
+        % endif
+    }
+
+    let NotesTab = {
+        template: '#notes-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+        computed: {},
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.notes = response.data.notes
+                this.noteTypeOptions = response.data.note_types
+            },
+
+            % if request.has_perm('people_profile.add_note'):
+
+                addNoteInit() {
+                    this.editNoteUUID = null
+                    this.editNoteType = null
+                    this.editNoteSubject = null
+                    this.editNoteText = null
+                    this.editNoteDelete = false
+                    this.editNoteShowDialog = true
+                },
+
+            % endif
+
+            % if request.has_perm('people_profile.edit_note'):
+
+                editNoteInit(note) {
+                    this.editNoteUUID = note.uuid
+                    this.editNoteType = note.note_type
+                    this.editNoteSubject = note.subject
+                    this.editNoteText = note.text
+                    this.editNoteDelete = false
+                    this.editNoteShowDialog = true
+                },
+
+            % endif
+
+            % if request.has_perm('people_profile.delete_note'):
+
+                deleteNoteInit(note) {
+                    this.editNoteUUID = note.uuid
+                    this.editNoteType = note.note_type
+                    this.editNoteSubject = note.subject
+                    this.editNoteText = note.text
+                    this.editNoteDelete = true
+                    this.editNoteShowDialog = true
+                },
+
+            % endif
+
+            % if request.has_any_perm('people_profile.add_note', 'people_profile.edit_note', 'people_profile.delete_note'):
+
+                editNoteSave() {
+                    this.editNoteSaving = true
+
+                    let url = null
+                    if (!this.editNoteUUID) {
+                        url = '${master.get_action_url('profile_add_note', instance)}'
+                    } else if (this.editNoteDelete) {
+                        url = '${master.get_action_url('profile_delete_note', instance)}'
+                    } else {
+                        url = '${master.get_action_url('profile_edit_note', instance)}'
+                    }
+
+                    let params = {
+                        uuid: this.editNoteUUID,
+                        note_type: this.editNoteType,
+                        note_subject: this.editNoteSubject,
+                        note_text: this.editNoteText,
+                    }
+
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.editNoteSaving = false
+                        this.editNoteShowDialog = false
+                        this.refreshTab()
+                    }, response => {
+                        this.editNoteSaving = false
+                    })
+                },
+
+            % endif
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_notes_tab_component()">
+  ${self.declare_notes_tab_vars()}
+  <script type="text/javascript">
+
+    NotesTab.data = function() { return NotesTabData }
+    Vue.component('notes-tab', NotesTab)
+    <% request.register_component('notes-tab', 'NotesTab') %>
+
+  </script>
+</%def>
+
+% if expose_transactions:
+
+    <%def name="declare_transactions_tab_vars()">
+      <script type="text/javascript">
+
+        let TransactionsTabData = {}
+
+        let TransactionsTab = {
+            template: '#transactions-tab-template',
+            mixins: [TabMixin, SimpleRequestMixin],
+            props: {
+                person: Object,
+            },
+            computed: {},
+            methods: {
+
+                // nb. we override this completely, just tell the grid to refresh
+                refreshTab() {
+                    this.refreshingTab = true
+                    this.$refs.transactionsGrid.loadAsyncData(null, () => {
+                        this.refreshed = Date.now()
+                        this.refreshingTab = false
+                    })
+                }
+            },
+        }
+
+      </script>
+    </%def>
+
+    <%def name="make_transactions_tab_component()">
+      ${self.declare_transactions_tab_vars()}
+      <script type="text/javascript">
+
+        TransactionsTab.data = function() { return TransactionsTabData }
+        Vue.component('transactions-tab', TransactionsTab)
+        <% request.register_component('transactions-tab', 'TransactionsTab') %>
+
+      </script>
+    </%def>
+
+% endif
+
+<%def name="declare_user_tab_vars()">
+  <script type="text/javascript">
+
+    let UserTabData = {
+        % if hasattr(master, 'profile_tab_user'):
+        refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}',
+        % endif
+        users: [],
+
+        % if request.has_perm('users.create'):
+            createUserShowDialog: false,
+            createUserUsername: null,
+            createUserActive: false,
+            createUserSaving: false,
+        % endif
+    }
+
+    let UserTab = {
+        template: '#user-tab-template',
+        mixins: [TabMixin, SimpleRequestMixin],
+        props: {
+            person: Object,
+        },
+
+        computed: {
+
+            % if request.has_perm('users.create'):
+
+                createUserSaveDisabled() {
+                    if (this.createUserSaving) {
+                        return true
+                    }
+                    if (!this.createUserUsername) {
+                        return true
+                    }
+                    return false
+                },
+
+            % endif
+        },
+
+        methods: {
+
+            refreshTabSuccess(response) {
+                this.users = response.data.users
+                this.createUserSuggestedUsername = response.data.suggested_username
+            },
+
+            % if request.has_perm('users.create'):
+
+                createUserInit() {
+                    this.createUserUsername = this.createUserSuggestedUsername
+                    this.createUserActive = true
+                    this.createUserShowDialog = true
+                    this.$nextTick(() => {
+                        this.$refs.username.focus()
+                    })
+                },
+
+                createUserSave() {
+                    this.createUserSaving = true
+
+                    % if hasattr(master, 'profile_make_user'):
+                    let url = '${master.get_action_url('profile_make_user', instance)}'
                     % endif
-                  </div>
+                    let params = {
+                        username: this.createUserUsername,
+                        active: this.createUserActive,
+                    }
 
-                </div>
+                    this.simplePOST(url, params, response => {
+                        this.$emit('profile-changed', response.data)
+                        this.createUserSaving = false
+                        this.createUserShowDialog = false
+                        this.refreshTab()
+                    }, response => {
+                        this.createUserSaving = false
+                    })
+                },
 
-              </div>
-          % endfor
-        </div>
+            % endif
+        },
+    }
 
-    % else:
-        <p>${person} has never been a user.</p>
+  </script>
+</%def>
+
+<%def name="make_user_tab_component()">
+  ${self.declare_user_tab_vars()}
+  <script type="text/javascript">
+
+    UserTab.data = function() { return UserTabData }
+    Vue.component('user-tab', UserTab)
+    <% request.register_component('user-tab', 'UserTab') %>
+
+  </script>
+</%def>
+
+<%def name="make_profile_info_component()">
+
+  ## DEPRECATED; called for back-compat
+  ${self.declare_profile_info_vars()}
+
+  <script>
+    ProfileInfo.data = function() { return ProfileInfoData }
+    Vue.component('profile-info', ProfileInfo)
+    <% request.register_component('profile-info', 'ProfileInfo') %>
+  </script>
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+
+  ${self.render_personal_tab_template()}
+
+  % if expose_members:
+      ${self.render_member_tab_template()}
+  % endif
+
+  ${self.render_customer_tab_template()}
+  % if expose_customer_shoppers:
+      ${self.render_shopper_tab_template()}
+  % endif
+  ${self.render_employee_tab_template()}
+  ${self.render_notes_tab_template()}
+
+  % if expose_transactions:
+      ${transactions_grid.render_complete(allow_save_defaults=False)|n}
+      ${self.render_transactions_tab_template()}
+  % endif
+
+  ${self.render_user_tab_template()}
+  ${self.render_profile_info_template()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if request.has_perm('people_profile.view_versions'):
+        ThisPage.props.viewingHistory = Boolean
+        ThisPage.props.gettingRevisions = Boolean
+        ThisPage.props.revisions = Array
+        ThisPage.props.revisionVersionMap = null
     % endif
-  </div><!-- user-tab -->
 
-</div><!-- profile-tabs -->
+    let TabMixin = {
+
+        data() {
+            return {
+                refreshed: null,
+                refreshTabURL: null,
+                refreshingTab: false,
+            }
+        },
+        methods: {
+
+            refreshIfNeeded(time) {
+                if (this.refreshed && time && this.refreshed > time) {
+                    return
+                }
+                this.refreshTab()
+            },
+
+            refreshTab() {
+
+                if (this.refreshTabURL) {
+                    this.refreshingTab = true
+                    this.simpleGET(this.refreshTabURL, {}, response => {
+                        this.refreshTabSuccess(response)
+                        this.refreshTabSuccessExtra(response)
+                        this.refreshed = Date.now()
+                        this.refreshingTab = false
+                    })
+                }
+            },
+
+            // nb. subclass must define this as needed
+            refreshTabSuccess(response) {},
+
+            // nb. subclass may define this if needed
+            refreshTabSuccessExtra(response) {},
+        },
+    }
+
+
+    % if request.has_perm('people_profile.view_versions'):
+
+        WholePageData.viewingHistory = false
+        WholePageData.gettingRevisions = false
+        WholePageData.gotRevisions = false
+        WholePageData.revisions = []
+        WholePageData.revisionVersionMap = null
+
+        WholePage.methods.viewHistory = function() {
+            this.viewingHistory = true
+
+            if (!this.gotRevisions && !this.gettingRevisions) {
+                this.getRevisions()
+            }
+        }
+
+        WholePage.methods.refreshHistory = function() {
+            if (!this.gettingRevisions) {
+                this.getRevisions()
+            }
+        }
+
+        WholePage.methods.getRevisions = function() {
+            this.gettingRevisions = true
+
+            let url = '${url('people.view_profile_revisions', uuid=person.uuid)}'
+            this.simpleGET(url, {}, response => {
+                this.revisions = response.data.data
+                this.revisionVersionMap = response.data.vmap
+                this.gotRevisions = true
+                this.gettingRevisions = false
+            }, response => {
+                this.gettingRevisions = false
+            })
+        }
+
+    % endif
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+
+  ${self.make_personal_tab_component()}
+
+  % if expose_members:
+      ${self.make_member_tab_component()}
+  % endif
+
+  ${self.make_customer_tab_component()}
+  % if expose_customer_shoppers:
+      ${self.make_shopper_tab_component()}
+  % endif
+  ${self.make_employee_tab_component()}
+  ${self.make_notes_tab_component()}
+
+  % if expose_transactions:
+      <script type="text/javascript">
+
+        TransactionsGrid.data = function() { return TransactionsGridData }
+        Vue.component('transactions-grid', TransactionsGrid)
+        ## TODO: why is this line not needed?
+        ## <% request.register_component('transactions-grid', 'TransactionsGrid') %>
+
+      </script>
+      ${self.make_transactions_tab_component()}
+  % endif
+
+  ${self.make_user_tab_component()}
+  ${self.make_profile_info_component()}
+</%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="declare_profile_info_vars()"></%def>
diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako
deleted file mode 100644
index c3017aa0..00000000
--- a/tailbone/templates/people/view_profile_buefy.mako
+++ /dev/null
@@ -1,502 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/master/view.mako" />
-
-<%def name="page_content()">
-  <profile-info></profile-info>
-</%def>
-
-<%def name="render_this_page()">
-  ${self.page_content()}
-</%def>
-
-<%def name="render_member_tab()">
-  <b-tab-item label="Member" icon-pack="fas" :icon="members.length ? 'check' : null">
-
-    <div v-if="members.length">
-
-      <div style="display: flex; justify-content: space-between;">
-        <p>{{ person.display_name }} is associated with <strong>{{ members.length }}</strong> member account(s)</p>
-      </div>
-
-      <br />
-      <b-collapse v-for="member in members"
-                  :key="member.uuid"
-                  class="panel"
-                  :open="members.length == 1">
-
-        <div slot="trigger"
-             slot-scope="props"
-             class="panel-heading"
-             role="button">
-          <b-icon pack="fas"
-                  icon="caret-right">
-          </b-icon>
-          <strong>#{{ member.number }} {{ member.display }}</strong>
-        </div>
-
-        <div class="panel-block">
-          <div style="display: flex; justify-content: space-between; width: 100%;">
-            <div style="flex-grow: 1;">
-
-              <b-field horizontal label="Number">
-                {{ member.number }}
-              </b-field>
-
-              <b-field horizontal label="ID">
-                {{ member.id }}
-              </b-field>
-
-              <b-field horizontal label="Active">
-                {{ member.active }}
-              </b-field>
-
-              <b-field horizontal label="Joined">
-                {{ member.joined }}
-              </b-field>
-
-              <b-field horizontal label="Withdrew"
-                       v-if="member.withdrew">
-                {{ member.withdrew }}
-              </b-field>
-
-              <b-field horizontal label="Person">
-                <a v-if="member.person_uuid != person.uuid"
-                   :href="member.view_profile_url">
-                  {{ member.person_display_name }}
-                </a>
-                <span v-if="member.person_uuid == person.uuid">
-                  {{ member.person_display_name }}
-                </span>
-              </b-field>
-
-            </div>
-            <div class="buttons" style="align-items: start;">
-              ${self.render_member_panel_buttons(member)}
-            </div>
-          </div>
-        </div>
-      </b-collapse>
-    </div>
-
-    <div v-if="!members.length">
-      <p>{{ person.display_name }} has never had a member account.</p>
-    </div>
-
-  </b-tab-item>
-</%def>
-
-<%def name="render_member_panel_buttons(member)">
-  % if request.has_perm('members.view'):
-      <b-button tag="a" :href="member.view_url">
-        View Member
-      </b-button>
-  % endif
-</%def>
-
-<%def name="render_customer_tab()">
-  <b-tab-item label="Customer" icon-pack="fas" :icon="customers.length ? 'check' : null">
-
-    <div v-if="customers.length">
-
-      <div style="display: flex; justify-content: space-between;">
-        <p>{{ person.display_name }} is associated with <strong>{{ customers.length }}</strong> customer account(s)</p>
-      </div>
-
-      <br />
-      <b-collapse v-for="customer in customers"
-                  :key="customer.uuid"
-                  class="panel"
-                  :open="customers.length == 1">
-
-        <div slot="trigger"
-             slot-scope="props"
-             class="panel-heading"
-             role="button">
-          <b-icon pack="fas"
-                  icon="caret-right">
-          </b-icon>
-          <strong>#{{ customer.number }} {{ customer.name }}</strong>
-        </div>
-
-        <div class="panel-block">
-          <div style="display: flex; justify-content: space-between; width: 100%;">
-            <div style="flex-grow: 1;">
-
-              <b-field horizontal label="Number">
-                {{ customer.number }}
-              </b-field>
-
-              <b-field horizontal label="ID">
-                {{ customer.id }}
-              </b-field>
-
-              <b-field horizontal label="Name">
-                {{ customer.name }}
-              </b-field>
-
-              <b-field horizontal label="People">
-                <ul>
-                  <li v-for="p in customer.people"
-                      :key="p.uuid">
-                    <a v-if="p.uuid != person.uuid"
-                       :href="p.view_profile_url">
-                      {{ p.display_name }}
-                    </a>
-                    <span v-if="p.uuid == person.uuid">
-                      {{ p.display_name }}
-                    </span>
-                  </li>
-                </ul>
-              </b-field>
-
-              <b-field horizontal label="Address"
-                       v-for="address in customer.addresses"
-                       :key="address.uuid">
-                {{ address.display }}
-              </b-field>
-
-            </div>
-            <div class="buttons" style="align-items: start;">
-              ${self.render_customer_panel_buttons(customer)}
-            </div>
-          </div>
-        </div>
-      </b-collapse>
-    </div>
-
-    <div v-if="!customers.length">
-      <p>{{ person.display_name }} has never had a customer account.</p>
-    </div>
-
-  </b-tab-item> <!-- Customer -->
-</%def>
-
-<%def name="render_customer_panel_buttons(customer)">
-  % if request.has_perm('customers.view'):
-      <b-button tag="a" :href="customer.view_url">
-        View Customer
-      </b-button>
-  % endif
-</%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
-  <script type="text/x-template" id="profile-info-template">
-    <div>
-      <b-tabs v-model="activeTab" type="is-boxed">
-
-        <b-tab-item label="Personal" icon="check" icon-pack="fas">
-          <div style="display: flex; justify-content: space-between;">
-
-            <div>
-
-              <div class="field-wrapper first_name">
-                <div class="field-row">
-                  <label>First Name</label>
-                  <div class="field">
-                    ${person.first_name}
-                  </div>
-                </div>
-              </div>
-
-              <div class="field-wrapper middle_name">
-                <div class="field-row">
-                  <label>Middle Name</label>
-                  <div class="field">
-                    ${person.middle_name}
-                  </div>
-                </div>
-              </div>
-
-              <div class="field-wrapper last_name">
-                <div class="field-row">
-                  <label>Last Name</label>
-                  <div class="field">
-                    ${person.last_name}
-                  </div>
-                </div>
-              </div>
-
-              <div class="field-wrapper street">
-                <div class="field-row">
-                  <label>Street 1</label>
-                  <div class="field">
-                    ${person.address.street if person.address else ''}
-                  </div>
-                </div>
-              </div>
-
-              <div class="field-wrapper street2">
-                <div class="field-row">
-                  <label>Street 2</label>
-                  <div class="field">
-                    ${person.address.street2 if person.address else ''}
-                  </div>
-                </div>
-              </div>
-
-              <div class="field-wrapper city">
-                <div class="field-row">
-                  <label>City</label>
-                  <div class="field">
-                    ${person.address.city if person.address else ''}
-                  </div>
-                </div>
-              </div>
-
-              <div class="field-wrapper state">
-                <div class="field-row">
-                  <label>State</label>
-                  <div class="field">
-                    ${person.address.state if person.address else ''}
-                  </div>
-                </div>
-              </div>
-
-              <div class="field-wrapper zipcode">
-                <div class="field-row">
-                  <label>Zipcode</label>
-                  <div class="field">
-                    ${person.address.zipcode if person.address else ''}
-                  </div>
-                </div>
-              </div>
-
-              % if person.phones:
-                  % for phone in person.phones:
-                      <div class="field-wrapper">
-                        <div class="field-row">
-                          <label>Phone Number</label>
-                          <div class="field">
-                            ${phone.number} (type: ${phone.type})
-                          </div>
-                        </div>
-                      </div>
-                  % endfor
-              % else:
-                  <div class="field-wrapper">
-                    <div class="field-row">
-                      <label>Phone Number</label>
-                      <div class="field">
-                        (none on file)
-                      </div>
-                    </div>
-                  </div>
-              % endif
-
-              % if person.emails:
-                  % for email in person.emails:
-                      <div class="field-wrapper">
-                        <div class="field-row">
-                          <label>Email Address</label>
-                          <div class="field">
-                            ${email.address} (type: ${email.type})
-                          </div>
-                        </div>
-                      </div>
-                  % endfor
-              % else:
-                  <div class="field-wrapper">
-                    <div class="field-row">
-                      <label>Email Address</label>
-                      <div class="field">
-                        (none on file)
-                      </div>
-                    </div>
-                  </div>
-              % endif
-
-            </div>
-
-            <div>
-              % if request.has_perm('people.view'):
-                  ${h.link_to("View Person", url('people.view', uuid=person.uuid), class_='button')}
-              % endif
-            </div>
-
-          </div>
-        </b-tab-item><!-- Personal -->
-
-        ${self.render_customer_tab()}
-
-        ${self.render_member_tab()}
-
-        <b-tab-item label="Employee" ${'icon="check" icon-pack="fas"' if employee else ''|n}>
-
-          % if employee:
-              <div style="display: flex; justify-content: space-between;">
-
-                <div>
-
-                  <div class="field-wrapper id">
-                    <div class="field-row">
-                      <label>ID</label>
-                      <div class="field">
-                        ${employee.id or ''}
-                      </div>
-                    </div>
-                  </div>
-
-                  <div class="field-wrapper display_name">
-                    <div class="field-row">
-                      <label>Display Name</label>
-                      <div class="field">
-                        ${employee.display_name or ''}
-                      </div>
-                    </div>
-                  </div>
-
-                  <div class="field-wrapper status">
-                    <div class="field-row">
-                      <label>Status</label>
-                      <div class="field">
-                        ${enum.EMPLOYEE_STATUS.get(employee.status, '')}
-                      </div>
-                    </div>
-                  </div>
-
-                  % if employee.phones:
-                      % for phone in employee.phones:
-                          <div class="field-wrapper">
-                            <div class="field-row">
-                              <label>Phone Number</label>
-                              <div class="field">
-                                ${phone.number} (type: ${phone.type})
-                              </div>
-                            </div>
-                          </div>
-                      % endfor
-                  % else:
-                      <div class="field-wrapper">
-                        <div class="field-row">
-                          <label>Phone Number</label>
-                          <div class="field">
-                            (none on file)
-                          </div>
-                        </div>
-                      </div>
-                  % endif
-
-                  % if employee.emails:
-                      % for email in employee.emails:
-                          <div class="field-wrapper">
-                            <div class="field-row">
-                              <label>Email Address</label>
-                              <div class="field">
-                                ${email.address} (type: ${email.type})
-                              </div>
-                            </div>
-                          </div>
-                      % endfor
-                  % else:
-                      <div class="field-wrapper">
-                        <div class="field-row">
-                          <label>Email Address</label>
-                          <div class="field">
-                            (none on file)
-                          </div>
-                        </div>
-                      </div>
-                  % endif
-
-                </div>
-
-                <div>
-                  % if request.has_perm('employees.view'):
-                      ${h.link_to("View Employee", url('employees.view', uuid=employee.uuid), class_='button')}
-                  % endif
-                </div>
-
-              </div>
-
-          % else:
-              <p>${person} has never been an employee.</p>
-          % endif
-        </b-tab-item><!-- Employee -->
-
-        <b-tab-item label="User" ${'icon="check" icon-pack="fas"' if person.users else ''|n}>
-          % if person.users:
-              <p>${person} is associated with <strong>${len(person.users)}</strong> user account(s)</p>
-              <br />
-              <div id="users-accordion">
-                % for user in person.users:
-
-                    <b-collapse class="panel"
-                                ## TODO: what's up with aria-id here?
-                                ## aria-id="contentIdForA11y2"
-                                >
-
-                      <div
-                         slot="trigger"
-                         class="panel-heading"
-                         role="button"
-                         ## TODO: what's up with aria-id here?
-                         ## aria-controls="contentIdForA11y2"
-                         >
-                        <strong>${user.username}</strong>
-                      </div>
-
-                      <div class="panel-block">
-
-                        <div style="display: flex; justify-content: space-between; width: 100%;">
-
-                          <div>
-
-                            <div class="field-wrapper id">
-                              <div class="field-row">
-                                <label>Username</label>
-                                <div class="field">
-                                  ${user.username}
-                                </div>
-                              </div>
-                            </div>
-
-                          </div>
-
-                          <div>
-                            % if request.has_perm('users.view'):
-                                ${h.link_to("View User", url('users.view', uuid=user.uuid), class_='button')}
-                            % endif
-                          </div>
-
-                        </div>
-
-                      </div>
-                    </b-collapse>
-                % endfor
-              </div>
-
-          % else:
-              <p>${person} has never been a user.</p>
-          % endif
-        </b-tab-item><!-- User -->
-
-      </b-tabs>
-    </div>
-  </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
-
-    const ProfileInfo = {
-        template: '#profile-info-template',
-        data() {
-            return {
-                activeTab: 0,
-                person: ${json.dumps(person_data)|n},
-                customers: ${json.dumps(customers_data)|n},
-                members: ${json.dumps(members_data)|n},
-            }
-        },
-    }
-
-    Vue.component('profile-info', ProfileInfo)
-
-  </script>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako
new file mode 100644
index 00000000..cb8b51aa
--- /dev/null
+++ b/tailbone/templates/poser/reports/view.mako
@@ -0,0 +1,74 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="render_form_buttons()">
+  <div v-if="!showUploadForm" class="buttons">
+    % if master.has_perm('replace'):
+    <b-button type="is-primary"
+              @click="showUploadForm = true">
+      Upload Replacement Module
+    </b-button>
+    % endif
+    <once-button type="is-primary"
+                 tag="a"
+                 % if instance.get('error'):
+                 href="#" disabled
+                 % else:
+                 href="${url('generate_specific_report', type_key=instance['report'].type_key)}"
+                 % endif
+                 text="Generate this Report">
+    </once-button>
+  </div>
+  % if master.has_perm('replace'):
+  <div v-if="showUploadForm">
+    ${h.form(master.get_action_url('replace', instance), enctype='multipart/form-data', **{'@submit': 'uploadSubmitting = true'})}
+    ${h.csrf_token(request)}
+    <b-field label="New Module File" horizontal>
+
+      <b-field class="file is-primary"
+               :class="{'has-name': !!uploadFile}"
+               >
+        <b-upload name="replacement_module"
+                  v-model="uploadFile"
+                  class="file-label">
+          <span class="file-cta">
+            <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+            <span class="file-label">Click to upload</span>
+          </span>
+        </b-upload>
+        <span v-if="uploadFile"
+              class="file-name">
+          {{ uploadFile.name }}
+        </span>
+      </b-field>
+
+      <div class="buttons">
+        <b-button @click="showUploadForm = false">
+          Cancel
+        </b-button>
+        <b-button type="is-primary"
+                  native-type="submit"
+                  :disabled="uploadSubmitting || !uploadFile"
+                  icon-pack="fas"
+                  icon-left="save">
+          {{ uploadSubmitting ? "Working, please wait..." : "Save" }}
+        </b-button>
+      </div>
+
+    </b-field>
+    ${h.end_form()}
+  </div>
+  % endif
+  <br />
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if master.has_perm('replace'):
+      <script>
+        ${form.vue_component}Data.showUploadForm = false
+        ${form.vue_component}Data.uploadFile = null
+        ${form.vue_component}Data.uploadSubmitting = false
+      </script>
+  % endif
+</%def>
diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako
new file mode 100644
index 00000000..239e7db2
--- /dev/null
+++ b/tailbone/templates/poser/setup.mako
@@ -0,0 +1,126 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">Poser Setup</%def>
+
+<%def name="page_content()">
+  <br />
+
+  % if not poser_dir_exists:
+
+      <p class="block">
+        Before you can use Poser features, ${app_title} must create the
+        file structure for it.
+      </p>
+
+      <p class="block">
+        A new folder will be created at this location:&nbsp; &nbsp;
+        <span class="is-family-monospace has-text-weight-bold">
+          ${poser_dir}
+        </span>
+      </p>
+
+      <p class="block">
+        Once set up, ${app_title} can generate code for certain features,
+        in the Poser folder.&nbsp; You can then access these features from
+        within ${app_title}.
+      </p>
+
+      <p class="block">
+        You are free to edit most files in the Poser folder as well.&nbsp;
+        When you do so ${app_title} should pick up on the changes with no
+        need for app restart.
+      </p>
+
+      <p class="block">
+        Proceed?
+      </p>
+
+      ${h.form(request.current_route_url(), **{'@submit': 'setupSubmitting = true'})}
+      ${h.csrf_token(request)}
+      <b-button type="is-primary"
+                native-type="submit"
+                :disabled="setupSubmitting">
+        {{ setupSubmitting ? "Working, please wait..." : "Go for it!" }}
+      </b-button>
+      ${h.end_form()}
+
+  % else:
+
+      <h3 class="is-size-3 block">Root Folder</h3>
+
+      <p class="block">
+        Poser folder already exists at:&nbsp; &nbsp;
+        <span class="is-family-monospace has-text-weight-bold">
+          ${poser_dir}
+        </span>
+      </p>
+
+      ${h.form(request.current_route_url(), class_='block', **{'@submit': 'setupSubmitting = true'})}
+      ${h.csrf_token(request)}
+      ${h.hidden('action', value='refresh')}
+      <b-button type="is-primary"
+                native-type="submit"
+                :disabled="setupSubmitting"
+                icon-pack="fas"
+                icon-left="redo">
+        {{ setupSubmitting ? "Working, please wait..." : "Refresh Folder" }}
+      </b-button>
+      ${h.end_form()}
+
+      <h3 class="is-size-3 block">Modules</h3>
+
+      <ul class="list" style="max-width: 80%;">
+        <li class="list-item">
+          <span class="is-family-monospace">poser</span>
+          <span class="is-pulled-right">
+            % if poser_imported['poser']:
+                <span class="is-family-monospace">
+                  ${poser_imported['poser'].__file__}
+                </span>
+            % else:
+                <span class="has-background-warning">
+                  ${poser_import_errors['poser']}
+                </span>
+            % endif
+          </span>
+        </li>
+        <li class="list-item">
+          <span class="is-family-monospace">poser.reports</span>
+          <span class="is-pulled-right">
+            % if poser_imported['reports']:
+                <span class="is-family-monospace">
+                  ${poser_imported['reports'].__file__}
+                </span>
+            % else:
+                <span class="has-background-warning">
+                  ${poser_import_errors['reports']}
+                </span>
+            % endif
+          </span>
+        </li>
+        <li class="list-item">
+          <span class="is-family-monospace">poser.web.views</span>
+          <span class="is-pulled-right">
+            % if poser_imported['views']:
+                <span class="is-family-monospace">
+                  ${poser_imported['views'].__file__}
+                </span>
+            % else:
+                <span class="has-background-warning">
+                  ${poser_import_errors['views']}
+                </span>
+            % endif
+          </span>
+        </li>
+      </ul>
+
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.setupSubmitting = false
+  </script>
+</%def>
diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako
new file mode 100644
index 00000000..cdde15c5
--- /dev/null
+++ b/tailbone/templates/poser/views/configure.mako
@@ -0,0 +1,36 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <p class="block has-text-weight-bold is-italic">
+    NB.&nbsp; Any changes made here will require an app restart!
+  </p>
+
+  % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]):
+      <h3 class="block is-size-3">Views for:&nbsp; ${topkey}</h3>
+      % for group_key, group in topgroup.items():
+          <h4 class="block is-size-4">${group_key.capitalize()}</h4>
+          % for key, label in group:
+              ${self.simple_flag(key, label)}
+          % endfor
+      % endfor
+  % endfor
+
+</%def>
+
+<%def name="simple_flag(key, label)">
+  <b-field label="${label}" horizontal>
+    <b-select name="tailbone.includes.${key}"
+              v-model="simpleSettings['tailbone.includes.${key}']"
+              @input="settingsNeedSaved = true">
+      <option :value="null">(disabled)</option>
+      % for option in view_options[key]:
+          <option value="${option}">${option}</option>
+      % endfor
+    </b-select>
+  </b-field>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index f055ce5d..ddc44e3d 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -3,154 +3,104 @@
 
 <%def name="title()">Find ${model_title_plural} by Permission</%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    <% gcount = len(permissions) %>
-    var permissions_by_group = {
-    % for g, (gkey, group) in enumerate(permissions, 1):
-        <% pcount = len(group['perms']) %>
-        '${gkey}': {
-        % for p, (pkey, perm) in enumerate(group['perms'], 1):
-            '${pkey}': "${perm['label']}"${',' if p < pcount else ''}
-        % endfor
-        }${',' if g < gcount else ''}
-    % endfor
-    };
-
-    $(function() {
-
-        $('#permission_group').selectmenu({
-            change: function(event, ui) {
-                var perms = $('#permission');
-                perms.find('option:first').siblings('option').remove();
-                $.each(permissions_by_group[ui.item.value], function(key, label) {
-                    perms.append($('<option value="' + key + '">' + label + '</option>'));
-                });
-                perms.selectmenu('refresh');
-            }
-        });
-
-        $('#permission').selectmenu();
-
-        $('#find-by-perm-form').submit(function() {
-            $('.grid').remove();
-            $(this).find('#submit').button('disable').button('option', 'label', "Searching, please wait...");
-        });
-
-    });
-
-  </script>
-  % endif
-</%def>
-
 <%def name="page_content()">
-  % if use_buefy:
-      <find-principals :permission-groups="permissionGroups"
-                       :sorted-groups="sortedGroups">
-      </find-principals>
-  % else:
-      ## not buefy
-      ${h.form(request.current_route_url(), id='find-by-perm-form')}
-      ${h.csrf_token(request)}
-
-      <div class="form">
-        ${self.wtfield(form, 'permission_group')}
-        ${self.wtfield(form, 'permission')}
-        <div class="buttons">
-          ${h.submit('submit', "Find {}".format(model_title_plural))}
-        </div>
-      </div>
-
-      ${h.end_form()}
-
-      % if principals is not None:
-      <div class="grid half">
-        <br />
-        <h2>${model_title_plural} with that permission (${len(principals)} total):</h2>
-        ${self.principal_table()}
-      </div>
-      % endif
-  % endif
+  <br />
+  <find-principals :permission-groups="permissionGroups"
+                   :sorted-groups="sortedGroups">
+  </find-principals>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="principal_table()">
+  <div
+    style="width: 50%;"
+    >
+    ${grid.render_table_element(data_prop='principalsData')|n}
+  </div>
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="find-principals-template">
-    <div class="app-wrapper">
+    <div>
 
-      ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})}
-      ${h.csrf_token(request)}
+      ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})}
+        <div style="margin-left: 10rem; max-width: 50%;">
+
+          ${h.hidden('permission_group', **{':value': 'selectedGroup'})}
+          <b-field label="Permission Group" horizontal>
+            <b-autocomplete v-if="!selectedGroup"
+                            ref="permissionGroupAutocomplete"
+                            v-model="permissionGroupTerm"
+                            :data="permissionGroupChoices"
+                            :custom-formatter="filtr => filtr.label"
+                            open-on-focus
+                            keep-first
+                            icon-pack="fas"
+                            clearable
+                            clear-on-select
+                            expanded
+                            @select="permissionGroupSelect">
+            </b-autocomplete>
+            <b-button v-if="selectedGroup"
+                      @click="permissionGroupReset()">
+              {{ permissionGroups[selectedGroup].label }}
+            </b-button>
+          </b-field>
+
+          ${h.hidden('permission', **{':value': 'selectedPermission'})}
+          <b-field label="Permission" horizontal>
+            <b-autocomplete v-if="!selectedPermission"
+                            ref="permissionAutocomplete"
+                            v-model="permissionTerm"
+                            :data="permissionChoices"
+                            :custom-formatter="filtr => filtr.label"
+                            open-on-focus
+                            keep-first
+                            icon-pack="fas"
+                            clearable
+                            clear-on-select
+                            expanded
+                            @select="permissionSelect">
+            </b-autocomplete>
+            <b-button v-if="selectedPermission"
+                      @click="permissionReset()">
+              {{ selectedPermissionLabel }}
+            </b-button>
+          </b-field>
+
+          <b-field horizontal>
+            <div class="buttons" style="margin-top: 1rem;">
+              <once-button tag="a"
+                           href="${request.path_url}"
+                           text="Reset Form">
+              </once-button>
+              <b-button type="is-primary"
+                        native-type="submit"
+                        icon-pack="fas"
+                        icon-left="search"
+                        :disabled="formSubmitting">
+                {{ formSubmitting ? "Working, please wait..." : "Find ${model_title_plural}" }}
+              </b-button>
+            </div>
+          </b-field>
 
-      <div class="field-wrapper">
-        <label for="permission_group">${form['permission_group'].label}</label>
-        <div class="field">
-          <b-select name="permission_group"
-                    id="permission_group"
-                    v-model="selectedGroup"
-                    @input="selectGroup">
-            <option v-for="groupkey in sortedGroups"
-                    :key="groupkey"
-                    :value="groupkey">
-              {{ permissionGroups[groupkey].label }}
-            </option>
-          </b-select>
         </div>
-      </div>
-
-      <div class="field-wrapper">
-        <label for="permission">${form['permission'].label}</label>
-        <div class="field">
-          <b-select name="permission"
-                    v-model="selectedPermission">
-            <option v-for="perm in groupPermissions"
-                    :key="perm.permkey"
-                    :value="perm.permkey">
-              {{ perm.label }}
-            </option>
-          </b-select>
-        </div>
-      </div>
-
-      <div class="buttons">
-        <b-button type="is-primary"
-                  native-type="submit"
-                  :disabled="formSubmitting">
-          {{ formButtonText }}
-        </b-button>
-      </div>
-
       ${h.end_form()}
 
       % if principals is not None:
-      <div class="grid half">
-        <br />
-        <h2>Found ${len(principals)} ${model_title_plural} with permission: ${selected_permission}</h2>
-        ${self.principal_table()}
-      </div>
+          <br />
+          <p class="block">
+            Found ${len(principals)} ${model_title_plural} with permission:
+            <span class="has-text-weight-bold">${selected_permission}</span>
+          </p>
+          ${self.principal_table()}
       % endif
 
-    </div><!-- app-wrapper -->
+    </div>
   </script>
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
   <script type="text/javascript">
 
-    ThisPageData.permissionGroups = ${json.dumps(buefy_perms)|n}
-    ThisPageData.sortedGroups = ${json.dumps(buefy_sorted_groups)|n}
-
-  </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
-
-    Vue.component('find-principals', {
+    const FindPrincipals = {
         template: '#find-principals-template',
         props: {
             permissionGroups: Object,
@@ -158,37 +108,139 @@
         },
         data() {
             return {
-                groupPermissions: ${json.dumps(buefy_perms.get(selected_group, {}).get('permissions', []))|n},
+                groupPermissions: ${json.dumps(perms_data.get(selected_group, {}).get('permissions', []))|n},
+                permissionGroupTerm: '',
+                permissionTerm: '',
                 selectedGroup: ${json.dumps(selected_group)|n},
-                % if selected_permission:
                 selectedPermission: ${json.dumps(selected_permission)|n},
-                % elif selected_group in buefy_perms:
-                selectedPermission: ${json.dumps(buefy_perms[selected_group]['permissions'][0]['permkey'])|n},
-                % else:
-                selectedPermission: null,
-                % endif
-                formButtonText: "Find ${model_title_plural}",
+                selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n},
                 formSubmitting: false,
+                principalsData: ${json.dumps(principals_data)|n},
             }
         },
-        methods: {
 
-            selectGroup(groupkey) {
+        computed: {
 
-                // re-populate Permission dropdown, auto-select first option
-                this.groupPermissions = this.permissionGroups[groupkey].permissions
-                this.selectedPermission = this.groupPermissions[0].permkey
+            permissionGroupChoices() {
+
+                // collect all groups
+                let choices = []
+                for (let groupkey of this.sortedGroups) {
+                    choices.push(this.permissionGroups[groupkey])
+                }
+
+                // parse list of search terms
+                let terms = []
+                for (let term of this.permissionGroupTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+
+                // filter groups by search terms
+                choices = choices.filter(option => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+
+                return choices
             },
 
-            submitForm() {
-                this.formSubmitting = true
-                this.formButtonText = "Working, please wait..."
-            }
+            permissionChoices() {
+
+                // collect all permissions for current group
+                let choices = this.groupPermissions
+
+                // parse list of search terms
+                let terms = []
+                for (let term of this.permissionTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+
+                // filter permissions by search terms
+                choices = choices.filter(option => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+
+                return choices
+            },
+        },
+
+        methods: {
+
+            navigateTo(url) {
+                location.href = url
+            },
+
+            permissionGroupSelect(option) {
+                this.selectedPermission = null
+                this.selectedPermissionLabel = null
+                if (option) {
+                    this.selectedGroup = option.groupkey
+                    this.groupPermissions = this.permissionGroups[option.groupkey].permissions
+                    this.$nextTick(() => {
+                        this.$refs.permissionAutocomplete.focus()
+                    })
+                }
+            },
+
+            permissionGroupReset() {
+                this.selectedGroup = null
+                this.selectedPermission = null
+                this.selectedPermissionLabel = ''
+                this.$nextTick(() => {
+                    this.$refs.permissionGroupAutocomplete.focus()
+                })
+            },
+
+            permissionSelect(option) {
+                if (option) {
+                    this.selectedPermission = option.permkey
+                    this.selectedPermissionLabel = option.label
+                }
+            },
+
+            permissionReset() {
+                this.selectedPermission = null
+                this.selectedPermissionLabel = null
+                this.permissionTerm = ''
+                this.$nextTick(() => {
+                    this.$refs.permissionAutocomplete.focus()
+                })
+            },
         }
-    })
+    }
 
   </script>
 </%def>
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.permissionGroups = ${json.dumps(perms_data)|n}
+    ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n}
+  </script>
+</%def>
 
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('find-principals', FindPrincipals)
+    <% request.register_component('find-principals', 'FindPrincipals') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/principal/index.mako b/tailbone/templates/principal/index.mako
index 4ed3ba5b..fa806455 100644
--- a/tailbone/templates/principal/index.mako
+++ b/tailbone/templates/principal/index.mako
@@ -3,8 +3,8 @@
 
 <%def name="context_menu_items()">
   ${parent.context_menu_items()}
-  % if request.has_perm('{}.find_by_perm'.format(permission_prefix)):
-      <li>${h.link_to("Find {} with Permission X".format(model_title_plural), url('{}.find_by_perm'.format(route_prefix)))}</li>
+  % if master.has_perm('find_by_perm'):
+      <li>${h.link_to(f"Find {model_title_plural} by Permission", url(f'{route_prefix}.find_by_perm'))}</li>
   % endif
 </%def>
 
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index efeaac1e..db029e5a 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -7,63 +7,22 @@
   <li>${h.link_to("Back to Products", url('products'))}</li>
 </%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-    $(function() {
-
-        $('select[name="batch_type"]').on('selectmenuchange', function(event, ui) {
-            $('.params-wrapper').hide();
-            $('.params-wrapper.' + ui.item.value).show();
-        });
-
-        $('.params-wrapper.' + $('select[name="batch_type"]').val()).show();
-
-    });
-  </script>
-  % endif
-</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  % if not use_buefy:
-  <style type="text/css">
-    .params-wrapper {
-        display: none;
-    }
-  </style>
-  % endif
-</%def>
-
 <%def name="render_deform_field(form, field)">
-  % if use_buefy:
-      <b-field horizontal
-               % if field.error:
-               type="is-danger"
-               :message='${form.messages_json(field.error.messages())|n}'
-               % endif
-               label="${field.title}">
-        ${field.serialize(use_buefy=True)|n}
-      </b-field>
-  % else:
-      <div class="field-wrapper ${field.name}">
-        <div class="field-row">
-          <label for="${field.oid}">${field.title}</label>
-          <div class="field">
-            ${field.serialize()|n}
-          </div>
-        </div>
-      </div>
-  % endif
+  <b-field horizontal
+           % if field.description:
+           message="${field.description}"
+           % endif
+           % if field.error:
+           type="is-danger"
+           :message='${form.messages_json(field.error.messages())|n}'
+           % endif
+           label="${field.title}">
+    ${field.serialize()|n}
+  </b-field>
 </%def>
 
 <%def name="render_form_innards()">
-  % if use_buefy:
-  ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})}
-  % else:
-  ${h.form(request.current_route_url(), class_='autodisable')}
-  % endif
+  ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})}
   ${h.csrf_token(request)}
 
   <section>
@@ -71,77 +30,58 @@
     ${render_deform_field(form, dform['description'])}
     ${render_deform_field(form, dform['notes'])}
 
-    % for key, pform in six.iteritems(params_forms):
-        % if use_buefy:
-            <div v-show="field_model_batch_type == '${key}'">
-              % for field in pform.make_deform_form():
-                  ${render_deform_field(pform, field)}
-              % endfor
-            </div>
-        % else:
-            <div class="params-wrapper ${key}">
-              ## TODO: hacky to use deform? at least is explicit..
-              % for field in pform.make_deform_form():
-                  ${render_deform_field(pform, field)}
-              % endfor
-            </div>
-        % endif
+    % for key, pform in params_forms.items():
+        <div v-show="field_model_batch_type == '${key}'">
+          % for field in pform.make_deform_form():
+              ${render_deform_field(pform, field)}
+          % endfor
+        </div>
     % endfor
   </section>
 
   <br />
   <div class="buttons">
-    % if use_buefy:
-        <b-button type="is-primary"
-                  native-type="submit"
-                  :disabled="${form.component_studly}Submitting">
-          {{ ${form.component_studly}ButtonText }}
-        </b-button>
-        <b-button tag="a" href="${url('products')}">
-          Cancel
-        </b-button>
-    % else:
-        ${h.submit('make-batch', "Create Batch")}
-        ${h.link_to("Cancel", url('products'), class_='button')}
-    % endif
+    <b-button type="is-primary"
+              native-type="submit"
+              :disabled="${form.vue_component}Submitting">
+      {{ ${form.vue_component}ButtonText }}
+    </b-button>
+    <b-button tag="a" href="${url('products')}">
+      Cancel
+    </b-button>
   </div>
 
   ${h.end_form()}
 </%def>
 
-<%def name="render_form()">
-  % if use_buefy:
-      <script type="text/x-template" id="${form.component}-template">
-        ${self.render_form_innards()}
-      </script>
-  % else:
-      <div class="form">
-        ${self.render_form_innards()}
-      </div>
-  % endif
+<%def name="render_form_template()">
+  <script type="text/x-template" id="${form.vue_tagname}-template">
+    ${self.render_form_innards()}
+  </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <% request.register_component(form.vue_tagname, form.vue_component) %>
+  <script>
 
-    ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform_buefy.mako)
+    ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
 
-    let ${form.component_studly} = {
-        template: '#${form.component}-template',
+    let ${form.vue_component} = {
+        template: '#${form.vue_tagname}-template',
         methods: {
 
             ## TODO: deprecate / remove the latter option here
             % if form.auto_disable_save or form.auto_disable:
-                submit${form.component_studly}() {
-                    this.${form.component_studly}Submitting = true
-                    this.${form.component_studly}ButtonText = "Working, please wait..."
+                submit${form.vue_component}() {
+                    this.${form.vue_component}Submitting = true
+                    this.${form.vue_component}ButtonText = "Working, please wait..."
                 }
             % endif
         }
     }
 
-    let ${form.component_studly}Data = {
+    let ${form.vue_component}Data = {
 
         ## TODO: ugh, this seems pretty hacky.  need to declare some data models
         ## for various field components to bind to...
@@ -156,8 +96,8 @@
 
         ## TODO: deprecate / remove the latter option here
         % if form.auto_disable_save or form.auto_disable:
-            ${form.component_studly}Submitting: false,
-            ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
+            ${form.vue_component}Submitting: false,
+            ${form.vue_component}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
         % endif
 
         ## TODO: more hackiness, this is for the sake of batch params
@@ -175,6 +115,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako
new file mode 100644
index 00000000..a43a85d4
--- /dev/null
+++ b/tailbone/templates/products/configure.mako
@@ -0,0 +1,120 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Display</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field grouped>
+
+      <b-field label="Key Field">
+        <b-select name="rattail.product.key"
+                  v-model="simpleSettings['rattail.product.key']"
+                  @input="updateKeyTitle()">
+          <option value="upc">upc</option>
+          <option value="item_id">item_id</option>
+          <option value="scancode">scancode</option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Key Field Label">
+        <b-input name="rattail.product.key_title"
+                 v-model="simpleSettings['rattail.product.key_title']"
+                 @input="settingsNeedSaved = true">
+        </b-input>
+      </b-field>
+
+    </b-field>
+
+    <b-field message="If a product has an image in the DB, that will still be preferred.">
+      <b-checkbox name="tailbone.products.show_pod_image"
+                  v-model="simpleSettings['tailbone.products.show_pod_image']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show "POD" Images as fallback
+      </b-checkbox>
+    </b-field>
+
+    <b-field label="POD Image Base URL"
+             style="max-width: 50%;">
+      <b-input name="rattail.pod.pictures.gtin.root_url"
+               v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']"
+               :disabled="!simpleSettings['tailbone.products.show_pod_image']"
+               @input="settingsNeedSaved = true"
+               expanded />
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Handling</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If set, GPC values like 002XXXXXYYYYY-Z will be converted to 002XXXXX00000-Z for lookup">
+      <b-checkbox name="rattail.products.convert_type2_for_gpc_lookup"
+                  v-model="simpleSettings['rattail.products.convert_type2_for_gpc_lookup']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Auto-convert Type 2 UPC for sake of lookup
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="If set, then &quot;case size&quot; etc. will not be shown.">
+      <b-checkbox name="rattail.products.units_only"
+                  v-model="simpleSettings['rattail.products.units_only']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Products only come in units
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Labels</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="User must also have permission to use this feature.">
+      <b-checkbox name="tailbone.products.print_labels"
+                  v-model="simpleSettings['tailbone.products.print_labels']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow quick/direct label printing from Products page
+      </b-checkbox>
+    </b-field>
+
+    <b-field label="Speed Bump Threshold"
+             message="Show speed bump when at least this many labels are quick-printed at once.  Empty means never show speed bump.">
+      <b-input name="tailbone.products.quick_labels.speedbump_threshold"
+               v-model="simpleSettings['tailbone.products.quick_labels.speedbump_threshold']"
+               type="number"
+               @input="settingsNeedSaved = true"
+               style="width: 10rem;">
+      </b-input>
+    </b-field>
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPage.methods.getTitleForKey = function(key) {
+        switch (key) {
+        case 'item_id':
+            return "Item ID"
+        case 'scancode':
+            return "Scancode"
+        default:
+            return "UPC"
+        }
+    }
+
+    ThisPage.methods.updateKeyTitle = function() {
+        this.simpleSettings['rattail.product.key_title'] = this.getTitleForKey(
+            this.simpleSettings['rattail.product.key'])
+        this.settingsNeedSaved = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako
index 06b1e7e0..5ffa9512 100644
--- a/tailbone/templates/products/index.mako
+++ b/tailbone/templates/products/index.mako
@@ -1,103 +1,85 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-
-    table.label-printing th {
-        font-weight: normal;
-        padding: 0px 0px 2px 4px;
-        text-align: left;
-    }
-
-    table.label-printing td {
-        padding: 0px 0px 0px 4px;
-    }
-
-    table.label-printing #label-quantity {
-        text-align: right;
-        width: 30px;
-    }
-
-    div.grid table tbody td.size,
-    div.grid table tbody td.regular_price_uuid,
-    div.grid table tbody td.current_price_uuid {
-        padding-right: 6px;
-        text-align: right;
-    }
-    
-    div.grid table tbody td.labels {
-        text-align: center;
-    }
-    
-  </style>
+<%def name="grid_tools()">
+  ${parent.grid_tools()}
+  % if label_profiles and master.has_perm('print_labels'):
+      <b-field grouped>
+        <b-field label="Label">
+          <b-select v-model="quickLabelProfile">
+            % for profile in label_profiles:
+                <option value="${profile.uuid}">
+                  ${profile.description}
+                </option>
+            % endfor
+          </b-select>
+        </b-field>
+        <b-field label="Qty.">
+          <b-input v-model="quickLabelQuantity"
+                   ref="quickLabelQuantityInput"
+                   style="width: 4rem;">
+          </b-input>
+        </b-field>
+      </b-field>
+  % endif
 </%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if label_profiles and request.has_perm('products.print_labels'):
-      <script type="text/javascript">
+<%def name="render_grid_component()">
+  <${grid.component} :csrftoken="csrftoken"
+     % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple':
+     @deleteActionClicked="deleteObject"
+     % endif
+     % if label_profiles and master.has_perm('print_labels'):
+     @quick-label-print="quickLabelPrint"
+     % endif
+     >
+  </${grid.component}>
+</%def>
 
-      $(function() {
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if label_profiles and master.has_perm('print_labels'):
+      <script>
 
-          $('.grid-wrapper .grid-header .tools select').selectmenu();
+        ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n}
+        ${grid.vue_component}Data.quickLabelQuantity = 1
+        ${grid.vue_component}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n}
 
-          $('.grid-wrapper').on('click', 'a.print_label', function() {
-              var tr = $(this).parents('tr:first');
-              var quantity = $('table.label-printing #label-quantity');
-              if (isNaN(quantity.val())) {
-                  alert("You must provide a valid label quantity.");
-                  quantity.select();
-                  quantity.focus();
-              } else {
-                  quantity = quantity.val();
-                  var data = {
-                      product: tr.data('uuid'),
-                      profile: $('#label-profile').val(),
-                      quantity: quantity
-                  };
-                  $.get('${url('products.print_labels')}', data, function(data) {
-                      if (data.error) {
-                          alert("An error occurred while attempting to print:\n\n" + data.error);
-                      } else if (quantity == '1') {
-                          alert("1 label has been printed.");
-                      } else {
-                          alert(quantity + " labels have been printed.");
-                      }
-                  });
-              }
-              return false;
-          });
-      });
+        ${grid.vue_component}.methods.quickLabelPrint = function(row) {
+
+            let quantity = parseInt(this.quickLabelQuantity)
+            if (isNaN(quantity)) {
+                alert("You must provide a valid label quantity.")
+                this.$refs.quickLabelQuantityInput.focus()
+                return
+            }
+
+            if (this.quickLabelSpeedbumpThreshold && quantity >= this.quickLabelSpeedbumpThreshold) {
+                if (!confirm("Are you sure you want to print " + quantity + " labels?")) {
+                    return
+                }
+            }
+
+            this.$emit('quick-label-print', row.uuid, this.quickLabelProfile, quantity)
+        }
+
+        ThisPage.methods.quickLabelPrint = function(product, profile, quantity) {
+            let url = '${url('products.print_labels')}'
+
+            let data = new FormData()
+            data.append('product', product)
+            data.append('profile', profile)
+            data.append('quantity', quantity)
+
+            this.submitForm(url, data, response => {
+                if (quantity == 1) {
+                    alert("1 label has been printed.")
+                } else {
+                    alert(quantity.toString() + " labels have been printed.")
+                }
+            })
+        }
 
       </script>
   % endif
 </%def>
-
-<%def name="grid_tools()">
-  % if label_profiles and request.has_perm('products.print_labels'):
-      <table class="label-printing">
-        <thead>
-          <tr>
-            <th>Label</th>
-            <th>Qty.</th>
-          </tr>
-        </thead>
-        <tbody>
-          <td>
-            <select name="label-profile" id="label-profile">
-              % for profile in label_profiles:
-                  <option value="${profile.uuid}">${profile.description}</option>
-              % endfor
-            </select>
-          </td>
-          <td>
-            <input type="text" name="label-quantity" id="label-quantity" value="1" />
-          </td>
-        </tbody>
-      </table>
-  % endif
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
new file mode 100644
index 00000000..bb9590b2
--- /dev/null
+++ b/tailbone/templates/products/lookup.mako
@@ -0,0 +1,411 @@
+## -*- coding: utf-8; -*-
+
+<%def name="tailbone_product_lookup_template()">
+  <script type="text/x-template" id="tailbone-product-lookup-template">
+    <div style="width: 100%;">
+      <div style="display: flex; gap: 0.5rem;">
+
+        <b-field :style="{'flex-grow': product ? '0' : '1'}">
+          <${b}-autocomplete v-if="!product"
+                             ref="productAutocomplete"
+                             v-model="autocompleteValue"
+                             expanded
+                             placeholder="Enter UPC or brand, description etc."
+                             :data="autocompleteOptions"
+                             % if request.use_oruga:
+                                 @input="getAutocompleteOptions"
+                                 :formatter="option => option.label"
+                             % else:
+                                 @typing="getAutocompleteOptions"
+                                 :custom-formatter="option => option.label"
+                                 field="value"
+                             % endif
+                             @select="autocompleteSelected"
+                             style="width: 100%;">
+          </${b}-autocomplete>
+          <b-button v-if="product"
+                    @click="clearSelection(true)">
+            {{ product.full_description }}
+          </b-button>
+        </b-field>
+
+        <b-button type="is-primary"
+                  v-if="!product"
+                  @click="lookupInit()"
+                  icon-pack="fas"
+                  icon-left="search">
+          Full Lookup
+        </b-button>
+
+        <b-button v-if="product"
+                  type="is-primary"
+                  tag="a" target="_blank"
+                  :href="product.url"
+                  :disabled="!product.url"
+                  icon-pack="fas"
+                  icon-left="external-link-alt">
+          View Product
+        </b-button>
+
+      </div>
+
+      <b-modal :active.sync="lookupShowDialog">
+        <div class="card">
+          <div class="card-content">
+
+            <b-field grouped>
+
+              <b-input v-model="searchTerm" 
+                       ref="searchTermInput"
+                       % if not request.use_oruga:
+                           @keydown.native="searchTermInputKeydown"
+                       % endif
+                       />
+
+              <b-button class="control"
+                        type="is-primary"
+                        @click="performSearch()">
+                Search
+              </b-button>
+
+              <b-checkbox v-model="searchProductKey"
+                          native-value="true">
+                ${request.rattail_config.product_key_title()}
+              </b-checkbox>
+
+              <b-checkbox v-model="searchVendorItemCode"
+                          native-value="true">
+                Vendor Code
+              </b-checkbox>
+
+              <b-checkbox v-model="searchAlternateCode"
+                          native-value="true">
+                Alt Code
+              </b-checkbox>
+
+              <b-checkbox v-model="searchProductBrand"
+                          native-value="true">
+                Brand
+              </b-checkbox>
+
+              <b-checkbox v-model="searchProductDescription"
+                          native-value="true">
+                Description
+              </b-checkbox>
+
+            </b-field>
+
+            <${b}-table :data="searchResults"
+                        narrowed
+                        % if request.use_oruga:
+                            v-model:selected="searchResultSelected"
+                        % else:
+                            :selected.sync="searchResultSelected"
+                            icon-pack="fas"
+                        % endif
+                        :loading="searchResultsLoading">
+
+              <${b}-table-column label="${request.rattail_config.product_key_title()}"
+                              field="product_key"
+                              v-slot="props">
+                {{ props.row.product_key }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Brand"
+                              field="brand_name"
+                              v-slot="props">
+                {{ props.row.brand_name }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Description"
+                              field="description"
+                              v-slot="props">
+                <span :class="{organic: props.row.organic}">
+                  {{ props.row.description }}
+                  {{ props.row.size }}
+                </span>
+              </${b}-table-column>
+
+              <${b}-table-column label="Unit Price"
+                              field="unit_price"
+                              v-slot="props">
+                {{ props.row.unit_price_display }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Sale Price"
+                              field="sale_price"
+                              v-slot="props">
+                <span class="has-background-warning">
+                  {{ props.row.sale_price_display }}
+                </span>
+              </${b}-table-column>
+
+              <${b}-table-column label="Sale Ends"
+                              field="sale_ends"
+                              v-slot="props">
+                <span class="has-background-warning">
+                  {{ props.row.sale_ends_display }}
+                </span>
+              </${b}-table-column>
+
+              <${b}-table-column label="Department"
+                              field="department_name"
+                              v-slot="props">
+                {{ props.row.department_name }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Vendor"
+                              field="vendor_name"
+                              v-slot="props">
+                {{ props.row.vendor_name }}
+              </${b}-table-column>
+
+              <${b}-table-column label="Actions"
+                              v-slot="props">
+                <a :href="props.row.url"
+                   % if not request.use_oruga:
+                       class="grid-action"
+                   % endif
+                   target="_blank">
+                  % if request.use_oruga:
+                      <span class="icon-text">
+                        <o-icon icon="external-link-alt" />
+                        <span>View</span>
+                      </span>
+                  % else:
+                      <i class="fas fa-external-link-alt"></i>
+                      View
+                  % endif
+                </a>
+              </${b}-table-column>
+
+              <template #empty>
+                <div class="content has-text-grey has-text-centered">
+                  <p>
+                    <b-icon
+                      pack="fas"
+                      icon="sad-tear"
+                      size="is-large">
+                    </b-icon>
+                  </p>
+                  <p>Nothing here.</p>
+                </div>
+              </template>
+            </${b}-table>
+
+            <br />
+            <div class="level">
+              <div class="level-left">
+                <div class="level-item buttons">
+                  <b-button @click="cancelDialog()">
+                    Cancel
+                  </b-button>
+                  <b-button type="is-primary"
+                            @click="selectResult()"
+                            :disabled="!searchResultSelected">
+                    Choose Selected
+                  </b-button>
+                </div>
+              </div>
+              <div class="level-right">
+                <div class="level-item">
+                  <span v-if="searchResultsElided"
+                        class="has-text-danger">
+                    {{ searchResultsElided }} results are not shown
+                  </span>
+                </div>
+              </div>
+            </div>
+
+          </div>
+        </div>
+      </b-modal>
+
+    </div>
+  </script>
+</%def>
+
+<%def name="tailbone_product_lookup_component()">
+  <script type="text/javascript">
+
+    const TailboneProductLookup = {
+        template: '#tailbone-product-lookup-template',
+        props: {
+            product: {
+                type: Object,
+            },
+            autocompleteUrl: {
+                type: String,
+                default: '${url('products.autocomplete')}',
+            },
+        },
+        data() {
+            return {
+                autocompleteValue: '',
+                autocompleteOptions: [],
+
+                lookupShowDialog: false,
+
+                searchTerm: null,
+                searchTermLastUsed: null,
+                % if request.use_oruga:
+                    searchTermInputElement: null,
+                % endif
+
+                searchProductKey: true,
+                searchVendorItemCode: true,
+                searchProductBrand: true,
+                searchProductDescription: true,
+                searchAlternateCode: true,
+
+                searchResults: [],
+                searchResultsLoading: false,
+                searchResultsElided: 0,
+                searchResultSelected: null,
+            }
+        },
+
+        % if request.use_oruga:
+
+            mounted() {
+                this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input')
+                this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown)
+            },
+
+            beforeDestroy() {
+                this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown)
+            },
+
+        % endif
+
+        methods: {
+
+            focus() {
+                if (!this.product) {
+                    this.$refs.productAutocomplete.focus()
+                }
+            },
+
+            clearSelection(focus) {
+
+                // clear data
+                this.autocompleteValue = ''
+                this.$emit('selected', null)
+
+                // maybe set focus to our (autocomplete) component
+                if (focus) {
+                    this.$nextTick(() => {
+                        this.focus()
+                    })
+                }
+            },
+
+            ## TODO: add debounce for oruga?
+            % if request.use_oruga:
+            getAutocompleteOptions(entry) {
+            % else:
+            getAutocompleteOptions: debounce(function (entry) {
+            % endif
+
+                // since the `@typing` event from buefy component does not
+                // "self-regulate" in any way, we a) use `debounce` above,
+                // but also b) skip the search unless we have at least 3
+                // characters of input from user
+                if (entry.length < 3) {
+                    this.data = []
+                    return
+                }
+
+                // and perform the search
+                this.$http.get(this.autocompleteUrl + '?term=' + encodeURIComponent(entry))
+                    .then(({ data }) => {
+                        this.autocompleteOptions = data
+                    }).catch((error) => {
+                        this.autocompleteOptions = []
+                        throw error
+                    })
+            % if request.use_oruga:
+            },
+            % else:
+            }),
+            % endif
+
+            autocompleteSelected(option) {
+                this.$emit('selected', {
+                    uuid: option.value,
+                    full_description: option.label,
+                    url: option.url,
+                    image_url: option.image_url,
+                })
+            },
+
+            lookupInit() {
+                this.searchResultSelected = null
+                this.lookupShowDialog = true
+
+                this.$nextTick(() => {
+
+                    this.searchTerm = this.autocompleteValue
+                    if (this.searchTerm != this.searchTermLastUsed) {
+                        this.searchTermLastUsed = null
+                        this.performSearch()
+                    }
+
+                    this.$refs.searchTermInput.focus()
+                })
+            },
+
+            searchTermInputKeydown(event) {
+                if (event.which == 13) {
+                    this.performSearch()
+                }
+            },
+
+            performSearch() {
+                if (this.searchResultsLoading) {
+                    return
+                }
+
+                if (!this.searchTerm || !this.searchTerm.length) {
+                    this.$refs.searchTermInput.focus()
+                    return
+                }
+
+                this.searchResultsLoading = true
+                this.searchResultSelected = null
+
+                let url = '${url('products.search')}'
+                let params = {
+                    term: this.searchTerm,
+                    search_product_key: this.searchProductKey,
+                    search_vendor_code: this.searchVendorItemCode,
+                    search_brand_name: this.searchProductBrand,
+                    search_description: this.searchProductDescription,
+                    search_alt_code: this.searchAlternateCode,
+                }
+
+                this.$http.get(url, {params: params}).then((response) => {
+                    this.searchTermLastUsed = params.term
+                    this.searchResults = response.data.results
+                    this.searchResultsElided = response.data.elided
+                    this.searchResultsLoading = false
+                })
+            },
+
+            selectResult() {
+                this.lookupShowDialog = false
+                this.$emit('selected', this.searchResultSelected)
+            },
+
+            cancelDialog() {
+                this.searchResultSelected = null
+                this.lookupShowDialog = false
+            },
+        },
+    }
+
+    Vue.component('tailbone-product-lookup', TailboneProductLookup)
+    <% request.register_component('tailbone-product-lookup', 'TailboneProductLookup') %>
+
+  </script>
+</%def>
diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako
new file mode 100644
index 00000000..72c9c76d
--- /dev/null
+++ b/tailbone/templates/products/pending/view.mako
@@ -0,0 +1,130 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+<%namespace name="product_lookup" file="/products/lookup.mako" />
+
+<%def name="page_content()">
+  ${parent.page_content()}
+
+  % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY):
+      ${h.form(master.get_action_url('ignore_product', instance), ref='ignoreProductForm')}
+      ${h.csrf_token(request)}
+      ${h.end_form()}
+  % endif
+
+  % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED):
+      <b-modal has-modal-card
+               :active.sync="resolveProductShowDialog">
+        <div class="modal-card">
+          ${h.form(url('{}.resolve_product'.format(route_prefix), uuid=instance.uuid), ref='resolveProductForm')}
+          ${h.csrf_token(request)}
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Resolve Product</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              If this product already exists, you can declare that by
+              identifying the record below.
+            </p>
+            <p class="block">
+              The app will take care of updating any Customer Orders
+              etc.  as needed once you declare the match.
+            </p>
+            <b-field label="Pending Product">
+              <span>${instance.full_description}</span>
+            </b-field>
+            <b-field label="Actual Product" expanded>
+              <tailbone-product-lookup ref="productLookup"
+                                       autocomplete-url="${url('products.autocomplete_special', key='with_key')}"
+                                       :product="actualProduct"
+                                       @selected="productSelected">
+              </tailbone-product-lookup>
+            </b-field>
+            ${h.hidden('product_uuid', **{':value': 'resolveProductUUID'})}
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="resolveProductShowDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      :disabled="resolveProductSubmitDisabled"
+                      @click="resolveProductSubmit()"
+                      icon-pack="fas"
+                      icon-left="object-ungroup">
+              {{ resolveProductSubmitting ? "Working, please wait..." : "I declare these are the same" }}
+            </b-button>
+          </footer>
+          ${h.end_form()}
+        </div>
+      </b-modal>
+  % endif
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${product_lookup.tailbone_product_lookup_template()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY):
+
+        ThisPage.methods.ignoreProductInit = function() {
+            if (!confirm("Really ignore this product?\n\n"
+                         + "This will leave it unresolved, but hidden via default filters.")) {
+                return
+            }
+            this.$refs.ignoreProductForm.submit()
+        }
+
+    % endif
+
+    % if master.has_perm('resolve_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY, enum.PENDING_PRODUCT_STATUS_IGNORED):
+
+        ThisPageData.resolveProductShowDialog = false
+        ThisPageData.resolveProductUUID = null
+        ThisPageData.resolveProductSubmitting = false
+
+        ThisPage.computed.resolveProductSubmitDisabled = function() {
+            if (this.resolveProductSubmitting) {
+                return true
+            }
+            if (!this.resolveProductUUID) {
+                return true
+            }
+            return false
+        }
+
+        ThisPage.methods.resolveProductInit = function() {
+            this.resolveProductUUID = null
+            this.resolveProductShowDialog = true
+            this.$nextTick(() => {
+                this.$refs.productLookup.focus()
+            })
+        }
+
+        ThisPage.methods.resolveProductSubmit = function() {
+            this.resolveProductSubmitting = true
+            this.$refs.resolveProductForm.submit()
+        }
+
+        ThisPageData.actualProduct = null
+
+        ThisPage.methods.productSelected = function(product) {
+           this.actualProduct = product
+           this.resolveProductUUID = product ? product.uuid : null
+        }
+
+    % endif
+
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${product_lookup.tailbone_product_lookup_component()}
+</%def>
diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako
index 23b6d2ca..66ca3128 100644
--- a/tailbone/templates/products/view.mako
+++ b/tailbone/templates/products/view.mako
@@ -1,147 +1,46 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'):
-      <script type="text/javascript">
-
-        function showPriceHistory(typ) {
-            var dialog = $('#' + typ + '-price-history-dialog');
-            dialog.dialog({
-                title: typ[0].toUpperCase() + typ.slice(1) + " Price History",
-                width: 600,
-                height: 300,
-                modal: true,
-                buttons: [
-                    {
-                        text: "Close",
-                        click: function() {
-                            dialog.dialog('close');
-                        }
-                    }
-                ]
-            });
-        }
-
-        function showCostHistory() {
-            var dialog = $('#cost-history-dialog');
-            dialog.dialog({
-                title: "Cost History",
-                width: 600,
-                height: 300,
-                modal: true,
-                buttons: [
-                    {
-                        text: "Close",
-                        click: function() {
-                            dialog.dialog('close');
-                        }
-                    }
-                ]
-            });
-        }
-
-        $(function() {
-
-            $('#view-regular-price-history').on('click', function() {
-                showPriceHistory('regular');
-                return false;
-            });
-
-            $('#view-current-price-history').on('click', function() {
-                showPriceHistory('current');
-                return false;
-            });
-
-            $('#view-suggested-price-history').on('click', function() {
-                showPriceHistory('suggested');
-                return false;
-            });
-
-            $('#view-cost-history').on('click', function() {
-                showCostHistory();
-                return false;
-            });
-
-        });
-
-      </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
-  % if use_buefy:
-        #main-product-panel {
-            margin-right: 2em;
-            margin-top: 1em;
-        }
-        #pricing-panel .field-wrapper .field {
-            white-space: nowrap;
-        }
-  % else:
-      .price-history-dialog {
-          display: none;
-      }
-      .price-history-dialog .grid {
-          color: black;
-      }
-  % endif
+    nav.item-panel {
+        min-width: 600px;
+    }
+    #main-product-panel {
+        margin-right: 2em;
+        margin-top: 1em;
+    }
+    #pricing-panel .field-wrapper .field {
+        white-space: nowrap;
+    }
   </style>
 </%def>
 
 <%def name="render_main_fields(form)">
-  ${form.render_field_readonly('upc')}
-  ${form.render_field_readonly('brand')}
-  ${form.render_field_readonly('description')}
-  ${form.render_field_readonly('size')}
-  ${form.render_field_readonly('unit_size')}
-  ${form.render_field_readonly('unit_of_measure')}
-  ${form.render_field_readonly('case_size')}
-  % if instance.is_pack_item():
-      ${form.render_field_readonly('pack_size')}
-      ${form.render_field_readonly('unit')}
-      ${form.render_field_readonly('default_pack')}
-  % elif instance.packs:
-      ${form.render_field_readonly('packs')}
-  % endif
+  % for field in panel_fields['main']:
+      ${form.render_field_readonly(field)}
+  % endfor
   ${self.extra_main_fields(form)}
 </%def>
 
 <%def name="left_column()">
-  % if use_buefy:
-      <nav class="panel" id="pricing-panel">
-        <p class="panel-heading">Pricing</p>
-        <div class="panel-block">
-          <div>
-            ${self.render_price_fields(form)}
-          </div>
-        </div>
-      </nav>
-      <nav class="panel">
-        <p class="panel-heading">Flags</p>
-        <div class="panel-block">
-          <div>
-            ${self.render_flag_fields(form)}
-          </div>
-        </div>
-      </nav>
-  % else:
-  <div class="panel">
-    <h2>Pricing</h2>
-    <div class="panel-body">
-      ${self.render_price_fields(form)}
+  <nav class="panel item-panel" id="pricing-panel">
+    <p class="panel-heading">Pricing</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        ${self.render_price_fields(form)}
+      </div>
     </div>
-  </div>
-  <div class="panel">
-    <h2>Flags</h2>
-    <div class="panel-body">
-      ${self.render_flag_fields(form)}
+  </nav>
+  <nav class="panel item-panel">
+    <p class="panel-heading">Flags</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        ${self.render_flag_fields(form)}
+      </div>
     </div>
-  </div>
-  % endif
+  </nav>
   ${self.extra_left_panels()}
 </%def>
 
@@ -158,23 +57,14 @@
 <%def name="extra_main_fields(form)"></%def>
 
 <%def name="organization_panel()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">Organization</p>
-        <div class="panel-block">
-          <div>
-            ${self.render_organization_fields(form)}
-          </div>
-        </div>
-      </nav>
-  % else:
-  <div class="panel">
-    <h2>Organization</h2>
-    <div class="panel-body">
-      ${self.render_organization_fields(form)}
+  <nav class="panel item-panel">
+    <p class="panel-heading">Organization</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        ${self.render_organization_fields(form)}
+      </div>
     </div>
-  </div>
-  % endif
+  </nav>
 </%def>
 
 <%def name="render_organization_fields(form)">
@@ -190,39 +80,30 @@
     ${form.render_field_readonly('regular_price')}
     ${form.render_field_readonly('current_price')}
     ${form.render_field_readonly('current_price_ends')}
+    ${form.render_field_readonly('sale_price')}
+    ${form.render_field_readonly('sale_price_ends')}
+    ${form.render_field_readonly('tpr_price')}
+    ${form.render_field_readonly('tpr_price_ends')}
     ${form.render_field_readonly('suggested_price')}
     ${form.render_field_readonly('deposit_link')}
     ${form.render_field_readonly('tax')}
 </%def>
 
 <%def name="render_flag_fields(form)">
-    ${form.render_field_readonly('weighed')}
-    ${form.render_field_readonly('discountable')}
-    ${form.render_field_readonly('special_order')}
-    ${form.render_field_readonly('organic')}
-    ${form.render_field_readonly('not_for_sale')}
-    ${form.render_field_readonly('discontinued')}
-    ${form.render_field_readonly('deleted')}
+  % for field in panel_fields['flag']:
+      ${form.render_field_readonly(field)}
+  % endfor
 </%def>
 
 <%def name="movement_panel()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">Movement</p>
-        <div class="panel-block">
-          <div>
-            ${self.render_movement_fields(form)}
-          </div>
-        </div>
-      </nav>
-  % else:
-  <div class="panel">
-    <h2>Movement</h2>
-    <div class="panel-body">
-      ${self.render_movement_fields(form)}
+  <nav class="panel item-panel">
+    <p class="panel-heading">Movement</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        ${self.render_movement_fields(form)}
+      </div>
     </div>
-  </div>
-  % endif
+  </nav>
 </%def>
 
 <%def name="render_movement_fields(form)">
@@ -230,137 +111,54 @@
 </%def>
 
 <%def name="lookup_codes_grid()">
-  <div class="grid full no-border">
-    <table>
-      <thead>
-        <th>Seq</th>
-        <th>Code</th>
-      </thead>
-      <tbody>
-        % for code in instance._codes:
-            <tr>
-              <td>${code.ordinal}</td>
-              <td>${code.code}</td>
-            </tr>
-        % endfor
-      </tbody>
-    </table>
-  </div>
+  ${lookup_codes['grid'].render_table_element(data_prop='lookupCodesData')|n}
 </%def>
 
 <%def name="lookup_codes_panel()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">Additional Lookup Codes</p>
-        <div class="panel-block">
-          ${self.lookup_codes_grid()}
-        </div>
-      </nav>
-  % else:
-  <div class="panel-grid" id="product-codes">
-    <h2>Additional Lookup Codes</h2>
-    ${self.lookup_codes_grid()}
-  </div>
-  % endif
+  <nav class="panel item-panel">
+    <p class="panel-heading">Additional Lookup Codes</p>
+    <div class="panel-block">
+      ${self.lookup_codes_grid()}
+    </div>
+  </nav>
 </%def>
 
 <%def name="sources_grid()">
-  <div class="grid full no-border">
-    <table>
-      <thead>
-        <th>${costs_label_preferred}</th>
-        <th>${costs_label_vendor}</th>
-        <th>${costs_label_code}</th>
-        <th>${costs_label_case_size}</th>
-        <th>Case Cost</th>
-        <th>Unit Cost</th>
-        <th>Status</th>
-      </thead>
-      <tbody>
-        % for i, cost in enumerate(instance.costs, 1):
-            <tr class="${'even' if i % 2 == 0 else 'odd'}">
-              <td class="center">${'X' if cost.preference == 1 else ''}</td>
-              <td>
-                % if request.has_perm('vendors.view'):
-                    ${h.link_to(cost.vendor, request.route_url('vendors.view', uuid=cost.vendor_uuid))}
-                % else:
-                    ${cost.vendor}
-                % endif
-              </td>
-              <td class="center">${cost.code or ''}</td>
-              <td class="center">${h.pretty_quantity(cost.case_size)}</td>
-              <td class="right">${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}</td>
-              <td class="right">${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}</td>
-              <td>${"discontinued" if cost.discontinued else "available"}</td>
-            </tr>
-        % endfor
-      </tbody>
-    </table>
-  </div>
+  ${vendor_sources['grid'].render_table_element(data_prop='vendorSourcesData')|n}
 </%def>
 
 <%def name="sources_panel()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">
-          Vendor Sources
-          % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
-              <a href="#" @click.prevent="showCostHistory()">
-                (view cost history)
-              </a>
-          % endif
-        </p>
-        <div class="panel-block">
-          ${self.sources_grid()}
-        </div>
-      </nav>
-  % else:
-  <div class="panel-grid" id="product-costs">
-    <h2>
+  <nav class="panel item-panel">
+    <p class="panel-heading">
       Vendor Sources
       % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
-          <a id="view-cost-history" href="#">(view cost history)</a>
+          <a href="#" @click.prevent="showCostHistory()">
+            (view cost history)
+          </a>
       % endif
-    </h2>
-    ${self.sources_grid()}
-  </div>
-  % endif
+    </p>
+    <div class="panel-block">
+      ${self.sources_grid()}
+    </div>
+  </nav>
 </%def>
 
 <%def name="notes_panel()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">Notes</p>
-        <div class="panel-block">
-          <div class="field">${form.render_field_readonly('notes')}</div>
-        </div>
-      </nav>
-  % else:
-  <div class="panel">
-    <h2>Notes</h2>
-    <div class="panel-body">
+  <nav class="panel item-panel">
+    <p class="panel-heading">Notes</p>
+    <div class="panel-block">
       <div class="field">${form.render_field_readonly('notes')}</div>
     </div>
-  </div>
-  % endif
+  </nav>
 </%def>
 
 <%def name="ingredients_panel()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">Ingredients</p>
-        <div class="panel-block">
-          ${form.render_field_readonly('ingredients')}
-        </div>
-      </nav>
-  % else:
-  <div class="panel">
-    <h2>Ingredients</h2>
-    <div class="panel-body">
+  <nav class="panel item-panel">
+    <p class="panel-heading">Ingredients</p>
+    <div class="panel-block">
       ${form.render_field_readonly('ingredients')}
     </div>
-  </div>
-  % endif
+  </nav>
 </%def>
 
 <%def name="extra_left_panels()"></%def>
@@ -369,7 +167,7 @@
 
 <%def name="render_this_page()">
   ${parent.render_this_page()}
-  % if use_buefy and request.rattail_config.versioning_enabled() and master.has_perm('versions'):
+  % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
 
       <b-modal :active.sync="showingPriceHistory_regular"
                has-modal-card>
@@ -380,7 +178,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${regular_price_history_grid.render_buefy_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n}
+            ${regular_price_history_grid.render_table_element(data_prop='regularPriceHistoryData', loading='regularPriceHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingPriceHistory_regular = false">
@@ -399,7 +197,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${current_price_history_grid.render_buefy_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n}
+            ${current_price_history_grid.render_table_element(data_prop='currentPriceHistoryData', loading='currentPriceHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingPriceHistory_current = false">
@@ -418,7 +216,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${suggested_price_history_grid.render_buefy_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n}
+            ${suggested_price_history_grid.render_table_element(data_prop='suggestedPriceHistoryData', loading='suggestedPriceHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingPriceHistory_suggested = false">
@@ -437,7 +235,7 @@
             </p>
           </header>
           <section class="modal-card-body">
-            ${cost_history_grid.render_buefy_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n}
+            ${cost_history_grid.render_table_element(data_prop='costHistoryData', loading='costHistoryLoading', paginated=True, per_page=10)|n}
           </section>
           <footer class="modal-card-foot">
             <b-button @click="showingCostHistory = false">
@@ -450,96 +248,51 @@
 </%def>
 
 <%def name="page_content()">
-  % if use_buefy:
-          <div style="display: flex; flex-direction: column;">
-
-            <nav class="panel" id="main-product-panel">
-              <p class="panel-heading">Product</p>
-              <div class="panel-block">
-                <div style="display: flex; justify-content: space-between; width: 100%;">
-                  <div>
-                    ${self.render_main_fields(form)}
-                  </div>
-                  <div>
-                    % if image_url:
-                        ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)}
-                    % endif
-                  </div>
-                </div>
-              </div>
-            </nav>
-
-            <div style="display: flex;">
-              <div class="panel-wrapper"> <!-- left column -->
-                ${self.left_column()}
-              </div> <!-- left column -->
-              <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
-                ${self.right_column()}
-              </div> <!-- right column -->
-            </div>
+  <div style="display: flex; flex-direction: column;">
 
+    <nav class="panel item-panel" id="main-product-panel">
+      <p class="panel-heading">Product</p>
+      <div class="panel-block">
+        <div style="display: flex; gap: 2rem; width: 100%;">
+          <div style="flex-grow: 1;">
+            ${self.render_main_fields(form)}
           </div>
-
-  % else:
-      ## legacy / not buefy
-
-        <div style="display: flex; flex-direction: column;">
-
-          <div class="panel" id="product-main">
-            <h2>Product</h2>
-            <div class="panel-body">
-              <div style="display: flex; justify-content: space-between;">
-                <div>
-                  ${self.render_main_fields(form)}
-                </div>
-                <div>
-                  % if image_url:
-                      ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)}
-                  % endif
-                </div>
-              </div>
-            </div>
+          <div>
+            % if image_url:
+                ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)}
+            % endif
           </div>
-
-          <div style="display: flex;">
-            <div class="panel-wrapper"> <!-- left column -->
-              ${self.left_column()}
-            </div> <!-- left column -->
-            <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
-              ${self.right_column()}
-            </div> <!-- right column -->
-          </div>
-
         </div>
+      </div>
+    </nav>
 
-      % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
-          <div class="price-history-dialog" id="regular-price-history-dialog">
-            ${regular_price_history_grid.render_grid()|n}
-          </div>
-          <div class="price-history-dialog" id="current-price-history-dialog">
-            ${current_price_history_grid.render_grid()|n}
-          </div>
-          <div class="price-history-dialog" id="suggested-price-history-dialog">
-            ${suggested_price_history_grid.render_grid()|n}
-          </div>
-          <div class="price-history-dialog" id="cost-history-dialog">
-            ${cost_history_grid.render_grid()|n}
-          </div>
-      % endif
-  % endif
+    <div style="display: flex;">
+      <div class="panel-wrapper"> <!-- left column -->
+        ${self.left_column()}
+      </div> <!-- left column -->
+      <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
+        ${self.right_column()}
+      </div> <!-- right column -->
+    </div>
+
+  </div>
 
   % if buttons:
       ${buttons|n}
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
-      <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n}
+    ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n}
+
+    % if request.rattail_config.versioning_enabled() and master.has_perm('versions'):
 
         ThisPageData.showingPriceHistory_regular = false
-        ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.regularPriceHistoryDataRaw = ${json.dumps(regular_price_history_grid.get_table_data()['data'])|n}
         ThisPageData.regularPriceHistoryLoading = false
 
         ThisPage.computed.regularPriceHistoryData = function() {
@@ -568,7 +321,7 @@
         }
 
         ThisPageData.showingPriceHistory_current = false
-        ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_table_data()['data'])|n}
         ThisPageData.currentPriceHistoryLoading = false
 
         ThisPage.computed.currentPriceHistoryData = function() {
@@ -598,7 +351,7 @@
         }
 
         ThisPageData.showingPriceHistory_suggested = false
-        ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_table_data()['data'])|n}
         ThisPageData.suggestedPriceHistoryLoading = false
 
         ThisPage.computed.suggestedPriceHistoryData = function() {
@@ -627,7 +380,7 @@
         }
 
         ThisPageData.showingCostHistory = false
-        ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_buefy_data()['data'])|n}
+        ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_table_data()['data'])|n}
         ThisPageData.costHistoryLoading = false
 
         ThisPage.computed.costHistoryData = function() {
@@ -655,9 +408,6 @@
             })
         }
 
-      </script>
-  % endif
+    % endif
+  </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako
index 331e8e1a..ad0a1371 100644
--- a/tailbone/templates/progress.mako
+++ b/tailbone/templates/progress.mako
@@ -1,145 +1,219 @@
 ## -*- coding: utf-8; -*-
-<%namespace file="tailbone:templates/base.mako" import="core_javascript" />
-<%namespace file="/base.mako" import="jquery_theme" />
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html style="direction: ltr;" xmlns="http://www.w3.org/1999/xhtml" lang="en-us">
+<%namespace file="/base.mako" import="core_javascript" />
+<%namespace file="/base.mako" import="core_styles" />
+<!DOCTYPE html>
+<html lang="en">
   <head>
     <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
     <title>${initial_msg or "Working"}...</title>
     ${core_javascript()}
-    ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))}
-    ${jquery_theme()}
-    ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'))}
-    ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css'))}
-    ${h.stylesheet_link(request.static_url('tailbone:static/css/progress.css'))}
-    ${self.update_progress_func()}
+    ${core_styles()}
     ${self.extra_styles()}
+  </head>
+  <body style="height: 100%;">
+
+    <div id="whole-page-app">
+      <whole-page></whole-page>
+    </div>
+
+    <script type="text/x-template" id="whole-page-template">
+
+      <section class="hero is-fullheight">
+        <div class="hero-body">
+          <div class="container">
+
+            <div style="display: flex;">
+              <div style="flex-grow: 1;"></div>
+              <div>
+
+                <p class="block">
+                  {{ progressMessage }} ... {{ totalDisplay }}
+                </p>
+
+                <div class="level">
+
+                  <div class="level-item">
+                    <b-progress size="is-large"
+                                style="width: 400px;"
+                                :max="progressMax"
+                                :value="progressValue"
+                                show-value
+                                format="percent"
+                                precision="0">
+                    </b-progress>
+                  </div>
+
+                  % if can_cancel:
+                      <div class="level-item"
+                           style="margin-left: 2rem;">
+                        <b-button v-show="canCancel"
+                                  @click="cancelProgress()"
+                                  :disabled="cancelingProgress"
+                                  icon-pack="fas"
+                                  icon-left="ban">
+                          {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }}
+                        </b-button>
+                      </div>
+                  % endif
+
+                </div>
+
+              </div>
+              <div style="flex-grow: 1;"></div>
+            </div>
+
+            ${self.after_progress()}
+
+          </div>
+        </div>
+      </section>
+
+    </script>
+
     <script type="text/javascript">
 
-      var stillInProgress = true;
+      let WholePage = {
+          template: '#whole-page-template',
 
-      // fetch first progress data, one second from now
-      setTimeout(function() {
-          update_progress();
-      }, 1000);
+          computed: {
 
-      % if can_cancel:
-      $(function() {
+              totalDisplay() {
 
-          $('#cancel button').click(function() {
-              if (confirm("Do you really wish to cancel this operation?")) {
-                  stillInProgress = false;
-                  $(this).button('disable').button('option', 'label', "Canceling, please wait...");
-                  $.ajax({
-                      url: '${url('progress.cancel', key=progress.key)}?sessiontype=${progress.session.type}',
-                      data: {
-                          'cancel_msg': '${cancel_msg}',
-                      },
-                      success: function(data) {
-                          location.href = '${cancel_url}';
-                      },
-                  });
-              }
-          });
+                  % if can_cancel:
+                  if (!this.stillInProgress && !this.cancelingProgress) {
+                  % else:
+                  if (!this.stillInProgress) {
+                  % endif
+                      return "done!"
+                  }
 
-      });
-      % endif
-
-      </script>
-  </head>
-  <body>
-    <div id="body-wrapper">
-
-      <div id="wrapper">
-
-        <p><span id="message">${initial_msg or "Working"} (please wait)</span> ... <span id="total"></span></p>
-
-        <table id="progress-wrapper">
-          <tr>
-            <td>
-              <table id="progress">
-                <tr>
-                  <td id="complete"></td>
-                  <td id="remaining"></td>
-                </tr>
-              </table><!-- #progress -->
-            </td>
-            <td id="percentage"></td>
-            % if can_cancel:
-            <td id="cancel">
-              <button type="button" style="display: none;">Cancel</button>
-            </td>
-            % endif
-          </tr>
-        </table><!-- #progress-wrapper -->
-
-      </div><!-- #wrapper -->
-
-      ${self.after_progress()}
-
-    </div><!-- #body-wrapper -->
-  </body>
-</html>
-
-<%def name="update_progress_func()">
-  <script type="text/javascript">
-
-      function update_progress() {
-          $.ajax({
-              url: '${url('progress', key=progress.key)}?sessiontype=${progress.session.type}',
-
-              success: function(data) {
-
-                  if (data.error) {
-                      // errors stop the show, we redirect to "cancel" page
-                      location.href = '${cancel_url}';
-
-                  } else {
-                      if (data.complete || data.maximum) {
-                          $('#message').html(data.message);
-                          $('#total').html('('+data.maximum_display+' total)');
-                          % if can_cancel:
-                          $('#cancel button').show();
-                          % endif
-                          if (data.complete) {
-                              stillInProgress = false;
-                              % if can_cancel:
-                              $('#cancel button').hide();
-                              % endif
-                              $('#total').html('done!');
-                              $('#complete').css('width', '100%');
-                              $('#remaining').hide();
-                              $('#percentage').html('100 %');
-                              location.href = data.success_url;
-
-                          } else {
-                              // got progress data, so update display
-                              var width = parseInt(data.value) / parseInt(data.maximum);
-                              width = Math.round(100 * width);
-                              if (width) {
-                                  $('#complete').css('width', width+'%');
-                                  $('#percentage').html(width+' %');
-                              } else {
-                                  $('#complete').css('width', '0.01%');
-                                  $('#percentage').html('0 %');
-                              }
-                              $('#remaining').css('width', 'auto');
-                          }
-                      }
-
-                      if (stillInProgress) {
-                          // fetch progress data again, in one second from now
-                          setTimeout(function() {
-                              update_progress();
-                          }, 1000);
-                      }
+                  if (this.progressMaxDisplay) {
+                      return `(${'$'}{this.progressMaxDisplay} total)`
                   }
               },
-          });
+          },
+
+          mounted() {
+
+              // fetch first progress data, one second from now
+              setTimeout(() => {
+                  this.updateProgress()
+              }, 1000)
+
+              // custom logic if applicable
+              this.mountedCustom()
+          },
+
+          methods: {
+
+              mountedCustom() {},
+
+              updateProgress() {
+
+                  this.$http.get(this.progressURL).then(response => {
+
+                      if (response.data.error) {
+                          // errors stop the show, we redirect to "cancel" page
+                          location.href = '${cancel_url}'
+
+                      } else {
+
+                          if (response.data.complete || response.data.maximum) {
+                              this.progressMessage = response.data.message
+                              this.progressMaxDisplay = response.data.maximum_display
+
+                              if (response.data.complete) {
+                                  this.progressValue = this.progressMax
+                                  this.stillInProgress = false
+                                  % if can_cancel:
+                                  this.canCancel = false
+                                  % endif
+
+                                  location.href = response.data.success_url
+
+                              } else {
+                                  this.progressValue = response.data.value
+                                  this.progressMax = response.data.maximum
+                              }
+                          }
+
+                          // custom logic if applicable
+                          this.updateProgressCustom(response)
+
+                          if (this.stillInProgress) {
+
+                              // fetch progress data again, in one second from now
+                              setTimeout(() => {
+                                  this.updateProgress()
+                              }, 1000)
+                          }
+                      }
+                  })
+              },
+
+              updateProgressCustom(response) {},
+
+              % if can_cancel:
+
+                  cancelProgress() {
+
+                      if (confirm("Do you really wish to cancel this operation?")) {
+
+                          this.cancelingProgress = true
+                          this.stillInProgress = false
+
+                          let params = {cancel_msg: ${json.dumps(cancel_msg)|n}}
+                          this.$http.get(this.cancelURL, {params: params}).then(response => {
+                              location.href = ${json.dumps(cancel_url)|n}
+                          })
+                      }
+
+                  },
+
+              % endif
+          }
       }
-  </script>
-</%def>
+
+      let WholePageData = {
+
+          progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}',
+          progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)",
+          progressMax: null,
+          progressMaxDisplay: null,
+          progressValue: null,
+          stillInProgress: true,
+
+          % if can_cancel:
+          canCancel: true,
+          cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}',
+          cancelingProgress: false,
+          % endif
+      }
+
+    </script>
+
+    ${self.modify_whole_page_vars()}
+    ${self.make_whole_page_app()}
+
+  </body>
+</html>
 
 <%def name="extra_styles()"></%def>
 
 <%def name="after_progress()"></%def>
+
+<%def name="modify_whole_page_vars()"></%def>
+
+<%def name="make_whole_page_app()">
+  <script type="text/javascript">
+
+    WholePage.data = function() { return WholePageData }
+
+    Vue.component('whole-page', WholePage)
+
+    new Vue({
+        el: '#whole-page-app'
+    })
+
+  </script>
+</%def>
diff --git a/tailbone/templates/purchases/batches/create.mako b/tailbone/templates/purchases/batches/create.mako
index 3b94f7d0..35ee878a 100644
--- a/tailbone/templates/purchases/batches/create.mako
+++ b/tailbone/templates/purchases/batches/create.mako
@@ -1,99 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  ${self.func_show_mode()}
-  <script type="text/javascript">
-
-    var purchases_field = '${purchases_field}';
-    var purchases = null; // TODO: where is this used?
-
-    function vendor_selected(uuid, name) {
-        var mode = $('.mode select').val();
-        if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING} || mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) {
-            var purchases = $('.purchase_uuid select');
-            purchases.empty();
-
-            var data = {'vendor_uuid': uuid, 'mode': mode};
-            $.get('${url('purchases.batch.eligible_purchases')}', data, function(data) {
-                if (data.error) {
-                    alert(data.error);
-                } else {
-                    $.each(data.purchases, function(i, purchase) {
-                        purchases.append($('<option value="' + purchase.key + '">' + purchase.display + '</option>'));
-                    });
-                }
-            });
-
-            // TODO: apparently refresh doesn't work right?
-            // http://stackoverflow.com/a/10280078
-            // purchases.selectmenu('refresh');
-            purchases.selectmenu('destroy').selectmenu();
-        }
-    }
-
-    function vendor_cleared() {
-        var purchases = $('.purchase_uuid select');
-        purchases.empty();
-
-        // TODO: apparently refresh doesn't work right?
-        // http://stackoverflow.com/a/10280078
-        // purchases.selectmenu('refresh');
-        purchases.selectmenu('destroy').selectmenu();
-    }
-
-    $(function() {
-
-        $('.field-wrapper.mode select').selectmenu({
-            change: function(event, ui) {
-                show_mode(ui.item.value);
-            }
-        });
-
-        show_mode(${batch.mode or enum.PURCHASE_BATCH_MODE_ORDERING});
-
-    });
-
-  </script>
-</%def>
-
-<%def name="func_show_mode()">
-  <script type="text/javascript">
-
-    function show_mode(mode) {
-        if (mode == ${enum.PURCHASE_BATCH_MODE_ORDERING}) {
-            $('.field-wrapper.store_uuid').show();
-            $('.field-wrapper.' + purchases_field).hide();
-            $('.field-wrapper.department_uuid').show();
-            $('.field-wrapper.buyer_uuid').show();
-            $('.field-wrapper.date_ordered').show();
-            $('.field-wrapper.date_received').hide();
-            $('.field-wrapper.po_number').show();
-            $('.field-wrapper.invoice_date').hide();
-            $('.field-wrapper.invoice_number').hide();
-        } else if (mode == ${enum.PURCHASE_BATCH_MODE_RECEIVING}) {
-            $('.field-wrapper.store_uuid').hide();
-            $('.field-wrapper.purchase_uuid').show();
-            $('.field-wrapper.department_uuid').hide();
-            $('.field-wrapper.buyer_uuid').hide();
-            $('.field-wrapper.date_ordered').hide();
-            $('.field-wrapper.date_received').show();
-            $('.field-wrapper.invoice_date').show();
-            $('.field-wrapper.invoice_number').show();
-        } else if (mode == ${enum.PURCHASE_BATCH_MODE_COSTING}) {
-            $('.field-wrapper.store_uuid').hide();
-            $('.field-wrapper.purchase_uuid').show();
-            $('.field-wrapper.department_uuid').hide();
-            $('.field-wrapper.buyer_uuid').hide();
-            $('.field-wrapper.date_ordered').hide();
-            $('.field-wrapper.date_received').hide();
-            $('.field-wrapper.invoice_date').show();
-            $('.field-wrapper.invoice_number').show();
-        }
-    }
-
-  </script>
-</%def>
+## TODO: deprecate / remove this
 
 ${parent.body()}
diff --git a/tailbone/templates/purchases/batches/receive_form.mako b/tailbone/templates/purchases/batches/receive_form.mako
deleted file mode 100644
index 3a3ed888..00000000
--- a/tailbone/templates/purchases/batches/receive_form.mako
+++ /dev/null
@@ -1,468 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/base.mako" />
-
-<%def name="title()">Receiving Form (${batch.vendor})</%def>
-
-<%def name="head_tags()">
-  ${parent.head_tags()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))}
-  <script type="text/javascript">
-
-    function assert_quantity() {
-        if ($('#cases').val() && parseFloat($('#cases').val())) {
-            return true;
-        }
-        if ($('#units').val() && parseFloat($('#units').val())) {
-            return true;
-        }
-        alert("Please provide case and/or unit quantity");
-        $('#cases').select().focus();
-        return false;
-    }
-
-    function invalid_product(msg) {
-        $('#received-product-info p').text(msg);
-        $('#received-product-info img').hide();
-        $('#upc').focus().select();
-        $('.field-wrapper.cases input').prop('disabled', true);
-        $('.field-wrapper.units input').prop('disabled', true);
-        $('.buttons button').button('disable');
-    }
-
-    function pretty_quantity(cases, units) {
-        if (cases && units) {
-            return cases + " cases, " + units + " units";
-        } else if (cases) {
-            return cases + " cases";
-        } else if (units) {
-            return units + " units";
-        }
-        return '';
-    }
-
-    function show_quantity(name, cases, units) {
-        var quantity = pretty_quantity(cases, units);
-        var field = $('.field-wrapper.quantity_' + name);
-        field.find('.field').text(quantity);
-        if (quantity || name == 'ordered') {
-            field.show();
-        } else {
-            field.hide();
-        }
-    }
-
-    $(function() {
-
-        $('#upc').keydown(function(event) {
-
-            if (key_allowed(event)) {
-                return true;
-            }
-            if (key_modifies(event)) {
-                $('#product').val('');
-                $('#received-product-info p').html("please ENTER a scancode");
-                $('#received-product-info img').hide();
-                $('#received-product-info .warning').hide();
-                $('.product-fields').hide();
-                $('.receiving-fields').hide();
-                $('.field-wrapper.cases input').prop('disabled', true);
-                $('.field-wrapper.units input').prop('disabled', true);
-                $('.buttons button').button('disable');
-                return true;
-            }
-
-            // when user presses ENTER, do product lookup
-            if (event.which == 13) {
-                var upc = $(this).val();
-                var data = {'upc': upc};
-                $.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) {
-
-                    if (data.error) {
-                        alert(data.error);
-                        if (data.redirect) {
-                            $('#receiving-form').mask("Redirecting...");
-                            location.href = data.redirect;
-                        }
-
-                    } else if (data.product) {
-                        $('#upc').val(data.product.upc_pretty);
-                        $('#product').val(data.product.uuid);
-                        $('#brand_name').val(data.product.brand_name);
-                        $('#description').val(data.product.description);
-                        $('#size').val(data.product.size);
-                        $('#case_quantity').val(data.product.case_quantity);
-
-                        $('#received-product-info p').text(data.product.full_description);
-                        $('#received-product-info img').attr('src', data.product.image_url).show();
-                        if (! data.product.uuid) {
-                            // $('#received-product-info .warning.notfound').show();
-                            $('.product-fields').show();
-                        }
-                        if (data.product.found_in_batch) {
-                            show_quantity('ordered', data.product.cases_ordered, data.product.units_ordered);
-                            show_quantity('received', data.product.cases_received, data.product.units_received);
-                            show_quantity('damaged', data.product.cases_damaged, data.product.units_damaged);
-                            show_quantity('expired', data.product.cases_expired, data.product.units_expired);
-                            show_quantity('mispick', data.product.cases_mispick, data.product.units_mispick);
-                            $('.receiving-fields').show();
-                        } else {
-                            $('#received-product-info .warning.notordered').show();
-                        }
-                        $('.field-wrapper.cases input').prop('disabled', false);
-                        $('.field-wrapper.units input').prop('disabled', false);
-                        $('.buttons button').button('enable');
-                        $('#cases').focus().select();
-
-                    } else if (data.upc) {
-                        $('#upc').val(data.upc_pretty);
-                        $('#received-product-info p').text("product not found in our system");
-                        $('#received-product-info img').attr('src', data.image_url).show();
-
-                        $('#product').val('');
-                        $('#brand_name').val('');
-                        $('#description').val('');
-                        $('#size').val('');
-                        $('#case_quantity').val('');
-
-                        $('#received-product-info .warning.notfound').show();
-                        $('.product-fields').show();
-                        $('#brand_name').focus();
-                        $('.field-wrapper.cases input').prop('disabled', false);
-                        $('.field-wrapper.units input').prop('disabled', false);
-                        $('.buttons button').button('enable');
-
-                    } else {
-                        invalid_product('product not found');
-                    }
-                });
-            }
-            return false;
-        });
-
-        $('#received').click(function() {
-            if (! assert_quantity()) {
-                return;
-            }
-            $(this).button('disable').button('option', 'label', "Working...");
-            $('#mode').val('received');
-            $('#receiving-form').submit();
-        });
-
-        $('#damaged').click(function() {
-            if (! assert_quantity()) {
-                return;
-            }
-            $(this).button('disable').button('option', 'label', "Working...");
-            $('#mode').val('damaged');
-            $('#damaged-dialog').dialog({
-                title: "Damaged Product",
-                modal: true,
-                width: '500px',
-                buttons: [
-                    {
-                        text: "OK",
-                        click: function() {
-                            $('#damaged-dialog').dialog('close');
-                            $('#receiving-form #trash').val($('#damaged-dialog #trash').is(':checked') ? '1' : '');
-                            $('#receiving-form').submit();
-                        }
-                    },
-                    {
-                        text: "Cancel",
-                        click: function() {
-                            $('#damaged').button('option', 'label', "Damaged").button('enable');
-                            $('#damaged-dialog').dialog('close');
-                        }
-                    }
-                ]
-            });
-        });
-
-        $('#expiration input[type="date"]').datepicker();
-
-        $('#expired').click(function() {
-            if (! assert_quantity()) {
-                return;
-            }
-            $(this).button('disable').button('option', 'label', "Working...");
-            $('#mode').val('expired');
-            $('#expiration').dialog({
-                title: "Expired / Short Date",
-                modal: true,
-                width: '500px',
-                buttons: [
-                    {
-                        text: "OK",
-                        click: function() {
-                            $('#expiration').dialog('close');
-                            $('#receiving-form #expiration_date').val(
-                                $('#expiration input[type="date"]').val());
-                            $('#receiving-form #trash').val($('#expiration #trash').is(':checked') ? '1' : '');
-                            $('#receiving-form').submit();
-                        }
-                    },
-                    {
-                        text: "Cancel",
-                        click: function() {
-                            $('#expired').button('option', 'label', "Expired").button('enable');
-                            $('#expiration').dialog('close');
-                        }
-                    }
-                ]
-            });
-        });
-
-        $('#mispick').click(function() {
-            if (! assert_quantity()) {
-                return;
-            }
-            $(this).button('disable').button('option', 'label', "Working...");
-            $('#ordered-product').val('');
-            $('#ordered-product-textbox').val('');
-            $('#ordered-product-info p').html("please ENTER a scancode");
-            $('#ordered-product-info img').hide();
-            $('#mispick-dialog').dialog({
-                title: "Mispick - Ordered Product",
-                modal: true,
-                width: 400,
-                buttons: [
-                    {
-                        text: "OK",
-                        click: function() {
-                            if ($('#ordered-product-info .warning').is(':visible')) {
-                                alert("You must choose a product which was ordered.");
-                                $('#ordered-product-textbox').select().focus();
-                                return;
-                            }
-                            $('#mispick-dialog').dialog('close');
-                            $('#mode').val('mispick');
-                            $('#receiving-form').submit();
-                        }
-                    },
-                    {
-                        text: "Cancel",
-                        click: function() {
-                            $('#mispick').button('option', 'label', "Mispick").button('enable');
-                            $('#mispick-dialog').dialog('close');
-                        }
-                    }
-                ]
-            });
-        });
-
-        $('#ordered-product-textbox').keydown(function(event) {
-
-            if (key_allowed(event)) {
-                return true;
-            }
-            if (key_modifies(event)) {
-                $('#ordered_product').val('');
-                $('#ordered-product-info p').html("please ENTER a scancode");
-                $('#ordered-product-info img').hide();
-                $('#ordered-product-info .warning').hide();
-                return true;
-            }
-            if (event.which == 13) {
-                var input = $(this);
-                var data = {upc: input.val()};
-                $.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) {
-                    if (data.error) {
-                        alert(data.error);
-                        if (data.redirect) {
-                            $('#mispick-dialog').mask("Redirecting...");
-                            location.href = data.redirect;
-                        }
-                    } else if (data.product) {
-                        input.val(data.product.upc_pretty);
-                        $('#ordered_product').val(data.product.uuid);
-                        $('#ordered-product-info p').text(data.product.full_description);
-                        $('#ordered-product-info img').attr('src', data.product.image_url).show();
-                        if (data.product.found_in_batch) {
-                            $('#ordered-product-info .warning').hide();
-                        } else {
-                            $('#ordered-product-info .warning').show();
-                        }
-                    } else {
-                        $('#ordered-product-info p').text("product not found");
-                        $('#ordered-product-info img').hide();
-                        $('#ordered-product-info .warning').hide();
-                    }
-                });
-            }
-            return false;
-        });
-
-        $('#receiving-form').submit(function() {
-            $(this).mask("Working...");
-        });
-
-        $('#upc').focus();
-        $('.field-wrapper.cases input').prop('disabled', true);
-        $('.field-wrapper.units input').prop('disabled', true);
-        $('.buttons button').button('disable');
-
-    });
-  </script>
-</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-
-    .product-info {
-        margin-top: 0.5em;
-        text-align: center;
-    }
-
-    .product-info p {
-        margin-left: 0.5em;
-    }
-
-    .product-info .img-wrapper {
-        height: 150px;
-        margin: 0.5em 0;
-    }
-
-    #received-product-info .warning {
-        background: #f66;
-        display: none;
-    }
-
-    #mispick-dialog input[type="text"],
-    #ordered-product-info {
-        width: 320px;
-    }
-
-    #ordered-product-info .warning {
-        background: #f66;
-        display: none;
-    }
-
-  </style>
-</%def>
-
-
-<%def name="context_menu_items()">
-  <li>${h.link_to("Back to Purchase Batch", url('purchases.batch.view', uuid=batch.uuid))}</li>
-</%def>
-
-
-<ul id="context-menu">
-  ${self.context_menu_items()}
-</ul>
-
-<div class="form-wrapper">
-  ${form.begin(id='receiving-form')}
-  ${form.csrf_token()}
-  ${h.hidden('mode')}
-  ${h.hidden('expiration_date')}
-  ${h.hidden('trash')}
-  ${h.hidden('ordered_product')}
-
-  <div class="field-wrapper">
-    <label for="upc">Receiving UPC</label>
-    <div class="field">
-      ${h.hidden('product')}
-      <div>${h.text('upc', autocomplete='off')}</div>
-      <div id="received-product-info" class="product-info">
-        <p>please ENTER a scancode</p>
-        <div class="img-wrapper"><img /></div>
-        <div class="warning notfound">please confirm UPC and provide more details</div>
-        <div class="warning notordered">warning: product not found on current purchase</div>
-      </div>
-    </div>
-  </div>
-
-  <div class="product-fields" style="display: none;">
-
-    <div class="field-wrapper brand_name">
-      <label for="brand_name">Brand Name</label>
-      <div class="field">${h.text('brand_name')}</div>
-    </div>
-
-    <div class="field-wrapper description">
-      <label for="description">Description</label>
-      <div class="field">${h.text('description')}</div>
-    </div>
-
-    <div class="field-wrapper size">
-      <label for="size">Size</label>
-      <div class="field">${h.text('size')}</div>
-    </div>
-
-    <div class="field-wrapper case_quantity">
-      <label for="case_quantity">Units in Case</label>
-      <div class="field">${h.text('case_quantity')}</div>
-    </div>
-
-  </div>
-
-  <div class="receiving-fields" style="display: none;">
-
-    <div class="field-wrapper quantity_ordered">
-      <label for="quantity_ordered">Ordered</label>
-      <div class="field"></div>
-    </div>
-
-    <div class="field-wrapper quantity_received">
-      <label for="quantity_received">Received</label>
-      <div class="field"></div>
-    </div>
-
-    <div class="field-wrapper quantity_damaged">
-      <label for="quantity_damaged">Damaged</label>
-      <div class="field"></div>
-    </div>
-
-    <div class="field-wrapper quantity_expired">
-      <label for="quantity_expired">Expired</label>
-      <div class="field"></div>
-    </div>
-
-    <div class="field-wrapper quantity_mispick">
-      <label for="quantity_mispick">Mispick</label>
-      <div class="field"></div>
-    </div>
-
-  </div>
-
-  <div class="field-wrapper cases">
-    <label for="cases">Cases</label>
-    <div class="field">${h.text('cases', autocomplete='off')}</div>
-  </div>
-
-  <div class="field-wrapper units">
-    <label for="units">Units</label>
-    <div class="field">${h.text('units', autocomplete='off')}</div>
-  </div>
-
-  <div class="buttons">
-    <button type="button" id="received">Received</button>
-    <button type="button" id="damaged">Damaged</button>
-    <button type="button" id="expired">Expired</button>
-    <!-- <button type="button" id="mispick">Mispick</button> -->
-  </div>
-
-  ${form.end()}
-</div>
-
-<div id="damaged-dialog" style="display: none;">
-  <div class="field-wrapper trash">${h.checkbox('trash', label="Product will be discarded and cannot be returned", checked=False)}</div>
-</div>
-
-<div id="expiration" style="display: none;">
-  <div class="field-wrapper expiration-date">
-    <label for="expiration-date">Expiration Date</label>
-    <div class="field">${h.text('expiration-date', type='date')}</div>
-  </div>
-  <div class="field-wrapper trash">${h.checkbox('trash', label="Product will be discarded and cannot be returned", checked=False)}</div>
-</div>
-
-<div id="mispick-dialog" style="display: none;">
-  <div>${h.text('ordered-product-textbox', autocomplete='off')}</div>
-  <div id="ordered-product-info" class="product-info">
-    <p>please ENTER a scancode</p>
-    <div class="img-wrapper"><img /></div>
-    <div class="warning">warning: product not found on current purchase</div>
-  </div>
-</div>
diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako
index db59b939..94028bdb 100644
--- a/tailbone/templates/purchases/credits/index.mako
+++ b/tailbone/templates/purchases/credits/index.mako
@@ -1,97 +1,82 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
-
-    function update_change_status_button() {
-        var count = $('.grid tr:not(.header) td.checkbox input:checked').length;
-        $('button.change-status').button('option', 'disabled', count < 1);
-    }
-
-    $(function() {
-
-        $('.grid-wrapper').on('click', 'tr.header td.checkbox input', function() {
-            update_change_status_button();
-        });
-
-        $('.grid-wrapper').on('click', '.grid tr:not(.header) td.checkbox input', function() {
-            update_change_status_button();
-        });
-        $('.grid-wrapper').on('click', '.grid tr:not(.header)', function() {
-            update_change_status_button();
-        });
-
-        $('button.change-status').click(function() {
-            var uuids = [];
-            $('.grid tr:not(.header) td.checkbox input:checked').each(function() {
-                uuids.push($(this).parents('tr:first').data('uuid'));
-            });
-            if (! uuids.length) {
-                alert("You must first select one or more credits.");
-                return false;
-            }
-
-            var form = $('form[name="change-status"]');
-            form.find('[name="uuids"]').val(uuids.toString());
-
-            $('#change-status-dialog').dialog({
-                title: "Change Credit Status",
-                width: 500,
-                height: 300,
-                modal: true,
-                open: function() {
-                    // TODO: why must we do this here instead of using auto-enhance ?
-                    $('#change-status-dialog select[name="status"]').selectmenu();
-                },
-                buttons: [
-                    {
-                        text: "Submit",
-                        click: function(event) {
-                            disable_button(dialog_button(event));
-                            form.submit();
-                        }
-                    },
-                    {
-                        text: "Cancel",
-                        click: function() {
-                            $(this).dialog('close');
-                        }
-                    }
-                ]
-            });
-        });
-
-    });
-  </script>
-</%def>
-
 <%def name="grid_tools()">
   ${parent.grid_tools()}
-  <button type="button" class="change-status" disabled="disabled">Change Status</button>
+
+  <b-button type="is-primary"
+            @click="changeStatusInit()"
+            :disabled="!selected_uuids.length">
+    Change Status
+  </b-button>
+
+  <b-modal has-modal-card
+           :active.sync="changeStatusShowDialog">
+    <div class="modal-card">
+
+      <header class="modal-card-head">
+        <p class="modal-card-title">Change Status</p>
+      </header>
+
+      <section class="modal-card-body">
+
+        <p class="block">
+          Please choose the appropriate status for the selected credits.
+        </p>
+
+        <b-field label="Status">
+          <b-select v-model="changeStatusValue">
+            <option v-for="status in changeStatusOptions"
+                    :key="status.value"
+                    :value="status.value">
+              {{ status.label }}
+            </option>
+          </b-select>
+        </b-field>
+
+      </section>
+
+      <footer class="modal-card-foot">
+        <b-button @click="changeStatusShowDialog = false">
+          Cancel
+        </b-button>
+        <b-button type="is-primary"
+                  @click="changeStatusSubmit()"
+                  :disabled="changeStatusSubmitting || !changeStatusValue"
+                  icon-pack="fas"
+                  icon-left="save">
+          {{ changeStatusSubmitting ? "Working, please wait..." : "Save" }}
+        </b-button>
+      </footer>
+    </div>
+  </b-modal>
+
+  ${h.form(url('purchases.credits.change_status'), ref='changeStatusForm')}
+  ${h.csrf_token(request)}
+  ${h.hidden('uuids', **{':value': 'selected_uuids'})}
+  ${h.hidden('status', **{':value': 'changeStatusValue'})}
+  ${h.end_form()}
+
 </%def>
 
-${parent.body()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-<div id="change-status-dialog" style="display: none;">
-  ${h.form(url('purchases.credits.change_status'), name='change-status')}
-  ${h.csrf_token(request)}
-  ${h.hidden('uuids')}
+    ${grid.vue_component}Data.changeStatusShowDialog = false
+    ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n}
+    ${grid.vue_component}Data.changeStatusValue = null
+    ${grid.vue_component}Data.changeStatusSubmitting = false
 
-  <br />
-  <p>Please choose the appropriate status for the selected credits.</p>
+    ${grid.vue_component}.methods.changeStatusInit = function() {
+        this.changeStatusValue = null
+        this.changeStatusShowDialog = true
+    }
 
-  <div class="fieldset">
+    ${grid.vue_component}.methods.changeStatusSubmit = function() {
+        this.changeStatusSubmitting = true
+        this.$refs.changeStatusForm.submit()
+    }
 
-  <div class="field-wrapper status">
-    <label for="status">Status</label>
-    <div class="field">
-      ${h.select('status', None, status_options)}
-    </div>
-  </div>
-
-  </div>
-
-  ${h.end_form()}
-</div>
+  </script>
+</%def>
diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako
new file mode 100644
index 00000000..a36dde43
--- /dev/null
+++ b/tailbone/templates/receiving/configure.mako
@@ -0,0 +1,208 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Workflows</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Users can only choose from the workflows enabled below.
+    </p>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_from_scratch"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_scratch']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Scratch
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_from_invoice"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Single Invoice
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_from_multi_invoice"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_multi_invoice']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Multiple (Combined) Invoices
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Purchase Order
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_purchase_order_with_invoice']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Purchase Order, with Invoice
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_truck_dump_receiving"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_truck_dump_receiving']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Truck Dump
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Vendors</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow receiving for <span class="has-text-weight-bold">any</span> vendor
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Display</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.receiving.show_ordered_column_in_grid"
+                  v-model="simpleSettings['rattail.batch.purchase.receiving.show_ordered_column_in_grid']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show "ordered" quantities in row grid
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.receiving.show_shipped_column_in_grid"
+                  v-model="simpleSettings['rattail.batch.purchase.receiving.show_shipped_column_in_grid']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show "shipped" quantities in row grid
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Product Handling</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="NB. Allow Cases setting also affects Ordering behavior.">
+      <b-checkbox name="rattail.batch.purchase.allow_cases"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_cases']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow Cases
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="NB. Allow Decimal Quantities setting also affects Ordering behavior.">
+      <b-checkbox name="rattail.batch.purchase.allow_decimal_quantities"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_decimal_quantities']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow Decimal Quantities
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_expired_credits"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_expired_credits']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow "Expired" Credits
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit"
+                  v-model="simpleSettings['rattail.batch.purchase.receiving.should_autofix_invoice_case_vs_unit']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Try to auto-correct "case vs. unit" mistakes from invoice parser
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.receiving.allow_edit_catalog_unit_cost"
+                  v-model="simpleSettings['rattail.batch.purchase.receiving.allow_edit_catalog_unit_cost']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow edit of Catalog Unit Cost
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.receiving.allow_edit_invoice_unit_cost"
+                  v-model="simpleSettings['rattail.batch.purchase.receiving.allow_edit_invoice_unit_cost']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow edit of Invoice Unit Cost
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.receiving.auto_missing_credits"
+                  v-model="simpleSettings['rattail.batch.purchase.receiving.auto_missing_credits']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Auto-generate "missing" (DNR) credits for items not accounted for
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Mobile Interface</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="TODO: this may also affect Ordering (?)">
+      <b-checkbox name="rattail.batch.purchase.mobile_images"
+                  v-model="simpleSettings['rattail.batch.purchase.mobile_images']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show Product Images
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="If set, one or more &quot;quick receive&quot; buttons will be available for mobile receiving.">
+      <b-checkbox name="rattail.batch.purchase.mobile_quick_receive"
+                  v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow "Quick Receive"
+      </b-checkbox>
+    </b-field>
+
+    <b-field message="If set, only a &quot;quick receive all&quot; button will be shown.  Only applicable if quick receive (above) is enabled.">
+      <b-checkbox name="rattail.batch.purchase.mobile_quick_receive_all"
+                  v-model="simpleSettings['rattail.batch.purchase.mobile_quick_receive_all']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Quick Receive "All or Nothing"
+      </b-checkbox>
+    </b-field>
+
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako
index a8055188..35ee878a 100644
--- a/tailbone/templates/receiving/create.mako
+++ b/tailbone/templates/receiving/create.mako
@@ -1,78 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  ${self.func_show_batch_type()}
-  <script type="text/javascript">
-
-    % if master.allow_truck_dump:
-    var batch_vendor_map = ${json.dumps(batch_vendor_map)|n};
-    % endif
-
-    $(function() {
-
-        $('.batch_type select').on('selectmenuchange', function(event, ui) {
-            show_batch_type(ui.item.value);
-        });
-
-        $('.truck_dump_batch_uuid select').on('selectmenuchange', function(event, ui) {
-            var form = $(this).parents('form');
-            var uuid = ui.item.value ? batch_vendor_map[ui.item.value] : '';
-            form.find('input[name="vendor_uuid"]').val(uuid);
-        });
-
-        show_batch_type();
-    });
-
-  </script>
-</%def>
-
-<%def name="func_show_batch_type()">
-  <script type="text/javascript">
-
-    function show_batch_type(batch_type) {
-
-        if (batch_type === undefined) {
-            batch_type = $('.field-wrapper.batch_type select').val();
-        }
-
-        if (batch_type == 'from_scratch') {
-            $('.field-wrapper.truck_dump_batch_uuid').hide();
-            $('.field-wrapper.invoice_file').hide();
-            $('.field-wrapper.invoice_parser_key').hide();
-            $('.field-wrapper.vendor_uuid').show();
-            $('.field-wrapper.date_ordered').show();
-            $('.field-wrapper.date_received').show();
-            $('.field-wrapper.po_number').show();
-            $('.field-wrapper.invoice_date').show();
-            $('.field-wrapper.invoice_number').show();
-
-        } else if (batch_type == 'truck_dump_children_first') {
-            $('.field-wrapper.truck_dump_batch_uuid').hide();
-            $('.field-wrapper.invoice_file').hide();
-            $('.field-wrapper.invoice_parser_key').hide();
-            $('.field-wrapper.vendor_uuid').show();
-            $('.field-wrapper.date_ordered').hide();
-            $('.field-wrapper.date_received').show();
-            $('.field-wrapper.po_number').hide();
-            $('.field-wrapper.invoice_date').hide();
-            $('.field-wrapper.invoice_number').hide();
-
-        } else if (batch_type == 'truck_dump_children_last') {
-            $('.field-wrapper.truck_dump_batch_uuid').hide();
-            $('.field-wrapper.invoice_file').hide();
-            $('.field-wrapper.invoice_parser_key').hide();
-            $('.field-wrapper.vendor_uuid').show();
-            $('.field-wrapper.date_ordered').hide();
-            $('.field-wrapper.date_received').show();
-            $('.field-wrapper.po_number').hide();
-            $('.field-wrapper.invoice_date').hide();
-            $('.field-wrapper.invoice_number').hide();
-        }
-    }
-
-  </script>
-</%def>
+## TODO: deprecate / remove this
 
 ${parent.body()}
diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako
index 6596ff1b..a377e270 100644
--- a/tailbone/templates/receiving/declare_credit.mako
+++ b/tailbone/templates/receiving/declare_credit.mako
@@ -1,65 +1,52 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/base.mako" />
+<%inherit file="/form.mako" />
 
 <%def name="title()">Declare Credit for Row #${row.sequence}</%def>
 
 <%def name="context_menu_items()">
-  % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)):
+  ${parent.context_menu_items()}
+  % if master.rows_viewable and master.has_perm('view'):
       <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}</li>
   % endif
 </%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
+<%def name="render_form()">
 
-    function toggleFields(creditType) {
-        if (creditType === undefined) {
-            creditType = $('select[name="credit_type"]').val();
-        }
-        if (creditType == 'expired') {
-            $('.field-wrapper.expiration_date').show();
-        } else {
-            $('.field-wrapper.expiration_date').hide();
-        }
-    }
+  <p class="block">
+    Please select the "state" of the product, and enter the
+    appropriate quantity.
+  </p>
 
-    $(function() {
+  <p class="block">
+    Note that this tool will
+    <span class="has-text-weight-bold">deduct</span> from the
+    "received" quantity, and
+    <span class="has-text-weight-bold">add</span> to the
+    corresponding credit quantity.
+  </p>
 
-        toggleFields();
+  <p class="block">
+    Please see ${h.link_to("Receive Row", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))}
+    if you need to "receive" instead of "convert" the product.
+  </p>
 
-        $('select[name="credit_type"]').on('selectmenuchange', function(event, ui) {
-            toggleFields(ui.item.value);
-        });
+  ${parent.render_form()}
 
-    });
-  </script>
 </%def>
 
-<div style="display: flex; justify-content: space-between;">
+<%def name="form_body()">
 
-  <div class="form-wrapper">
+  ${form.render_field_complete('credit_type')}
 
-    <p style="padding: 1em;">
-      Please select the "state" of the product, and enter the appropriate
-      quantity.
-    </p>
+  ${form.render_field_complete('quantity')}
 
-    <p style="padding: 1em;">
-      Note that this tool will <strong>deduct</strong> from the "received"
-      quantity, and <strong>add</strong> to the corresponding credit quantity.
-    </p>
+  ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})}
 
-    <p style="padding: 1em;">
-      Please see ${h.link_to("Receive Row", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))}
-      if you need to "receive" instead of "convert" the product.
-    </p>
+</%def>
 
-    ${form.render()|n}
-  </div><!-- form-wrapper -->
+<%def name="render_form_template()">
+  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n}
+</%def>
 
-  <ul id="context-menu">
-    ${self.context_menu_items()}
-  </ul>
 
-</div>
+${parent.body()}
diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako
index 188fbe7b..48dc6755 100644
--- a/tailbone/templates/receiving/receive_row.mako
+++ b/tailbone/templates/receiving/receive_row.mako
@@ -1,65 +1,49 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/base.mako" />
+<%inherit file="/form.mako" />
 
 <%def name="title()">Receive for Row #${row.sequence}</%def>
 
 <%def name="context_menu_items()">
-  % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)):
+  ${parent.context_menu_items()}
+  % if master.rows_viewable and master.has_perm('view'):
       <li>${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}</li>
   % endif
 </%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
+<%def name="render_form()">
 
-    function toggleFields(mode) {
-        if (mode === undefined) {
-            mode = $('select[name="mode"]').val();
-        }
-        if (mode == 'expired') {
-            $('.field-wrapper.expiration_date').show();
-        } else {
-            $('.field-wrapper.expiration_date').hide();
-        }
-    }
+  <p class="block">
+    Please select the "state" of the product, and enter the appropriate
+    quantity.
+  </p>
 
-    $(function() {
+  <p class="block">
+    Note that this tool will <span class="has-text-weight-bold">add</span>
+    the corresponding quantities for the row.
+  </p>
 
-        toggleFields();
+  <p class="block">
+    Please see ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))}
+    if you need to "convert" some already-received amount, into a credit.
+  </p>
 
-        $('select[name="mode"]').on('selectmenuchange', function(event, ui) {
-            toggleFields(ui.item.value);
-        });
+  ${parent.render_form()}
 
-    });
-  </script>
 </%def>
 
-<div style="display: flex; justify-content: space-between;">
+<%def name="form_body()">
 
-  <div class="form-wrapper">
+  ${form.render_field_complete('mode')}
 
-    <p style="padding: 1em;">
-      Please select the "state" of the product, and enter the appropriate
-      quantity.
-    </p>
+  ${form.render_field_complete('quantity')}
 
-    <p style="padding: 1em;">
-      Note that this tool will <strong>add</strong> the corresponding
-      quantities for the row.
-    </p>
+  ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})}
 
-    <p style="padding: 1em;">
-      Please see ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid))}
-      if you need to "convert" some already-received amount, into a credit.
-    </p>
+</%def>
 
-    ${form.render()|n}
-  </div><!-- form-wrapper -->
+<%def name="render_form_template()">
+  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n}
+</%def>
 
-  <ul id="context-menu">
-    ${self.context_menu_items()}
-  </ul>
 
-</div>
+${parent.body()}
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 4bdf5862..710dec4a 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -1,318 +1,390 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if master.has_perm('edit_row'):
-      ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))}
-      <script type="text/javascript">
-
-        % if not batch.executed:
-        // keep track of which cost value is currently being edited
-        var editing_catalog_cost = null;
-        var editing_invoice_cost = null;
-
-        function start_editing(td) {
-            var value = null;
-            var text = td.text().replace(/^\s+|\s+$/g, '');
-            if (text) {
-                td.data('previous-value', text);
-                td.text('');
-                value = parseFloat(text.replace('$', ''));
-            }
-            var input = $('<input type="text" />');
-            td.append(input);
-            value = value ? value.toString() : '';
-            input.val(value).select().focus();
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+    % if allow_edit_catalog_unit_cost:
+        td.c_catalog_unit_cost {
+            cursor: pointer;
+            background-color: #fcc;
         }
-
-        function start_editing_catalog_cost(td) {
-            start_editing(td);
-            editing_catalog_cost = td;
+        tr.catalog_cost_confirmed td.c_catalog_unit_cost {
+            background-color: #cfc;
         }
-
-        function start_editing_invoice_cost(td) {
-            start_editing(td);
-            editing_invoice_cost = td;
+    % endif
+    % if allow_edit_invoice_unit_cost:
+        td.c_invoice_unit_cost {
+            cursor: pointer;
+            background-color: #fcc;
         }
-
-        function start_editing_next_catalog_cost() {
-            var tr = editing_catalog_cost.parents('tr:first');
-            var next = tr.next('tr:first');
-            if (next.length) {
-                start_editing_catalog_cost(next.find('td.catalog_unit_cost'));
-            } else {
-                editing_catalog_cost = null;
-            }
+        tr.invoice_cost_confirmed td.c_invoice_unit_cost {
+            background-color: #cfc;
         }
+    % endif
+  </style>
+</%def>
 
-        function start_editing_next_invoice_cost() {
-            var tr = editing_invoice_cost.parents('tr:first');
-            var next = tr.next('tr:first');
-            if (next.length) {
-                start_editing_invoice_cost(next.find('td.invoice_unit_cost'));
-            } else {
-                editing_invoice_cost = null;
-            }
-        }
-
-        function cancel_edit(td) {
-            var input = td.find('input');
-            input.blur();
-            input.remove();
-            var value = td.data('previous-value');
-            if (value) {
-                td.text(value);
-            }
-        }
-
-        function cancel_edit_catalog_cost() {
-            cancel_edit(editing_catalog_cost);
-            editing_catalog_cost = null;
-        }
-
-        function cancel_edit_invoice_cost() {
-            cancel_edit(editing_invoice_cost);
-            editing_invoice_cost = null;
-        }
-
-        % endif
-
-        $(function() {
-
-            % if not batch.executed:
-            $('.grid-wrapper').on('click', '.grid td.catalog_unit_cost', function() {
-                if (editing_catalog_cost) {
-                    editing_catalog_cost.find('input').focus();
-                    return
-                }
-                if (editing_invoice_cost) {
-                    editing_invoice_cost.find('input').focus();
-                    return
-                }
-                var td = $(this);
-                start_editing_catalog_cost(td);
-            });
-
-            $('.grid-wrapper').on('click', '.grid td.invoice_unit_cost', function() {
-                if (editing_invoice_cost) {
-                    editing_invoice_cost.find('input').focus();
-                    return
-                }
-                if (editing_catalog_cost) {
-                    editing_catalog_cost.find('input').focus();
-                    return
-                }
-                var td = $(this);
-                start_editing_invoice_cost(td);
-            });
-
-            $('.grid-wrapper').on('keyup', '.grid td.catalog_unit_cost input', function(event) {
-                var input = $(this);
-
-                // let numeric keys modify input value
-                if (! key_modifies(event)) {
-
-                    // when user presses Enter while editing cost value, submit
-                    // value to server for immediate persistence
-                    if (event.which == 13) {
-                        $('.grid-wrapper').mask("Updating cost...");
-                        var url = '${url('receiving.update_row_cost', uuid=batch.uuid)}';
-                        var td = input.parents('td:first');
-                        var tr = td.parents('tr:first');
-                        var data = {
-                            '_csrf': $('[name="_csrf"]').val(),
-                            'row_uuid': tr.data('uuid'),
-                            'catalog_unit_cost': input.val()
-                        };
-                        $.post(url, data, function(data) {
-                            if (data.error) {
-                                alert(data.error);
-                            } else {
-                                var total = null;
-
-                                // update catalog cost for row
-                                td.text(data.row.catalog_unit_cost);
-
-                                // mark cost as confirmed
-                                if (data.row.catalog_cost_confirmed) {
-                                    tr.addClass('catalog_cost_confirmed');
-                                }
-
-                                input.blur();
-                                input.remove();
-                                start_editing_next_catalog_cost();
-                            }
-                            $('.grid-wrapper').unmask();
-                        });
-
-                    // When user presses Escape while editing totals, cancel the edit.
-                    } else if (event.which == 27) {
-                        cancel_edit_catalog_cost();
-
-                    // Most other keys at this point should be unwanted...
-                    } else if (! key_allowed(event)) {
-                        return false;
-                    }
-                }
-            });
-
-            $('.grid-wrapper').on('keyup', '.grid td.invoice_unit_cost input', function(event) {
-                var input = $(this);
-
-                // let numeric keys modify input value
-                if (! key_modifies(event)) {
-
-                    // when user presses Enter while editing cost value, submit
-                    // value to server for immediate persistence
-                    if (event.which == 13) {
-                        $('.grid-wrapper').mask("Updating cost...");
-                        var url = '${url('receiving.update_row_cost', uuid=batch.uuid)}';
-                        var td = input.parents('td:first');
-                        var tr = td.parents('tr:first');
-                        var data = {
-                            '_csrf': $('[name="_csrf"]').val(),
-                            'row_uuid': tr.data('uuid'),
-                            'invoice_unit_cost': input.val()
-                        };
-                        $.post(url, data, function(data) {
-                            if (data.error) {
-                                alert(data.error);
-                            } else {
-                                var total = null;
-
-                                // update unit cost for row
-                                td.text(data.row.invoice_unit_cost);
-
-                                // update invoice total for row
-                                total = tr.find('td.invoice_total_calculated');
-                                total.text('$' + data.row.invoice_total_calculated);
-
-                                // update invoice total for batch
-                                total = $('.form .field-wrapper.invoice_total_calculated .field');
-                                total.text('$' + data.batch.invoice_total_calculated);
-
-                                // mark cost as confirmed
-                                if (data.row.invoice_cost_confirmed) {
-                                    tr.addClass('invoice_cost_confirmed');
-                                }
-
-                                input.blur();
-                                input.remove();
-                                start_editing_next_invoice_cost();
-                            }
-                            $('.grid-wrapper').unmask();
-                        });
-
-                    // When user presses Escape while editing totals, cancel the edit.
-                    } else if (event.which == 27) {
-                        cancel_edit_invoice_cost();
-
-                    // Most other keys at this point should be unwanted...
-                    } else if (! key_allowed(event)) {
-                        return false;
-                    }
-                }
-            });
-            % endif
-
-            $('.grid-wrapper').on('click', '.grid .actions a.transform', function() {
-
-                var form = $('form[name="transform-unit-form"]');
-                var row_uuid = $(this).parents('tr:first').data('uuid');
-                form.find('[name="row_uuid"]').val(row_uuid);
-
-                $.get(form.attr('action'), {row_uuid: row_uuid}, function(data) {
-
-                    if (typeof(data) == 'object') {
-                        alert(data.error);
-
-                    } else {
-                        $('#transform-unit-dialog').html(data);
-                        $('#transform-unit-dialog').dialog({
-                            title: "Transform Pack to Unit Item",
-                            width: 800,
-                            height: 450,
-                            modal: true,
-                            buttons: [
-                                {
-                                    text: "Transform",
-                                    click: function(event) {
-                                        disable_button(dialog_button(event));
-                                        form.submit();
-                                    }
-                                },
-                                {
-                                    text: "Cancel",
-                                    click: function() {
-                                        $(this).dialog('close');
-                                    }
-                                }
-                            ]
-                        });
-                    }
-                });
-
-                return false;
-            });
-
-        });
-
-      </script>
+<%def name="render_po_vs_invoice_helper()">
+  % if master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch):
+      <nav class="panel">
+        <p class="panel-heading">PO vs. Invoice</p>
+        <div class="panel-block">
+          <div style="width: 100%;">
+            ${po_vs_invoice_breakdown_grid}
+          </div>
+        </div>
+      </nav>
   % endif
 </%def>
 
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  % if not batch.executed and master.has_perm('edit_row'):
-      <style type="text/css">
-        .grid tr:not(.header) td.catalog_unit_cost,
-        .grid tr:not(.header) td.invoice_unit_cost {
-          cursor: pointer;
-          background-color: #fcc;
-        }
-        .grid tr.catalog_cost_confirmed:not(.header) td.catalog_unit_cost,
-        .grid tr.invoice_cost_confirmed:not(.header) td.invoice_unit_cost {
-          background-color: #cfc;
-        }
-        .grid td.catalog_unit_cost input,
-        .grid td.invoice_unit_cost input {
-          width: 4rem;
-        }
-      </style>
+<%def name="render_tools_helper()">
+  % if allow_confirm_all_costs or (master.has_perm('auto_receive') and master.can_auto_receive(batch)):
+      <nav class="panel">
+        <p class="panel-heading">Tools</p>
+        <div class="panel-block">
+          <div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;">
+
+            % if allow_confirm_all_costs:
+                <b-button type="is-primary"
+                          icon-pack="fas"
+                          icon-left="check"
+                          @click="confirmAllCostsShowDialog = true">
+                  Confirm All Costs
+                </b-button>
+                <b-modal has-modal-card
+                         :active.sync="confirmAllCostsShowDialog">
+                  <div class="modal-card">
+
+                    <header class="modal-card-head">
+                      <p class="modal-card-title">Confirm All Costs</p>
+                    </header>
+
+                    <section class="modal-card-body">
+                      <p class="block">
+                        You can automatically mark all catalog and invoice
+                        cost amounts as "confirmed" if you wish.
+                      </p>
+                      <p class="block">
+                        Would you like to do this?
+                      </p>
+                    </section>
+
+                    <footer class="modal-card-foot">
+                      <b-button @click="confirmAllCostsShowDialog = false">
+                        Cancel
+                      </b-button>
+                      ${h.form(url(f'{route_prefix}.confirm_all_costs', uuid=batch.uuid), **{'@submit': 'confirmAllCostsSubmitting = true'})}
+                      ${h.csrf_token(request)}
+                      <b-button type="is-primary"
+                                native-type="submit"
+                                :disabled="confirmAllCostsSubmitting"
+                                icon-pack="fas"
+                                icon-left="check">
+                        {{ confirmAllCostsSubmitting ? "Working, please wait..." : "Confirm All" }}
+                      </b-button>
+                      ${h.end_form()}
+                    </footer>
+                  </div>
+                </b-modal>
+            % endif
+
+            % if master.has_perm('auto_receive') and master.can_auto_receive(batch):
+                <b-button type="is-primary"
+                          @click="autoReceiveShowDialog = true"
+                          icon-pack="fas"
+                          icon-left="check">
+                  Auto-Receive All Items
+                </b-button>
+                <b-modal has-modal-card
+                         :active.sync="autoReceiveShowDialog">
+                  <div class="modal-card">
+
+                    <header class="modal-card-head">
+                      <p class="modal-card-title">Auto-Receive All Items</p>
+                    </header>
+
+                    <section class="modal-card-body">
+                      <p class="block">
+                        You can automatically set the "received" quantity to
+                        match the "shipped" quantity for all items, based on
+                        the invoice.
+                      </p>
+                      <p class="block">
+                        Would you like to do so?
+                      </p>
+                    </section>
+
+                    <footer class="modal-card-foot">
+                      <b-button @click="autoReceiveShowDialog = false">
+                        Cancel
+                      </b-button>
+                      ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), **{'@submit': 'autoReceiveSubmitting = true'})}
+                      ${h.csrf_token(request)}
+                      <b-button type="is-primary"
+                                native-type="submit"
+                                :disabled="autoReceiveSubmitting"
+                                icon-pack="fas"
+                                icon-left="check">
+                        {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }}
+                      </b-button>
+                      ${h.end_form()}
+                    </footer>
+                  </div>
+                </b-modal>
+            % endif
+          </div>
+        </div>
+      </nav>
   % endif
 </%def>
 
 <%def name="object_helpers()">
-  ${parent.object_helpers()}
-  ## TODO: for now this is a truck-dump-only feature? maybe should change that
-  % if not request.rattail_config.production() and master.allow_truck_dump:
-      % if not batch.executed and not batch.complete and request.has_perm('admin'):
-          % if (batch.is_truck_dump_parent() and batch.truck_dump_children_first) or not batch.is_truck_dump_related():
-              <div class="object-helper">
-                <h3>Development Tools</h3>
-                <div class="object-helper-content">
-                  ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')}
-                  ${h.csrf_token(request)}
-                  ${h.submit('submit', "Auto-Receive All Items")}
-                  ${h.end_form()}
-                </div>
-              </div>
-          % endif
-      % endif
+  ${self.render_status_breakdown()}
+  ${self.render_po_vs_invoice_helper()}
+  ${self.render_execute_helper()}
+  ${self.render_tools_helper()}
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost:
+      <script type="text/x-template" id="receiving-cost-editor-template">
+        <div>
+          <span v-show="!editing">
+            {{ value }}
+          </span>
+          <b-input v-model="inputValue"
+                   ref="input"
+                   v-show="editing"
+                   size="is-small"
+                   @keydown.native="inputKeyDown"
+                   @focus="selectAll"
+                   @blur="inputBlur"
+                   style="width: 6rem;">
+          </b-input>
+        </div>
+      </script>
   % endif
 </%def>
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-${parent.body()}
+    % if allow_confirm_all_costs:
 
-% if master.allow_truck_dump and request.has_perm('{}.edit_row'.format(permission_prefix)):
-    ${h.form(url('{}.transform_unit_row'.format(route_prefix), uuid=batch.uuid), name='transform-unit-form')}
-    ${h.csrf_token(request)}
-    ${h.hidden('row_uuid')}
-    ${h.end_form()}
+        ThisPageData.confirmAllCostsShowDialog = false
+        ThisPageData.confirmAllCostsSubmitting = false
 
-    <div id="transform-unit-dialog" style="display: none;">
-      <p>hello world</p>
-    </div>
-% endif
+    % endif
+
+    ThisPageData.autoReceiveShowDialog = false
+    ThisPageData.autoReceiveSubmitting = false
+
+    % if po_vs_invoice_breakdown_data is not Undefined:
+        ThisPageData.poVsInvoiceBreakdownData = ${json.dumps(po_vs_invoice_breakdown_data)|n}
+
+        ThisPage.methods.autoFilterPoVsInvoice = function(row) {
+            let filters = []
+            if (row.key == 'both') {
+                filters = [
+                    {key: 'po_line_number',
+                     verb: 'is_not_null'},
+                    {key: 'invoice_line_number',
+                     verb: 'is_not_null'},
+                ]
+            } else if (row.key == 'po_not_invoice') {
+                filters = [
+                    {key: 'po_line_number',
+                     verb: 'is_not_null'},
+                    {key: 'invoice_line_number',
+                     verb: 'is_null'},
+                ]
+            } else if (row.key == 'invoice_not_po') {
+                filters = [
+                    {key: 'po_line_number',
+                     verb: 'is_null'},
+                    {key: 'invoice_line_number',
+                     verb: 'is_not_null'},
+                ]
+            } else if (row.key == 'neither') {
+                filters = [
+                    {key: 'po_line_number',
+                     verb: 'is_null'},
+                    {key: 'invoice_line_number',
+                     verb: 'is_null'},
+                ]
+            }
+
+            if (!filters.length) {
+                return
+            }
+
+            this.$refs.rowGrid.setFilters(filters)
+            document.getElementById('rowGrid').scrollIntoView({
+                behavior: 'smooth',
+            })
+        }
+
+    % endif
+
+    % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost:
+
+        let ReceivingCostEditor = {
+            template: '#receiving-cost-editor-template',
+            mixins: [SimpleRequestMixin],
+            props: {
+                row: Object,
+                'field': String,
+                value: String,
+            },
+            data() {
+                return {
+                    inputValue: this.value,
+                    editing: false,
+                }
+            },
+            methods: {
+
+                selectAll() {
+                    // nb. must traverse into the <b-input> element
+                    let trueInput = this.$refs.input.$el.firstChild
+                    trueInput.select()
+                },
+
+                startEdit() {
+                    // nb. must strip $ sign etc. to get the real value
+                    let value = this.value.replace(/[^\-\d\.]/g, '')
+                    this.inputValue = parseFloat(value) || null
+                    this.editing = true
+                    this.$nextTick(() => {
+                        this.$refs.input.focus()
+                    })
+                },
+
+                inputKeyDown(event) {
+
+                    // when user presses Enter while editing cost value, submit
+                    // value to server for immediate persistence
+                    if (event.which == 13) {
+                        this.submitEdit()
+
+                    // when user presses Escape, cancel the edit
+                    } else if (event.which == 27) {
+                        this.cancelEdit()
+                    }
+                },
+
+                inputBlur(event) {
+                    // always assume user meant to cancel
+                    this.cancelEdit()
+                },
+
+                cancelEdit() {
+                    // reset input to discard any user entry
+                    this.inputValue = this.value
+                    this.editing = false
+                    this.$emit('cancel-edit')
+                },
+
+                submitEdit() {
+                    let url = '${url('{}.update_row_cost'.format(route_prefix), uuid=batch.uuid)}'
+
+                    let params = {
+                        row_uuid: this.$props.row.uuid,
+                    }
+                    params[this.$props.field] = this.inputValue
+
+                    this.simplePOST(url, params, response => {
+
+                        // let parent know cost value has changed
+                        // (this in turn will update data in *this*
+                        // component, and display will refresh)
+                        this.$emit('input', response.data.row[this.$props.field],
+                                   this.$props.row._index)
+
+                        // and hide the input box
+                        this.editing = false
+                    })
+                },
+            },
+        }
+
+        Vue.component('receiving-cost-editor', ReceivingCostEditor)
+
+    % endif
+
+    % if allow_edit_catalog_unit_cost:
+
+        ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) {
+
+            // start edit for clicked cell
+            this.$refs['catalogUnitCost_' + row.uuid].startEdit()
+        }
+
+        ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) {
+
+            // update display to indicate cost was confirmed
+            this.addRowClass(index, 'catalog_cost_confirmed')
+
+            // advance to next editable cost input...
+
+            // first try invoice cost within same row
+            let thisRow = this.data[index]
+            let cost = this.$refs['invoiceUnitCost_' + thisRow.uuid]
+            if (!cost) {
+
+                // or, try catalog cost from next row
+                let nextRow = this.data[index + 1]
+                if (nextRow) {
+                    cost = this.$refs['catalogUnitCost_' + nextRow.uuid]
+                }
+            }
+
+            // start editing next cost if found
+            if (cost) {
+                cost.startEdit()
+            }
+        }
+
+    % endif
+
+    % if allow_edit_invoice_unit_cost:
+
+        ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) {
+
+            // start edit for clicked cell
+            this.$refs['invoiceUnitCost_' + row.uuid].startEdit()
+        }
+
+        ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) {
+
+            // update display to indicate cost was confirmed
+            this.addRowClass(index, 'invoice_cost_confirmed')
+
+            // advance to next editable cost input...
+
+            // nb. always advance to next row, regardless of field
+            let nextRow = this.data[index + 1]
+            if (nextRow) {
+
+                // first try catalog cost from next row
+                let cost = this.$refs['catalogUnitCost_' + nextRow.uuid]
+                if (!cost) {
+
+                    // or, try invoice cost from next row
+                    cost = this.$refs['invoiceUnitCost_' + nextRow.uuid]
+                }
+
+                // start editing next cost if found
+                if (cost) {
+                    cost.startEdit()
+                }
+            }
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako
index 9ba6a0bb..086754c6 100644
--- a/tailbone/templates/receiving/view_row.mako
+++ b/tailbone/templates/receiving/view_row.mako
@@ -1,19 +1,722 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view_row.mako" />
 
-<%def name="object_helpers()">
-  ${parent.object_helpers()}
-  % if not batch.executed and not batch.is_truck_dump_child():
-      <div class="object-helper">
-        <h3>Receiving Tools</h3>
-        <div class="object-helper-content">
-          <div style="white-space: nowrap;">
-            ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')}
-            ${h.link_to("Declare Credit", url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')}
-          </div>
-        </div>
-      </div>
-  % endif
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+
+    nav.panel {
+        margin: 0.5rem;
+    }
+
+    .header-fields {
+        margin-top: 1rem;
+    }
+
+    .header-fields .field.is-horizontal {
+        margin-left: 3rem;
+    }
+
+    .header-fields .field.is-horizontal .field-label .label {
+        white-space: nowrap;
+    }
+
+    .quantity-form-fields {
+        margin: 2rem;
+    }
+
+    .quantity-form-fields .field.is-horizontal .field-label .label {
+        text-align: left;
+        width: 8rem;
+    }
+
+    .remove-credit .field.is-horizontal .field-label .label {
+        white-space: nowrap;
+    }
+
+  </style>
 </%def>
 
-${parent.body()}
+<%def name="page_content()">
+
+  <b-field grouped class="header-fields">
+
+    <b-field label="Sequence" horizontal>
+      {{ rowData.sequence }}
+    </b-field>
+
+    <b-field label="Status" horizontal>
+      {{ rowData.status }}
+    </b-field>
+
+    <b-field label="Calculated Total" horizontal>
+      {{ rowData.invoice_total_calculated }}
+    </b-field>
+
+  </b-field>
+
+  <div style="display: flex;">
+
+    <nav class="panel">
+      <p class="panel-heading">Product</p>
+      <div class="panel-block">
+        <div style="display: flex; gap: 1rem;">
+          <div style="flex-grow: 1;"
+             % if request.use_oruga:
+                 class="form-wrapper"
+             % endif
+            >
+            ${form.render_field_readonly('item_entry')}
+            % if row.product:
+                ${form.render_field_readonly(product_key_field)}
+                ${form.render_field_readonly('product')}
+            % else:
+                ${form.render_field_readonly(product_key_field)}
+                % if product_key_field != 'upc':
+                    ${form.render_field_readonly('upc')}
+                % endif
+                ${form.render_field_readonly('brand_name')}
+                ${form.render_field_readonly('description')}
+                ${form.render_field_readonly('size')}
+            % endif
+            ${form.render_field_readonly('vendor_code')}
+            ${form.render_field_readonly('case_quantity')}
+            ${form.render_field_readonly('catalog_unit_cost')}
+          </div>
+          % if image_url:
+              <div>
+                ${h.image(image_url, "Product Image", width=150, height=150)}
+              </div>
+          % endif
+        </div>
+      </div>
+    </nav>
+
+    <nav class="panel">
+      <p class="panel-heading">Quantities</p>
+      <div class="panel-block">
+        <div>
+          <div class="quantity-form-fields">
+
+            <b-field label="Ordered" horizontal>
+              {{ rowData.ordered }}
+            </b-field>
+
+            <hr />
+
+            <b-field label="Shipped" horizontal>
+              {{ rowData.shipped }}
+            </b-field>
+
+            <hr />
+
+            <b-field label="Received" horizontal
+                     v-if="rowData.received">
+              {{ rowData.received }}
+            </b-field>
+
+            <b-field label="Damaged" horizontal
+                     v-if="rowData.damaged">
+              {{ rowData.damaged }}
+            </b-field>
+
+            <b-field label="Expired" horizontal
+                     v-if="rowData.expired">
+              {{ rowData.expired }}
+            </b-field>
+
+            <b-field label="Mispick" horizontal
+                     v-if="rowData.mispick">
+              {{ rowData.mispick }}
+            </b-field>
+
+            <b-field label="Missing" horizontal
+                     v-if="rowData.missing">
+              {{ rowData.missing }}
+            </b-field>
+
+          </div>
+
+          % if master.has_perm('edit_row') and master.row_editable(row):
+              <div class="buttons">
+                <b-button type="is-primary"
+                          @click="accountForProductInit()"
+                          icon-pack="fas"
+                          icon-left="check">
+                  Account for Product
+                </b-button>
+                <b-button type="is-warning"
+                          @click="declareCreditInit()"
+                          :disabled="!rowData.received"
+                          icon-pack="fas"
+                          icon-left="thumbs-down">
+                  Declare Credit
+                </b-button>
+              </div>
+          % endif
+
+        </div>
+      </div>
+    </nav>
+
+  </div>
+
+  <${b}-modal has-modal-card
+              % if request.use_oruga:
+                  v-model:active="accountForProductShowDialog"
+              % else:
+                  :active.sync="accountForProductShowDialog"
+              % endif
+              >
+    <div class="modal-card">
+
+      <header class="modal-card-head">
+        <p class="modal-card-title">Account for Product</p>
+      </header>
+
+      <section class="modal-card-body">
+
+        <p class="block">
+          This is for declaring that you have encountered some
+          amount of the product.&nbsp; Ideally you will just
+          "receive" it normally, but you can indicate a "credit"
+          state if there is something amiss.
+        </p>
+
+        <b-field grouped>
+
+          % if allow_cases:
+              <b-field label="Case Qty.">
+                <span class="control">
+                  {{ rowData.case_quantity }}
+                </span>
+              </b-field>
+
+              <span class="control">
+                &nbsp;
+              </span>
+          % endif
+
+          <b-field label="Product State"
+                   :type="accountForProductMode ? null : 'is-danger'">
+            <b-select v-model="accountForProductMode">
+              <option v-for="mode in possibleReceivingModes"
+                      :key="mode"
+                      :value="mode">
+                {{ mode }}
+              </option>
+            </b-select>
+          </b-field>
+
+          <b-field label="Expiration Date"
+                   v-show="accountForProductMode == 'expired'"
+                   :type="accountForProductExpiration ? null : 'is-danger'">
+            <tailbone-datepicker v-model="accountForProductExpiration">
+            </tailbone-datepicker>
+          </b-field>
+
+        </b-field>
+
+        <div style="display: flex; gap: 0.5rem; align-items: center;">
+
+          <numeric-input v-model="accountForProductQuantity"
+                         ref="accountForProductQuantityInput">
+          </numeric-input>
+
+          % if allow_cases:
+              % if request.use_oruga:
+                  <div>
+                    <o-button label="Units"
+                              :variant="accountForProductUOM == 'units' ? 'primary' : null"
+                              @click="accountForProductUOMClicked('units')" />
+                    <o-button label="Cases"
+                              :variant="accountForProductUOM == 'cases' ? 'primary' : null"
+                              @click="accountForProductUOMClicked('cases')" />
+                  </div>
+              % else:
+                  <b-field
+                    ## TODO: a bit hacky, but otherwise buefy styles throw us off here
+                    style="margin-bottom: 0;">
+                    <b-radio-button v-model="accountForProductUOM"
+                                    @click.native="accountForProductUOMClicked('units')"
+                                    native-value="units">
+                      Units
+                    </b-radio-button>
+                    <b-radio-button v-model="accountForProductUOM"
+                                    @click.native="accountForProductUOMClicked('cases')"
+                                    native-value="cases">
+                      Cases
+                    </b-radio-button>
+                  </b-field>
+              % endif
+              <span v-if="accountForProductUOM == 'cases' && accountForProductQuantity">
+                = {{ accountForProductTotalUnits }}
+              </span>
+
+          % else:
+              <input type="hidden" v-model="accountForProductUOM" />
+              <span>Units</span>
+          % endif
+
+        </div>
+      </section>
+
+      <footer class="modal-card-foot">
+        <b-button @click="accountForProductShowDialog = false">
+          Cancel
+        </b-button>
+        <b-button type="is-primary"
+                  @click="accountForProductSubmit()"
+                  :disabled="accountForProductSubmitDisabled"
+                  icon-pack="fas"
+                  icon-left="check">
+          {{ accountForProductSubmitting ? "Working, please wait..." : "Account for Product" }}
+        </b-button>
+      </footer>
+    </div>
+  </${b}-modal>
+
+  <${b}-modal has-modal-card
+              % if request.use_oruga:
+                  v-model:active="declareCreditShowDialog"
+              % else:
+                  :active.sync="declareCreditShowDialog"
+              % endif
+              >
+    <div class="modal-card">
+
+      <header class="modal-card-head">
+        <p class="modal-card-title">Declare Credit</p>
+      </header>
+
+      <section class="modal-card-body">
+
+        <p class="block">
+          This is for <span class="is-italic">converting</span>
+          some amount you <span class="is-italic">already
+          received</span>, and now declaring there is something
+          wrong with it.
+        </p>
+
+        <b-field grouped>
+
+          <b-field label="Received">
+            <span class="control">
+              {{ rowData.received }}
+            </span>
+          </b-field>
+
+          <span class="control">
+            &nbsp;
+          </span>
+
+          <b-field label="Credit Type"
+                   :type="declareCreditType ? null : 'is-danger'">
+            <b-select v-model="declareCreditType">
+              <option v-for="typ in possibleCreditTypes"
+                      :key="typ"
+                      :value="typ">
+                {{ typ }}
+              </option>
+            </b-select>
+          </b-field>
+
+          <b-field label="Expiration Date"
+                   v-show="declareCreditType == 'expired'"
+                   :type="declareCreditExpiration ? null : 'is-danger'">
+            <tailbone-datepicker v-model="declareCreditExpiration">
+            </tailbone-datepicker>
+          </b-field>
+
+        </b-field>
+
+        <div style="display: flex; gap: 0.5rem; align-items: center;">
+
+            <numeric-input v-model="declareCreditQuantity"
+                           ref="declareCreditQuantityInput">
+            </numeric-input>
+
+            % if allow_cases:
+
+                % if request.use_oruga:
+                    <div>
+                      <o-button label="Units"
+                                :variant="declareCreditUOM == 'units' ? 'primary' : null"
+                                @click="declareCreditUOM = 'units'" />
+                      <o-button label="Cases"
+                                :variant="declareCreditUOM == 'cases' ? 'primary' : null"
+                                @click="declareCreditUOM = 'cases'" />
+                    </div>
+                % else:
+                    <b-field
+                      ## TODO: a bit hacky, but otherwise buefy styles throw us off here
+                      style="margin-bottom: 0;">
+                      <b-radio-button v-model="declareCreditUOM"
+                                      @click.native="declareCreditUOMClicked('units')"
+                                      native-value="units">
+                        Units
+                      </b-radio-button>
+                      <b-radio-button v-model="declareCreditUOM"
+                                      @click.native="declareCreditUOMClicked('cases')"
+                                      native-value="cases">
+                        Cases
+                      </b-radio-button>
+                    </b-field>
+                % endif
+                <span v-if="declareCreditUOM == 'cases' && declareCreditQuantity">
+                  = {{ declareCreditTotalUnits }}
+                </span>
+
+            % else:
+                <b-field>
+                  <input type="hidden" v-model="declareCreditUOM" />
+                  Units
+                </b-field>
+            % endif
+
+        </div>
+      </section>
+
+      <footer class="modal-card-foot">
+        <b-button @click="declareCreditShowDialog = false">
+          Cancel
+        </b-button>
+        <b-button type="is-warning"
+                  @click="declareCreditSubmit()"
+                  :disabled="declareCreditSubmitDisabled"
+                  icon-pack="fas"
+                  icon-left="thumbs-down">
+          {{ declareCreditSubmitting ? "Working, please wait..." : "Declare this Credit" }}
+        </b-button>
+      </footer>
+    </div>
+  </${b}-modal>
+
+  <nav class="panel" >
+    <p class="panel-heading">Credits</p>
+    <div class="panel-block">
+      <div>
+        ${form.render_field_value('credits')}
+      </div>
+    </div>
+  </nav>
+
+  <b-modal has-modal-card
+           :active.sync="removeCreditShowDialog">
+    <div class="modal-card remove-credit">
+
+      <header class="modal-card-head">
+        <p class="modal-card-title">Un-Declare Credit</p>
+      </header>
+
+      <section class="modal-card-body">
+
+        <p class="block">
+          If you un-declare this credit, the quantity below will
+          be added back to the
+          <span class="has-text-weight-bold">Received</span> tally.
+        </p>
+
+        <b-field label="Credit Type" horizontal>
+          {{ removeCreditRow.credit_type }}
+        </b-field>
+
+        <b-field label="Quantity" horizontal>
+          {{ removeCreditRow.shorted }}
+        </b-field>
+
+      </section>
+
+      <footer class="modal-card-foot">
+        <b-button @click="removeCreditShowDialog = false">
+          Cancel
+        </b-button>
+        <b-button type="is-danger"
+                  @click="removeCreditSubmit()"
+                  :disabled="removeCreditSubmitting"
+                  icon-pack="fas"
+                  icon-left="trash">
+          {{ removeCreditSubmitting ? "Working, please wait..." : "Un-Declare this Credit" }}
+        </b-button>
+      </footer>
+    </div>
+  </b-modal>
+
+  <div style="display: flex;">
+
+    % if master.batch_handler.has_purchase_order(batch):
+        <nav class="panel" >
+          <p class="panel-heading">Purchase Order</p>
+          <div class="panel-block">
+            <div
+              % if request.use_oruga:
+                  class="form-wrapper"
+              % endif
+              >
+              ${form.render_field_readonly('po_line_number')}
+              ${form.render_field_readonly('po_unit_cost')}
+              ${form.render_field_readonly('po_case_size')}
+              ${form.render_field_readonly('po_total')}
+            </div>
+          </div>
+        </nav>
+    % endif
+
+    % if master.batch_handler.has_invoice_file(batch):
+        <nav class="panel" >
+          <p class="panel-heading">Invoice</p>
+          <div class="panel-block">
+            <div
+              % if request.use_oruga:
+                  class="form-wrapper"
+              % endif
+              >
+              ${form.render_field_readonly('invoice_number')}
+              ${form.render_field_readonly('invoice_line_number')}
+              ${form.render_field_readonly('invoice_unit_cost')}
+              ${form.render_field_readonly('invoice_case_size')}
+              ${form.render_field_readonly('invoice_total', label="Invoice Total")}
+            </div>
+          </div>
+        </nav>
+    % endif
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+##     ThisPage.methods.editUnitCost = function() {
+##         alert("TODO: not yet implemented")
+##     }
+
+    ThisPage.methods.confirmUnitCost = function() {
+        alert("TODO: not yet implemented")
+    }
+
+    ThisPageData.rowData = ${json.dumps(row_context)|n}
+    ThisPageData.possibleReceivingModes = ${json.dumps(possible_receiving_modes)|n}
+    ThisPageData.possibleCreditTypes = ${json.dumps(possible_credit_types)|n}
+
+    ThisPageData.accountForProductShowDialog = false
+    ThisPageData.accountForProductMode = null
+    ThisPageData.accountForProductQuantity = null
+    ThisPageData.accountForProductUOM = 'units'
+    ThisPageData.accountForProductExpiration = null
+    ThisPageData.accountForProductSubmitting = false
+
+    ThisPage.computed.accountForProductTotalUnits = function() {
+        return this.renderQuantity(this.accountForProductQuantity,
+                                   this.accountForProductUOM)
+    }
+
+    ThisPage.computed.accountForProductSubmitDisabled = function() {
+        if (!this.accountForProductMode) {
+            return true
+        }
+        if (this.accountForProductMode == 'expired' && !this.accountForProductExpiration) {
+            return true
+        }
+        if (!this.accountForProductQuantity || this.accountForProductQuantity == 0) {
+            return true
+        }
+        if (this.accountForProductSubmitting) {
+            return true
+        }
+        return false
+    }
+
+    ThisPage.methods.accountForProductInit = function() {
+        this.accountForProductMode = 'received'
+        this.accountForProductExpiration = null
+        this.accountForProductQuantity = 0
+        this.accountForProductUOM = 'units'
+        this.accountForProductShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.accountForProductQuantityInput.select()
+            this.$refs.accountForProductQuantityInput.focus()
+        })
+    }
+
+    ThisPage.methods.accountForProductUOMClicked = function(uom) {
+
+        % if request.use_oruga:
+            this.accountForProductUOM = uom
+        % endif
+
+        // TODO: this does not seem to work as expected..even though
+        // the code appears to be correct
+        this.$nextTick(() => {
+            this.$refs.accountForProductQuantityInput.focus()
+        })
+    }
+
+    ThisPage.methods.accountForProductSubmit = function() {
+
+        let qty = parseFloat(this.accountForProductQuantity)
+        if (qty == NaN || !qty) {
+            this.$buefy.toast.open({
+                message: "You must enter a quantity.",
+                type: 'is-warning',
+                duration: 4000, // 4 seconds
+            })
+            return
+        }
+
+        if (this.accountForProductMode != 'received' && qty < 0) {
+            this.$buefy.toast.open({
+                message: "Negative amounts are only allowed for the \"received\" state.",
+                type: 'is-warning',
+                duration: 4000, // 4 seconds
+            })
+            return
+        }
+
+        this.accountForProductSubmitting = true
+        let url = '${url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}'
+        let params = {
+            mode: this.accountForProductMode,
+            quantity: {cases: null, units: null},
+            expiration_date: this.accountForProductExpiration,
+        }
+
+        if (this.accountForProductUOM == 'cases') {
+            params.quantity.cases = this.accountForProductQuantity
+        } else {
+            params.quantity.units = this.accountForProductQuantity
+        }
+
+        this.submitForm(url, params, response => {
+            this.rowData = response.data.row
+            this.accountForProductSubmitting = false
+            this.accountForProductShowDialog = false
+        }, response => {
+            this.accountForProductSubmitting = false
+        })
+    }
+
+    ThisPageData.declareCreditShowDialog = false
+    ThisPageData.declareCreditType = null
+    ThisPageData.declareCreditExpiration = null
+    ThisPageData.declareCreditQuantity = null
+    ThisPageData.declareCreditUOM = 'units'
+    ThisPageData.declareCreditSubmitting = false
+
+    ThisPage.methods.renderQuantity = function(qty, uom) {
+        qty = parseFloat(qty)
+        if (qty == NaN) {
+            return "n/a"
+        }
+        if (uom == 'cases') {
+            qty *= this.rowData.case_quantity
+        }
+        if (qty == NaN) {
+            return "n/a"
+        }
+        if (qty == 1) {
+            return "1 unit"
+        }
+        if (qty == -1) {
+            return "-1 unit"
+        }
+        if (Math.round(qty) == qty) {
+            return qty.toString() + " units"
+        }
+        return qty.toFixed(4) + " units"
+    }
+
+    ThisPage.computed.declareCreditTotalUnits = function() {
+        return this.renderQuantity(this.declareCreditQuantity,
+                                   this.declareCreditUOM)
+    }
+
+    ThisPage.computed.declareCreditSubmitDisabled = function() {
+        if (!this.declareCreditType) {
+            return true
+        }
+        if (this.declareCreditType == 'expired' && !this.declareCreditExpiration) {
+            return true
+        }
+        if (!this.declareCreditQuantity || this.declareCreditQuantity == 0) {
+            return true
+        }
+        if (this.declareCreditSubmitting) {
+            return true
+        }
+        return false
+    }
+
+    ThisPage.methods.declareCreditInit = function() {
+        this.declareCreditType = null
+        this.declareCreditExpiration = null
+        % if allow_cases:
+            if (this.rowData.cases_received) {
+                this.declareCreditQuantity = this.rowData.cases_received
+                this.declareCreditUOM = 'cases'
+            } else {
+                this.declareCreditQuantity = this.rowData.units_received
+                this.declareCreditUOM = 'units'
+            }
+        % else:
+            this.declareCreditQuantity = this.rowData.units_received
+            this.declareCreditUOM = 'units'
+        % endif
+        this.declareCreditShowDialog = true
+    }
+
+    ThisPage.methods.declareCreditSubmit = function() {
+        this.declareCreditSubmitting = true
+        let url = '${url('{}.declare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}'
+        let params = {
+            credit_type: this.declareCreditType,
+            cases: null,
+            units: null,
+            expiration_date: this.declareCreditExpiration,
+        }
+
+        % if allow_cases:
+            if (this.declareCreditUOM == 'cases') {
+                params.cases = this.declareCreditQuantity
+            } else {
+                params.units = this.declareCreditQuantity
+            }
+        % else:
+            params.units = this.declareCreditQuantity
+        % endif
+
+        this.submitForm(url, params, response => {
+            this.rowData = response.data.row
+            this.declareCreditSubmitting = false
+            this.declareCreditShowDialog = false
+        }, response => {
+            this.declareCreditSubmitting = false
+        })
+    }
+
+    ThisPageData.removeCreditShowDialog = false
+    ThisPageData.removeCreditRow = {}
+    ThisPageData.removeCreditSubmitting = false
+
+    ThisPage.methods.removeCreditInit = function(row) {
+        this.removeCreditRow = row
+        this.removeCreditShowDialog = true
+    }
+
+    ThisPage.methods.removeCreditSubmit = function() {
+        this.removeCreditSubmitting = true
+        let url = '${url('{}.undeclare_credit'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid)}'
+        let params = {
+            uuid: this.removeCreditRow.uuid,
+        }
+
+        this.submitForm(url, params, response => {
+            this.rowData = response.data.row
+            this.removeCreditSubmitting = false
+            this.removeCreditShowDialog = false
+        })
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/reports/base.mako b/tailbone/templates/reports/base.mako
deleted file mode 100644
index 5833b0ec..00000000
--- a/tailbone/templates/reports/base.mako
+++ /dev/null
@@ -1,3 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/base.mako" />
-${parent.body()}
diff --git a/tailbone/templates/reports/choose.mako b/tailbone/templates/reports/choose.mako
deleted file mode 100644
index 58c9ee22..00000000
--- a/tailbone/templates/reports/choose.mako
+++ /dev/null
@@ -1,130 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/form.mako" />
-
-<%def name="title()">${index_title}</%def>
-
-<%def name="content_title()"></%def>
-
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    var report_descriptions = ${json.dumps(report_descriptions)|n};
-
-    function show_description(key) {
-        var desc = report_descriptions[key];
-        $('#report-description').text(desc);
-    }
-
-    $(function() {
-
-        var report_type = $('select[name="report_type"]');
-
-        report_type.change(function(event) {
-            show_description(report_type.val());
-        });
-
-    });
-
-  </script>
-  % endif
-</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-
-    % if use_form:
-        % if use_buefy:
-            #report-description {
-              margin-left: 2em;
-            }
-        % else:
-            #report-description {
-              margin-top: 2em;
-              margin-left: 2em;
-            }
-        % endif
-    % else:
-        .report-selection {
-          margin-left: 10em;
-          margin-top: 3em;
-        }
-
-        .report-selection h3 {
-            margin-top: 2em;
-        }
-    % endif
-
-  </style>
-</%def>
-
-<%def name="context_menu_items()">
-  % if request.has_perm('report_output.list'):
-      ${h.link_to("View Generated Reports", url('report_output'))}
-  % endif
-</%def>
-
-<%def name="render_buefy_form()">
-  <div class="form">
-    <p>Please select the type of report you wish to generate.</p>
-    <br />
-    <div style="display: flex;">
-      <tailbone-form v-on:report-change="reportChanged"></tailbone-form>
-      <div id="report-description">{{ reportDescription }}</div>
-    </div>
-  </div>
-</%def>
-
-<%def name="page_content()">
-  % if use_form:
-      % if use_buefy:
-          ${parent.page_content()}
-      % else:
-      <div class="form-wrapper">
-        <p>Please select the type of report you wish to generate.</p>
-
-        <div style="display: flex;">
-          ${form.render()|n}
-          <div id="report-description"></div>
-        </div>
-
-      </div><!-- form-wrapper -->
-      % endif
-  % else:
-      <div>
-        <p>Please select the type of report you wish to generate.</p>
-
-        <div class="report-selection">
-          % for key in sorted_reports:
-              <% report = reports[key] %>
-              <h3>${h.link_to(report.name, url('generate_specific_report', type_key=key))}</h3>
-              <p>${report.__doc__}</p>
-          % endfor
-        </div>
-      </div>
-  % endif
-</%def>
-
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
-  <script type="text/javascript">
-
-    TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n}
-
-    TailboneForm.methods.reportTypeChanged = function(reportType) {
-        this.$emit('report-change', this.reportDescriptions[reportType])
-    }
-
-    ThisPageData.reportDescription = null
-
-    ThisPage.methods.reportChanged = function(description) {
-        this.reportDescription = description
-    }
-
-  </script>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generate.mako b/tailbone/templates/reports/generate.mako
deleted file mode 100644
index 57e72385..00000000
--- a/tailbone/templates/reports/generate.mako
+++ /dev/null
@@ -1,29 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/form.mako" />
-
-<%def name="title()">${index_title} &raquo; ${report.name}</%def>
-
-<%def name="content_title()">${report.name}</%def>
-
-<%def name="context_menu_items()">
-  % if request.has_perm('report_output.list'):
-      ${h.link_to("View Generated Reports", url('report_output'))}
-  % endif
-</%def>
-
-<%def name="render_buefy_form()">
-  <div class="form">
-    <p style="padding: 1em;">${report.__doc__}</p>
-    <br />
-    <tailbone-form></tailbone-form>
-  </div>
-</%def>
-
-<%def name="render_form()">
-  % if not use_buefy:
-  <p style="padding: 1em;">${report.__doc__}</p>
-  % endif
-  ${parent.render_form()}
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako
new file mode 100644
index 00000000..0921530c
--- /dev/null
+++ b/tailbone/templates/reports/generated/choose.mako
@@ -0,0 +1,73 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/create.mako" />
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+
+    % if use_form:
+        #report-description {
+          margin-left: 2em;
+        }
+    % else:
+        .report-selection {
+          margin-left: 10em;
+          margin-top: 3em;
+        }
+
+        .report-selection h3 {
+            margin-top: 2em;
+        }
+    % endif
+
+  </style>
+</%def>
+
+<%def name="render_form()">
+  <div class="form">
+    <p>Please select the type of report you wish to generate.</p>
+    <br />
+    <div style="display: flex;">
+      <tailbone-form v-on:report-change="reportChanged"></tailbone-form>
+      <div id="report-description">{{ reportDescription }}</div>
+    </div>
+  </div>
+</%def>
+
+<%def name="page_content()">
+  % if use_form:
+      ${parent.page_content()}
+  % else:
+      <div>
+        <br />
+        <p>Please select the type of report you wish to generate.</p>
+
+        <div class="report-selection">
+          % for key in sorted_reports:
+              <% report = reports[key] %>
+              <h3>${h.link_to(report.name, url('generate_specific_report', type_key=key))}</h3>
+              <p>${report.__doc__}</p>
+          % endfor
+        </div>
+      </div>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n}
+
+    ${form.vue_component}.methods.reportTypeChanged = function(reportType) {
+        this.$emit('report-change', this.reportDescriptions[reportType])
+    }
+
+    ThisPageData.reportDescription = null
+
+    ThisPage.methods.reportChanged = function(description) {
+        this.reportDescription = description
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/reports/generated/configure.mako b/tailbone/templates/reports/generated/configure.mako
new file mode 100644
index 00000000..50109702
--- /dev/null
+++ b/tailbone/templates/reports/generated/configure.mako
@@ -0,0 +1,22 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Generating</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If not set, reports are shown as simple list of hyperlinks.">
+      <b-checkbox name="tailbone.reporting.choosing_uses_form"
+                  v-model="simpleSettings['tailbone.reporting.choosing_uses_form']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show report chooser as form, with dropdown
+      </b-checkbox>
+    </b-field>
+
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako
new file mode 100644
index 00000000..f60a9819
--- /dev/null
+++ b/tailbone/templates/reports/generated/delete.mako
@@ -0,0 +1,11 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/delete.mako" />
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    % if params_data is not Undefined:
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
+    % endif
+  </script>
+</%def>
diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako
new file mode 100644
index 00000000..2b8fa66c
--- /dev/null
+++ b/tailbone/templates/reports/generated/generate.mako
@@ -0,0 +1,28 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/form.mako" />
+
+<%def name="title()">${index_title} &raquo; ${report.name}</%def>
+
+<%def name="content_title()">New Report:&nbsp; ${report.name}</%def>
+
+<%def name="render_form()">
+  <div class="form">
+    <p class="block">
+      ${report.__doc__}
+    </p>
+    % if report.help_url:
+        <p class="block">
+          <b-button icon-pack="fas"
+                    icon-left="question-circle"
+                    tag="a" target="_blank"
+                    href="${report.help_url}">
+            Help for this report
+          </b-button>
+        </p>
+    % endif
+    <tailbone-form></tailbone-form>
+  </div>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako
index c7d34efa..cce6f346 100644
--- a/tailbone/templates/reports/generated/view.mako
+++ b/tailbone/templates/reports/generated/view.mako
@@ -1,11 +1,33 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if request.has_perm('{}.generate'.format(permission_prefix)):
-      <li>${h.link_to("Generate new Report", url('generate_report'))}</li>
+<%def name="object_helpers()">
+  % if master.has_perm('create'):
+      <nav class="panel">
+        <p class="panel-heading">Tools</p>
+        <div class="panel-block buttons">
+          <div style="display: flex; flex-direction: column;">
+          <once-button type="is-primary"
+                       % if rerun_report_url:
+                       tag="a" href="${rerun_report_url}"
+                       % else:
+                       disabled title="Unknown report type"
+                       % endif
+                       text="Re-run This Report"
+                       icon-pack="fas"
+                       icon-left="arrow-circle-right">
+          </once-button>
+          </div>
+        </div>
+      </nav>
   % endif
 </%def>
 
-${parent.body()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    % if params_data is not Undefined:
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
+    % endif
+  </script>
+</%def>
diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako
index 41c74cda..cc5adc10 100644
--- a/tailbone/templates/reports/inventory.mako
+++ b/tailbone/templates/reports/inventory.mako
@@ -1,35 +1,57 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/reports/base.mako" />
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
 
-<%def name="title()">Report : Inventory Worksheet</%def>
+<%def name="title()">Inventory Worksheet</%def>
 
-<p>Please provide the following criteria to generate your report:</p>
-<br />
+<%def name="page_content()">
 
-${h.form(request.current_route_url())}
-${h.csrf_token(request)}
+  <p class="block">
+    Please provide the following criteria to generate your report:
+  </p>
 
-<div class="field-wrapper">
-  <label for="department">Department</label>
-  <div class="field">
-    <select name="department">
-      % for department in departments:
-          <option value="${department.uuid}">${department.name}</option>
-      % endfor
-    </select>
+  ${h.form(request.current_route_url())}
+  ${h.csrf_token(request)}
+
+  <b-field label="Department">
+    <b-select name="department">
+      <option v-for="dept in departments"
+              :key="dept.uuid"
+              :value="dept.uuid">
+        {{ dept.name }}
+      </option>
+    </b-select>
+  </b-field>
+
+  <b-field>
+    <b-checkbox name="weighted-only" native-value="1">
+      Only include items which are sold by weight.
+    </b-checkbox>
+  </b-field>
+
+  <b-field>
+    <b-checkbox name="exclude-not-for-sale"
+                v-model="excludeNotForSale"
+                native-value="1">
+      Exclude items marked "not for sale".
+    </b-checkbox>
+  </b-field>
+
+  <div class="buttons">
+    <b-button type="is-primary"
+              native-type="submit"
+              icon-pack="fas"
+              icon-left="arrow-circle-right">
+      Generate Report
+    </b-button>
   </div>
-</div>
 
-<div class="field-wrapper">
-  ${h.checkbox('weighted-only', label="Only include items which are sold by weight.")}
-</div>
+  ${h.end_form()}
+</%def>
 
-<div class="field-wrapper">
-  ${h.checkbox('exclude-not-for-sale', label="Exclude items marked \"not for sale\".", checked=True)}
-</div>
-
-<div class="buttons">
-  ${h.submit('submit', "Generate Report")}
-</div>
-
-${h.end_form()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n}
+    ThisPageData.excludeNotForSale = true
+  </script>
+</%def>
diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako
index 1b8d555c..61ccdb16 100644
--- a/tailbone/templates/reports/ordering.mako
+++ b/tailbone/templates/reports/ordering.mako
@@ -1,89 +1,129 @@
 ## -*- coding: utf-8 -*-
-<%inherit file="/reports/base.mako" />
+<%inherit file="/page.mako" />
 
-<%def name="title()">Report : Ordering Worksheet</%def>
+<%def name="title()">Ordering Worksheet</%def>
 
-<%def name="head_tags()">
-  ${parent.head_tags()}
-  <style type="text/css">
+<%def name="page_content()">
 
-    div.grid {
-        clear: none;
-    }
+  <p class="block">
+    Please provide the following criteria to generate your report:
+  </p>
+
+  <div style="max-width: 50%;">
+    ${h.form(request.current_route_url(), **{'@submit': 'validateForm'})}
+    ${h.csrf_token(request)}
+    ${h.hidden('departments', **{':value': 'departmentUUIDs'})}
+
+    <b-field label="Vendor">
+      <tailbone-autocomplete v-model="vendorUUID"
+                             service-url="${url('vendors.autocomplete')}"
+                             name="vendor"
+                             expanded
+                             % if request.use_oruga:
+                                 @update:model-value="vendorChanged"
+                             % else:
+                                 @input="vendorChanged"
+                             % endif
+                             >
+      </tailbone-autocomplete>
+    </b-field>
+
+    <b-field label="Departments">
+      <${b}-table v-if="fetchedDepartments"
+                  :data="departments"
+                  narrowed
+                  checkable
+                  % if request.use_oruga:
+                      v-model:checked-rows="checkedDepartments"
+                  % else:
+                      :checked-rows.sync="checkedDepartments"
+                  % endif
+                  :loading="fetchingDepartments">
+
+        <${b}-table-column field="number"
+                        label="Number"
+                        v-slot="props">
+          {{ props.row.number }}
+        </${b}-table-column>
+
+        <${b}-table-column field="name"
+                        label="Name"
+                        v-slot="props">
+          {{ props.row.name }}
+        </${b}-table-column>
+
+      </${b}-table>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="preferred_only"
+                  v-model="preferredVendorOnly"
+                  native-value="1">
+        Only include products for which this vendor is preferred.
+      </b-checkbox>
+    </b-field>
+
+    ${self.extra_fields()}
+
+    <div class="buttons">
+      <b-button type="is-primary"
+                native-type="submit"
+                icon-pack="fas"
+                icon-left="arrow-circle-right">
+        Generate Report
+      </b-button>
+    </div>
+
+    ${h.end_form()}
+  </div>
 
-  </style>
 </%def>
 
-<p>Please provide the following criteria to generate your report:</p>
-<br />
+<%def name="extra_fields()"></%def>
 
-${h.form(request.current_route_url())}
-${h.hidden('departments', value='')}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-<div class="field-wrapper">
-  ${h.hidden('vendor', value='')}
-  <label for="vendor-name">Vendor:</label>
-  ${h.text('vendor-name', size='40', value='')}
-  <div id="vendor-display" style="display: none;">
-    <span>(no vendor)</span>&nbsp;
-    <button type="button" id="change-vendor">Change</button>
-  </div>
-</div>
+    ThisPageData.vendorUUID = null
+    ThisPageData.departments = []
+    ThisPageData.checkedDepartments = []
+    ThisPageData.preferredVendorOnly = true
+    ThisPageData.fetchingDepartments = false
+    ThisPageData.fetchedDepartments = false
 
-<div class="field-wrapper">
-  <label>Departments:</label>
-  <div class="grid"></div>
-</div>
+    ThisPage.computed.departmentUUIDs = function() {
+        let uuids = []
+        for (let dept of this.checkedDepartments) {
+            uuids.push(dept.uuid)
+        }
+        return uuids.join(',')
+    }
 
-<div class="field-wrapper">
-  ${h.checkbox('preferred_only', label="Include only those products for which this vendor is preferred.", checked=True)}
-</div>
+    ThisPage.methods.vendorChanged = function(uuid) {
+        if (uuid) {
+            this.fetchingDepartments = true
 
-<div class="buttons">
-  ${h.submit('submit', "Generate Report")}
-</div>
+            let url = '${url('departments.by_vendor')}'
+            let params = {uuid: uuid}
+            this.$http.get(url, {params: params}).then(response => {
+                this.departments = response.data
+                this.fetchingDepartments = false
+                this.fetchedDepartments = true
+            })
 
-${h.end_form()}
+        } else {
+            this.departments = []
+            this.fetchedDepartments = false
+        }
+    }
 
-<script type="text/javascript">
+    ThisPage.methods.validateForm = function(event) {
+        if (!this.departmentUUIDs.length) {
+            alert("You must select at least one Department.")
+            event.preventDefault()
+        }
+    }
 
-$(function() {
-
-    var autocompleter = $('#vendor-name').autocomplete({
-        serviceUrl: '${url('vendors.autocomplete')}',
-        width: 300,
-        onSelect: function(value, data) {
-            $('#vendor').val(data);
-            $('#vendor-name').hide();
-            $('#vendor-name').val('');
-            $('#vendor-display span').html(value);
-            $('#vendor-display').show();
-            loading($('div.grid'));
-            $('div.grid').load('${url('departments.by_vendor')}', {'uuid': data});
-        },
-    });
-
-    $('#vendor-name').focus();
-
-    $('#change-vendor').click(function() {
-        $('#vendor').val('');
-        $('#vendor-display').hide();
-        $('#vendor-name').show();
-        $('#vendor-name').focus();
-        $('div.grid').empty();
-    });
-
-    $('form').submit(function() {
-        var depts = [];
-        $('div.grid table tbody tr').each(function() {
-            if ($(this).find('td.checkbox input[type=checkbox]').is(':checked')) {
-                depts.push(get_uuid(this));
-            }
-            $('#departments').val(depts.toString());
-            return true;
-        });
-    });
-    
-});
-
-</script>
+  </script>
+</%def>
diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako
new file mode 100644
index 00000000..5cdf2be5
--- /dev/null
+++ b/tailbone/templates/reports/problems/view.mako
@@ -0,0 +1,76 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="object_helpers()">
+  ${parent.object_helpers()}
+  % if master.has_perm('execute'):
+      <nav class="panel">
+        <p class="panel-heading">Tools</p>
+        <div class="panel-block buttons">
+          <b-button type="is-primary"
+                    @click="runReportShowDialog = true"
+                    icon-pack="fas"
+                    icon-left="arrow-circle-right">
+            Run this Report
+          </b-button>
+        </div>
+      </nav>
+
+      <b-modal has-modal-card
+               :active.sync="runReportShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Run Problem Report</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              You can run this problem report right now if you like.
+            </p>
+
+            <p class="block">
+              Keep in mind the following may receive email, should the
+              report find any problems.
+            </p>
+
+            <ul>
+              % for recip in instance['email_recipients']:
+                  <li>${recip}</li>
+              % endfor
+            </ul>
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="runReportShowDialog = false">
+              Cancel
+            </b-button>
+            ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      native-type="submit"
+                      :disabled="runReportSubmitting"
+                      icon-pack="fas"
+                      icon-left="arrow-circle-right">
+              {{ runReportSubmitting ? "Working, please wait..." : "Run Problem Report" }}
+            </b-button>
+            ${h.end_form()}
+          </footer>
+        </div>
+      </b-modal>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if weekdays_data is not Undefined:
+        ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
+    % endif
+
+    ThisPageData.runReportShowDialog = false
+    ThisPageData.runReportSubmitting = false
+
+  </script>
+</%def>
diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako
index 625b2675..89dd56c3 100644
--- a/tailbone/templates/roles/create.mako
+++ b/tailbone/templates/roles/create.mako
@@ -6,15 +6,11 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     // TODO: this variable name should be more dynamic (?) since this is
     // connected to (and only here b/c of) the permissions field
-    TailboneFormData.showingPermissionGroup = ''
-
+    ${form.vue_component}Data.showingPermissionGroup = ''
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako
index 67f63013..e77cca33 100644
--- a/tailbone/templates/roles/edit.mako
+++ b/tailbone/templates/roles/edit.mako
@@ -6,15 +6,11 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     // TODO: this variable name should be more dynamic (?) since this is
     // connected to (and only here b/c of) the permissions field
-    TailboneFormData.showingPermissionGroup = ''
-
+    ${form.vue_component}Data.showingPermissionGroup = ''
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/roles/find_by_perm.mako b/tailbone/templates/roles/find_by_perm.mako
deleted file mode 100644
index 8908d12e..00000000
--- a/tailbone/templates/roles/find_by_perm.mako
+++ /dev/null
@@ -1,21 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/principal/find_by_perm.mako" />
-
-<%def name="principal_table()">
-  <table>
-    <thead>
-      <tr>
-        <th>Name</th>
-      </tr>
-    </thead>
-    <tbody>
-      % for role in principals:
-          <tr>
-            <td>${h.link_to(role.name, url('roles.view', uuid=role.uuid))}</td>
-          </tr>
-      % endfor
-    </tbody>
-  </table>
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako
index ab3b49df..f5588695 100644
--- a/tailbone/templates/roles/view.mako
+++ b/tailbone/templates/roles/view.mako
@@ -6,22 +6,20 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="page_content()">
-  ${parent.page_content()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-  <h2>Users</h2>
+    % if users_data is not Undefined:
+        ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n}
+    % endif
 
-  % if instance is guest_role:
-      <p>The guest role is implied for all anonymous users, i.e. when not logged in.</p>
-  % elif instance is authenticated_role:
-      <p>The authenticated role is implied for all users, but only when logged in.</p>
-  % elif users:
-      <p>The following users are assigned to this role:</p>
-      ${users.render_grid()|n}
-  % else:
-      <p>There are no users assigned to this role.</p>
-  % endif
+    ThisPage.methods.detachPerson = function(url) {
+        ## TODO: this should require POST! but for now we just redirect..
+        if (confirm("Are you sure you want to detach this person from this customer account?")) {
+            location.href = url
+        }
+    }
+
+  </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako
new file mode 100644
index 00000000..f9c815c2
--- /dev/null
+++ b/tailbone/templates/settings/email/configure.mako
@@ -0,0 +1,139 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">General</h3>
+  <div class="block" style="padding-left: 2rem;">
+    <b-field label="Mail Handler"
+             message="Leave blank for default handler.">
+      <b-input name="rattail.mail.handler"
+               v-model="simpleSettings['rattail.mail.handler']"
+               @input="settingsNeedSaved = true">
+      </b-input>
+    </b-field>
+    <b-field label="Template Paths"
+             message="Leave blank for default paths.">
+      <b-input name="rattail.mail.templates"
+               v-model="simpleSettings['rattail.mail.templates']"
+               @input="settingsNeedSaved = true">
+      </b-input>
+    </b-field>
+  </div>
+
+  <h3 class="block is-size-3">Sending</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="rattail.mail.record_attempts"
+                  v-model="simpleSettings['rattail.mail.record_attempts']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Make record of all attempts to send email
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.mail.send_email_on_failure"
+                  v-model="simpleSettings['rattail.mail.send_email_on_failure']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        When sending an email fails, send another to report the failure
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Testing</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field grouped>
+      <b-field horizontal label="Recipient">
+        <b-input v-model="testRecipient"></b-input>
+      </b-field>
+      <b-button type="is-primary"
+                @click="sendTest()"
+                :disabled="sendingTest">
+        {{ sendingTest ? "Working, please wait..." : "Send Test Email" }}
+      </b-button>
+    </b-field>
+
+    <div class="level">
+      <div class="level-left">
+        <div class="level-item">
+          <p>You can raise a "bogus" error to test if/how that generates email:</p>
+        </div>
+        <div class="level-item">
+          <b-button type="is-primary"
+                    % if request.has_perm('errors.bogus'):
+                    @click="raiseBogusError()"
+                    :disabled="raisingBogusError"
+                    % else:
+                    disabled
+                    title="your permissions do not allow this"
+                    % endif
+                    >
+            % if request.has_perm('errors.bogus'):
+            {{ raisingBogusError ? "Working, please wait..." : "Raise Bogus Error" }}
+            % else:
+            Raise Bogus Error
+            % endif
+          </b-button>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.testRecipient = ${json.dumps(user_email_address)|n}
+    ThisPageData.sendingTest = false
+
+    ThisPage.methods.sendTest = function() {
+        this.sendingTest = true
+        let url = '${url('emailprofiles.send_test')}'
+        let params = {recipient: this.testRecipient}
+        this.simplePOST(url, params, response => {
+            this.$buefy.toast.open({
+                message: "Test email was sent!",
+                type: 'is-success',
+                duration: 4000, // 4 seconds
+            })
+            this.sendingTest = false
+        }, response => {
+            this.sendingTest = false
+        })
+    }
+
+    % if request.has_perm('errors.bogus'):
+
+        ThisPageData.raisingBogusError = false
+
+        ThisPage.methods.raiseBogusError = function() {
+            this.raisingBogusError = true
+
+            let url = '${url('bogus_error')}'
+            this.$http.get(url).then(response => {
+                this.$buefy.toast.open({
+                    message: "Ironically, response was 200 which means we failed to raise an error!\n\nPlease investigate!",
+                    type: 'is-danger',
+                    duration: 5000, // 5 seconds
+                })
+                this.raisingBogusError = false
+            }, response => {
+                this.$buefy.toast.open({
+                    message: "Error was raised; please check your email and/or logs.",
+                    type: 'is-success',
+                    duration: 4000, // 4 seconds
+                })
+                this.raisingBogusError = false
+            })
+        }
+
+    % endif
+  </script>
+</%def>
diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako
new file mode 100644
index 00000000..ab8d6fa4
--- /dev/null
+++ b/tailbone/templates/settings/email/index.mako
@@ -0,0 +1,67 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="render_grid_component()">
+  % if master.has_perm('configure'):
+      <b-field horizontal label="Showing:">
+        <b-select v-model="showEmails" @input="updateVisibleEmails()">
+          <option value="available">Available Emails</option>
+          <option value="all">All Emails</option>
+          <option value="hidden">Hidden Emails</option>
+        </b-select>
+      </b-field>
+  % endif
+
+  ${parent.render_grid_component()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if master.has_perm('configure'):
+      <script>
+
+        ThisPageData.showEmails = 'available'
+
+        ThisPage.methods.updateVisibleEmails = function() {
+            this.$refs.grid.showEmails = this.showEmails
+        }
+
+        ${grid.vue_component}Data.showEmails = 'available'
+
+        ${grid.vue_component}.computed.visibleData = function() {
+
+            if (this.showEmails == 'available') {
+                return this.data.filter(email => email.hidden == 'No')
+
+            } else if (this.showEmails == 'hidden') {
+                return this.data.filter(email => email.hidden == 'Yes')
+            }
+
+            // showing all
+            return this.data
+        }
+
+        ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) {
+            return row.hidden == 'Yes' ? "Un-hide" : "Hide"
+        }
+
+        ${grid.vue_component}.methods.toggleHidden = function(row) {
+            let url = '${url('{}.toggle_hidden'.format(route_prefix))}'
+            let params = {
+                key: row.key,
+                hidden: row.hidden == 'No' ? true : false,
+            }
+            this.submitForm(url, params, response => {
+                // must update "original" data row, since our row arg
+                // may just be a proxy and not trigger view refresh
+                for (let email of this.data) {
+                    if (email.key == row.key) {
+                        email.hidden = params.hidden ? 'Yes' : 'No'
+                    }
+                }
+            })
+        }
+
+      </script>
+  % endif
+</%def>
diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako
index e7568398..73ad7066 100644
--- a/tailbone/templates/settings/email/view.mako
+++ b/tailbone/templates/settings/email/view.mako
@@ -1,48 +1,13 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-    % if not email.get_template('html'):
-      $(function() {
-          $('#preview-html').button('disable');
-          $('#preview-html').attr('title', "There is no HTML template on file for this email.");
-      });
-    % endif
-    % if not email.get_template('txt'):
-      $(function() {
-          $('#preview-txt').button('disable');
-          $('#preview-txt').attr('title', "There is no TXT template on file for this email.");
-      });
-    % endif
-  </script>
-  % endif
-</%def>
-
 <%def name="render_form()">
   ${parent.render_form()}
-  % if not use_buefy:
-      ${h.form(url('email.preview'), name='send-email-preview', class_='autodisable')}
-        ${h.csrf_token(request)}
-        ${h.hidden('email_key', value=instance['key'])}
-        ${h.link_to("Preview HTML", '{}?key={}&type=html'.format(url('email.preview'), instance['key']), id='preview-html', class_='button', target='_blank')}
-        ${h.link_to("Preview TXT", '{}?key={}&type=txt'.format(url('email.preview'), instance['key']), id='preview-txt', class_='button', target='_blank')}
-        or
-        ${h.text('recipient', value=request.user.email_address or '')}
-        ${h.submit('send_{}'.format(instance['key']), value="Send Preview Email")}
-      ${h.end_form()}
-  % endif
-</%def>
-
-<%def name="render_buefy_form()">
-  ${parent.render_buefy_form()}
   <email-preview-tools></email-preview-tools>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="email-preview-tools-template">
 
   ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})}
@@ -92,7 +57,7 @@
       </div>
 
       <div class="control">
-        <input name="recipient" type="email" class="input" value="${request.user.email_address or ''}" />
+        <b-input name="recipient" v-model="userEmailAddress"></b-input>
       </div>
 
       <div class="control">
@@ -107,10 +72,6 @@
 
   ${h.end_form()}
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
   <script type="text/javascript">
 
     const EmailPreviewTools = {
@@ -119,20 +80,29 @@
             return {
                 previewFormButtonText: "Send Preview Email",
                 previewFormSubmitting: false,
+                userEmailAddress: ${json.dumps(user_email_address)|n},
             }
         },
         methods: {
-            submitPreviewForm() {
+            submitPreviewForm(event) {
+                if (!this.userEmailAddress) {
+                    alert("Please provide an email address.")
+                    event.preventDefault()
+                    return
+                }
                 this.previewFormSubmitting = true
                 this.previewFormButtonText = "Working, please wait..."
             }
         }
     }
 
-    Vue.component('email-preview-tools', EmailPreviewTools)
-
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('email-preview-tools', EmailPreviewTools)
+    <% request.register_component('email-preview-tools', 'EmailPreviewTools') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako
index 7543712f..52b48832 100644
--- a/tailbone/templates/shifts/base.mako
+++ b/tailbone/templates/shifts/base.mako
@@ -1,102 +1,13 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/base.mako" />
-<%namespace file="/autocomplete.mako" import="autocomplete" />
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
 
 <%def name="title()">${page_title}</%def>
 
-<%def name="extra_javascript()">
-    ${parent.extra_javascript()}
-    <script type="text/javascript">
-
-      var data_modified = false;
-      var okay_to_leave = true;
-      var previous_selections = {};
-      % if weekdays is not Undefined:
-      var weekdays = [
-          % for i, day in enumerate(weekdays, 1):
-              '${day.strftime('%a %d %b %Y')}'${',' if i < len(weekdays) else ''}
-          % endfor
-      ];
-      % endif
-
-      window.onbeforeunload = function() {
-          if (! okay_to_leave) {
-              return "If you leave this page, you will lose all unsaved changes!";
-          }
-      }
-
-      function employee_selected(uuid, name) {
-          $('#filter-form').submit();
-      }
-
-      function confirm_leave() {
-          if (data_modified) {
-              if (confirm("If you navigate away from this page now, you will lose " +
-                          "unsaved changes.\n\nAre you sure you wish to do this?")) {
-                  okay_to_leave = true;
-                  return true;
-              }
-              return false;
-          }
-          return true;
-      }
-
-      function date_selected(dateText, inst) {
-          if (confirm_leave()) {
-              $('#filter-form').submit();
-          } else {
-              // revert date value
-              $('.week-picker input[name="date"]').val($('.week-picker').data('week'));
-          }
-      }
-
-      $(function() {
-
-          $('#filter-form').submit(function() {
-              $('.timesheet-header').mask("Fetching data");
-          });
-
-          $('.timesheet-header select').each(function() {
-              previous_selections[$(this).attr('name')] = $(this).val();
-          });
-
-          $('.timesheet-header select').selectmenu({
-              change: function(event, ui) {
-                  if (confirm_leave()) {
-                      $('#filter-form').submit();
-                  } else {
-                      var select = ui.item.element.parents('select');
-                      select.val(previous_selections[select.attr('name')]);
-                      select.selectmenu('refresh');
-                  }
-              }
-          });
-
-          $('.timesheet-header a.goto').click(function() {
-              $('.timesheet-header').mask("Fetching data");
-          });
-
-          $('.week-picker button.nav').click(function() {
-              if (confirm_leave()) {
-                  $('.week-picker input[name="date"]').val($(this).data('date'));
-                  $('#filter-form').submit();
-              }
-          });
-
-      });
-
-    </script>
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'))}
 </%def>
 
-<%def name="edit_timetable_javascript()">
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.edit-shifts.js'))}
-</%def>
-
 <%def name="edit_timetable_styles()">
   <style type="text/css">
     .timesheet .day {
@@ -146,7 +57,7 @@
                 <div class="field-wrapper employee">
                   <label>Employee</label>
                   <div class="field">
-                    ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n}
+                    ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n}
                   </div>
                 </div>
             % endif
@@ -241,7 +152,7 @@
       </tr>
     </thead>
     <tbody>
-      % for emp in sorted(employees, key=six.text_type):
+      % for emp in sorted(employees, key=str):
           <tr data-employee-uuid="${emp.uuid}">
             <td class="employee">
               ## TODO: add link to single employee schedule / timesheet here...
@@ -304,5 +215,9 @@
 
 <%def name="render_extra_totals(employee)"></%def>
 
+<%def name="page_content()">
+  ${self.timesheet_wrapper()}
+</%def>
 
-${self.timesheet_wrapper()}
+
+${parent.body()}
diff --git a/tailbone/templates/shifts/schedule_edit.mako b/tailbone/templates/shifts/schedule_edit.mako
index 7157ee27..4455c74d 100644
--- a/tailbone/templates/shifts/schedule_edit.mako
+++ b/tailbone/templates/shifts/schedule_edit.mako
@@ -1,60 +1,6 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
 <%inherit file="/shifts/base.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  ${self.edit_timetable_javascript()}
-  <script type="text/javascript">
-
-    $(function() {
-
-        % if allow_clear:
-        $('.clear-schedule').click(function() {
-            if (confirm("This will remove all shifts from the schedule you're " +
-                        "currently viewing.\n\nAre you sure you wish to do this?")) {
-                $(this).button('disable').button('option', 'label', "Clearing...");
-                okay_to_leave = true;
-                $('#clear-schedule-form').submit();
-            }
-        });
-        % endif
-
-        $('#copy-week').datepicker({
-            dateFormat: 'mm/dd/yy'
-        });
-
-        $('.copy-schedule').click(function() {
-            $('#copy-details').dialog({
-                modal: true,
-                title: "Copy from Another Week",
-                width: '500px',
-                buttons: [
-                    {
-                        text: "Copy Schedule",
-                        click: function(event) {
-                            if (! $('#copy-week').val()) {
-                                alert("You must specify the week from which to copy shift data.");
-                                $('#copy-week').focus();
-                                return;
-                            }
-                            disable_button(dialog_button(event), "Copying Schedule");
-                            $('#copy-schedule-form').submit();
-                        }
-                    },
-                    {
-                        text: "Cancel",
-                        click: function() {
-                            $('#copy-details').dialog('close');
-                        }
-                    }
-                ]
-            });
-        });
-
-    });
-  </script>
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   ${self.edit_timetable_styles()}
@@ -97,43 +43,50 @@
   </div>
 </%def>
 
+<%def name="page_content()">
 
-${self.timesheet_wrapper(with_edit_form=True)}
+  ${self.timesheet_wrapper(with_edit_form=True)}
 
-${edit_tools()}
+  ${edit_tools()}
 
-% if allow_clear:
-${h.form(url('schedule.edit'), id="clear-schedule-form")}
-${h.csrf_token(request)}
-${h.hidden('clear-schedule', value='clear')}
-${h.end_form()}
-% endif
-
-<div id="day-editor" style="display: none;">
-  <div class="shifts"></div>
-  <button type="button" id="add-shift">Add Shift</button>
-</div>
-
-<div id="copy-details" style="display: none;">
-  <p>
-    This tool will replace the currently visible schedule, with one from
-    another week.
-  </p>
-  <p>
-    <strong>NOTE:</strong>&nbsp; If you do this, all shifts in the current
-    schedule will be <em>removed</em>,
-    and then new shifts will be created based on the week you specify.
-  </p>
-  ${h.form(url('schedule.edit'), id='copy-schedule-form')}
+  % if allow_clear:
+  ${h.form(url('schedule.edit'), id="clear-schedule-form")}
   ${h.csrf_token(request)}
-  <label for="copy-week">Copy from week:</label>
-  ${h.text('copy-week')}
+  ${h.hidden('clear-schedule', value='clear')}
   ${h.end_form()}
-</div>
+  % endif
 
-<div id="snippets">
-  <div class="shift" data-uuid="">
-    ${h.text('edit_start_time')} thru ${h.text('edit_end_time')}
-    <button type="button"><span class="ui-icon ui-icon-trash"></span></button>
+  <div id="day-editor" style="display: none;">
+    <div class="shifts"></div>
+    <button type="button" id="add-shift">Add Shift</button>
   </div>
-</div>
+
+  <div id="copy-details" style="display: none;">
+    <p>
+      This tool will replace the currently visible schedule, with one from
+      another week.
+    </p>
+    <p>
+      <strong>NOTE:</strong>&nbsp; If you do this, all shifts in the current
+      schedule will be <em>removed</em>,
+      and then new shifts will be created based on the week you specify.
+    </p>
+    ${h.form(url('schedule.edit'), id='copy-schedule-form')}
+    ${h.csrf_token(request)}
+    <label for="copy-week">Copy from week:</label>
+    ${h.text('copy-week')}
+    ${h.end_form()}
+  </div>
+
+  <div id="snippets">
+    <div class="shift" data-uuid="">
+      ${h.text('edit_start_time')} thru ${h.text('edit_end_time')}
+      <button type="button"><span class="ui-icon ui-icon-trash"></span></button>
+    </div>
+  </div>
+
+</%def>
+
+
+${parent.body()}
+
diff --git a/tailbone/templates/shifts/timesheet.mako b/tailbone/templates/shifts/timesheet.mako
index 562cdb35..b93de6ac 100644
--- a/tailbone/templates/shifts/timesheet.mako
+++ b/tailbone/templates/shifts/timesheet.mako
@@ -1,4 +1,4 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
 <%inherit file="/shifts/base.mako" />
 
 <%def name="context_menu()">
@@ -25,4 +25,4 @@
 </%def>
 
 
-${self.timesheet_wrapper()}
+${parent.body()}
diff --git a/tailbone/templates/shifts/timesheet_edit.mako b/tailbone/templates/shifts/timesheet_edit.mako
index 96035663..c4dc7a6b 100644
--- a/tailbone/templates/shifts/timesheet_edit.mako
+++ b/tailbone/templates/shifts/timesheet_edit.mako
@@ -1,58 +1,6 @@
-## -*- coding: utf-8 -*-
+## -*- coding: utf-8; -*-
 <%inherit file="/shifts/base.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.timesheet.edit.js'))}
-  <script type="text/javascript">
-
-    show_timepicker = false;
-
-    $(function() {
-
-        $('.timesheet').on('click', '.day', function() {
-            editing_day = $(this);
-            var editor = $('#day-editor');
-            var employee = editing_day.siblings('.employee').text();
-            var date = weekdays[editing_day.get(0).cellIndex - 1];
-            var shifts = editor.children('.shifts');
-            shifts.empty();
-            editing_day.children('.shift:not(.deleted)').each(function() {
-                var uuid = $(this).data('uuid');
-                var times = $.trim($(this).children('span').text()).split(' - ');
-                times[0] = times[0] == '??' ? '' : times[0];
-                times[1] = times[1] == '??' ? '' : times[1];
-                add_shift(false, uuid, times[0], times[1]);
-            });
-            if (! shifts.children('.shift').length) {
-                add_shift();
-            }
-            editor.dialog({
-                modal: true,
-                title: employee + ' - ' + date,
-                position: {my: 'center', at: 'center', of: editing_day},
-                width: 'auto',
-                autoResize: true,
-                buttons: [
-                    {
-                        text: "Save Changes",
-                        click: save_dialog
-                    },
-                    {
-                        text: "Cancel",
-                        click: function() {
-                            editor.dialog('close');
-                        }
-                    }
-                ]
-            });
-        });
-
-    });
-
-  </script>
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   ${self.edit_timetable_styles()}
@@ -88,17 +36,23 @@
   ${h.csrf_token(request)}
 </%def>
 
+<%def name="page_content()">
 
-${self.timesheet_wrapper(with_edit_form=True, change_employee='confirm_leave')}
+  ${self.timesheet_wrapper(with_edit_form=True, change_employee='confirm_leave')}
 
-<div id="day-editor" style="display: none;">
-  <div class="shifts"></div>
-  <button type="button" id="add-shift">Add Shift</button>
-</div>
-
-<div id="snippets">
-  <div class="shift" data-uuid="">
-    ${h.text('edit_start_time')} thru ${h.text('edit_end_time')}
-    <button type="button"><span class="ui-icon ui-icon-trash"></span></button>
+  <div id="day-editor" style="display: none;">
+    <div class="shifts"></div>
+    <button type="button" id="add-shift">Add Shift</button>
   </div>
-</div>
+
+  <div id="snippets">
+    <div class="shift" data-uuid="">
+      ${h.text('edit_start_time')} thru ${h.text('edit_end_time')}
+      <button type="button"><span class="ui-icon ui-icon-trash"></span></button>
+    </div>
+  </div>
+
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako
new file mode 100644
index 00000000..34844c5c
--- /dev/null
+++ b/tailbone/templates/tables/create.mako
@@ -0,0 +1,985 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/create.mako" />
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+    .label {
+        white-space: nowrap;
+    }
+  </style>
+</%def>
+
+<%def name="render_this_page()">
+
+  ## scroll target used when navigating prev/next
+  <div ref="showme"></div>
+
+  % if not alembic_current_head:
+      <b-notification type="is-warning"
+                      :closable="false">
+        <p class="block">
+          DB is not up to date!  There are
+          ${h.link_to("pending migrations", url('{}.migrations'.format(route_prefix)))}.
+        </p>
+        <p class="block">
+          (This will be a problem if you wish to auto-generate a migration for a new table.)
+        </p>
+      </b-notification>
+  % endif
+
+  <b-steps v-model="activeStep"
+           animated
+           rounded
+           :has-navigation="false"
+           vertical
+           icon-pack="fas">
+
+    <b-step-item step="1"
+                 value="enter-details"
+                 label="Enter Details"
+                 clickable>
+      <h3 class="is-size-3 block">
+        Enter Details
+      </h3>
+
+      <b-field label="Schema Branch"
+               message="Leave this set to your custom app branch, unless you know what you're doing.">
+        <b-select v-model="alembicBranch"
+                  @input="dirty = true">
+          <option v-for="branch in alembicBranchOptions"
+                  :key="branch"
+                  :value="branch">
+            {{ branch }}
+          </option>
+        </b-select>
+      </b-field>
+
+      <b-field grouped>
+
+        <b-field label="Table Name"
+                 message="Should be singular in nature, i.e. 'widget' not 'widgets'">
+          <b-input v-model="tableName"
+                   @input="dirty = true">
+          </b-input>
+        </b-field>
+
+        <b-field label="Model/Class Name"
+                 message="Should be singular in nature, i.e. 'Widget' not 'Widgets'">
+          <b-input v-model="tableModelName"
+                   @input="dirty = true">
+          </b-input>
+        </b-field>
+
+      </b-field>
+
+      <b-field grouped>
+
+        <b-field label="Model Title"
+                 message="Human-friendly singular model title.">
+          <b-input v-model="tableModelTitle"
+                   @input="dirty = true">
+          </b-input>
+        </b-field>
+
+        <b-field label="Model Title Plural"
+                 message="Human-friendly plural model title.">
+          <b-input v-model="tableModelTitlePlural"
+                   @input="dirty = true">
+          </b-input>
+        </b-field>
+
+      </b-field>
+
+      <b-field label="Description"
+               message="Brief description of what a record in this table represents.">
+        <b-input v-model="tableDescription"
+                 @input="dirty = true">
+        </b-input>
+      </b-field>
+
+      <b-field>
+        <b-checkbox v-model="tableVersioned"
+                    @input="dirty = true">
+          Record version data for this table
+        </b-checkbox>
+      </b-field>
+
+      <br />
+
+      <div class="level-left">
+        <div class="level-item">
+          <h4 class="block is-size-4">Columns</h4>
+        </div>
+        <div class="level-item">
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="plus"
+                    @click="tableAddColumn()">
+            New
+          </b-button>
+        </div>
+      </div>
+
+      <b-table
+        :data="tableColumns">
+
+        <b-table-column field="name"
+                        label="Name"
+                        v-slot="props">
+          {{ props.row.name }}
+        </b-table-column>
+
+        <b-table-column field="data_type"
+                        label="Data Type"
+                        v-slot="props">
+          {{ formatDataType(props.row.data_type) }}
+        </b-table-column>
+
+        <b-table-column field="nullable"
+                        label="Nullable"
+                        v-slot="props">
+          {{ props.row.nullable ? "Yes" : "No" }}
+        </b-table-column>
+
+        <b-table-column field="versioned"
+                        label="Versioned"
+                        :visible="tableVersioned"
+                        v-slot="props">
+          {{ props.row.versioned ? "Yes" : "No" }}
+        </b-table-column>
+
+        <b-table-column field="description"
+                        label="Description"
+                        v-slot="props">
+          {{ props.row.description }}
+        </b-table-column>
+
+        <b-table-column field="actions"
+                        label="Actions"
+                        v-slot="props">
+          <a v-if="props.row.name != 'uuid'"
+             href="#"
+             @click.prevent="tableEditColumn(props.row)">
+            <i class="fas fa-edit"></i>
+            Edit
+          </a>
+          &nbsp;
+
+          <a v-if="props.row.name != 'uuid'"
+             href="#"
+             class="has-text-danger"
+             @click.prevent="tableDeleteColumn(props.index)">
+            <i class="fas fa-trash"></i>
+            Delete
+          </a>
+          &nbsp;
+        </b-table-column>
+
+      </b-table>
+
+      <b-modal has-modal-card
+               :active.sync="editingColumnShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">
+              {{ (editingColumn && editingColumn.name) ? "Edit" : "New" }} Column
+            </p>
+          </header>
+
+          <section class="modal-card-body">
+
+            <b-field label="Name">
+              <b-input v-model="editingColumnName"
+                       ref="editingColumnName">
+              </b-input>
+            </b-field>
+
+            <b-field grouped>
+
+              <b-field label="Data Type">
+                <b-select v-model="editingColumnDataType">
+                  <option value="String">String</option>
+                  <option value="Boolean">Boolean</option>
+                  <option value="Integer">Integer</option>
+                  <option value="Numeric">Numeric</option>
+                  <option value="Date">Date</option>
+                  <option value="DateTime">DateTime</option>
+                  <option value="Text">Text</option>
+                  <option value="LargeBinary">LargeBinary</option>
+                  <option value="_fk_uuid_">FK/UUID</option>
+                  <option value="_other_">Other</option>
+                </b-select>
+              </b-field>
+
+              <b-field v-if="editingColumnDataType == 'String'"
+                       label="Length"
+                       :type="{'is-danger': !editingColumnDataTypeLength}"
+                       style="max-width: 6rem;">
+                <b-input v-model="editingColumnDataTypeLength">
+                </b-input>
+              </b-field>
+
+              <b-field v-if="editingColumnDataType == 'Numeric'"
+                       label="Precision"
+                       :type="{'is-danger': !editingColumnDataTypePrecision}"
+                       style="max-width: 6rem;">
+                <b-input v-model="editingColumnDataTypePrecision">
+                </b-input>
+              </b-field>
+
+              <b-field v-if="editingColumnDataType == 'Numeric'"
+                       label="Scale"
+                       :type="{'is-danger': !editingColumnDataTypeScale}"
+                       style="max-width: 6rem;">
+                <b-input v-model="editingColumnDataTypeScale">
+                </b-input>
+              </b-field>
+
+              <b-field v-if="editingColumnDataType == '_fk_uuid_'"
+                       label="Reference Table"
+                       :type="{'is-danger': !editingColumnDataTypeReference}">
+                <b-select v-model="editingColumnDataTypeReference">
+                  <option v-for="table in existingTables"
+                          :key="table.name"
+                          :value="table.name">
+                    {{ table.name }}
+                  </option>
+                </b-select>
+              </b-field>
+
+              <b-field v-if="editingColumnDataType == '_other_'"
+                       label="Literal (include parens!)"
+                       :type="{'is-danger': !editingColumnDataTypeLiteral}"
+                       expanded>
+                <b-input v-model="editingColumnDataTypeLiteral">
+                </b-input>
+              </b-field>
+
+            </b-field>
+
+            <b-field grouped>
+
+              <b-field label="Nullable">
+                <b-checkbox v-model="editingColumnNullable"
+                            native-value="true">
+                  {{ editingColumnNullable }}
+                </b-checkbox>
+              </b-field>
+
+              <b-field label="Versioned"
+                       v-if="tableVersioned">
+                <b-checkbox v-model="editingColumnVersioned"
+                            native-value="true">
+                  {{ editingColumnVersioned }}
+                </b-checkbox>
+              </b-field>
+
+              <b-field v-if="editingColumnDataType == '_fk_uuid_'"
+                       label="Relationship">
+                <b-input v-model="editingColumnRelationship"></b-input>
+              </b-field>
+
+            </b-field>
+
+            <b-field label="Description">
+              <b-input v-model="editingColumnDescription"></b-input>
+            </b-field>
+
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="editingColumnShowDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="save"
+                      @click="editingColumnSave()">
+              Save
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+
+      <br />
+
+      <div class="buttons">
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="check"
+                  @click="showStep('write-model')">
+          Details are complete
+        </b-button>
+      </div>
+
+    </b-step-item>
+
+    <b-step-item step="2"
+                 value="write-model"
+                 label="Write Model">
+      <h3 class="is-size-3 block">
+        Write Model
+      </h3>
+
+      <b-field label="Schema Branch" horizontal>
+        {{ alembicBranch }}
+      </b-field>
+
+      <b-field label="Table Name" horizontal>
+        {{ tableName }}
+      </b-field>
+
+      <b-field label="Model Class" horizontal>
+        {{ tableModelName }}
+      </b-field>
+
+      <b-field horizontal label="File">
+        <b-input v-model="tableModelFile"></b-input>
+      </b-field>
+
+      <b-field horizontal>
+        <b-checkbox v-model="tableModelFileOverwrite">
+          Overwrite file if it exists
+        </b-checkbox>
+      </b-field>
+
+      <div class="form">
+        <div class="buttons">
+          <b-button icon-pack="fas"
+                    icon-left="arrow-left"
+                    @click="showStep('enter-details')">
+            Back
+          </b-button>
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="save"
+                    @click="writeModelFile()"
+                    :disabled="writingModelFile">
+            {{ writingModelFile ? "Working, please wait..." : "Write model class to file" }}
+          </b-button>
+          <b-button icon-pack="fas"
+                    icon-left="arrow-right"
+                    @click="showStep('review-model')">
+            Skip
+          </b-button>
+        </div>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="3"
+                 value="review-model"
+                 label="Review Model"
+                 clickable>
+      <h3 class="is-size-3 block">
+        Review Model
+      </h3>
+
+      <p class="block">
+        Model code was generated to file:
+      </p>
+
+      <p class="block is-family-code" style="padding-left: 3rem;">
+        {{ tableModelFile }}
+      </p>
+
+      <p class="block">
+        First, review that code and adjust to your liking.
+      </p>
+
+      <p class="block">
+        Next be sure to import the new model.  Typically this is done
+        by editing the file...
+      </p>
+
+      <p class="block is-family-code" style="padding-left: 3rem;">
+        ${model_dir}__init__.py
+      </p>
+
+      <p class="block">
+        ...and adding a line such as:
+      </p>
+
+      <p class="block is-family-code" style="padding-left: 3rem;">
+        from .{{ tableModelFileModuleName }} import {{ tableModelName }}
+      </p>
+
+      <p class="block">
+        Once you&apos;ve done all that, the web app must be restarted.
+        This may happen automatically depending on your setup.
+        Test the model import status below.
+      </p>
+
+      <div class="card block">
+        <header class="card-header">
+          <p class="card-header-title">
+            Model Import Status
+          </p>
+        </header>
+        <div class="card-content">
+          <div class="content">
+            <div class="level">
+              <div class="level-left">
+
+                <div class="level-item">
+                  <span v-if="!modelImported && !modelImportProblem">
+                    import not yet attempted
+                  </span>
+                  <span v-if="modelImported"
+                        class="has-text-success has-text-weight-bold">
+                    imported okay
+                  </span>
+                  <span v-if="modelImportProblem"
+                        class="has-text-danger">
+                    import failed: {{ modelImportStatus }}
+                  </span>
+                </div>
+              </div>
+              <div class="level-right">
+                <div class="level-item">
+                  <b-field horizontal label="Model Class">
+                    <b-input v-model="modelImportName"></b-input>
+                  </b-field>
+                </div>
+                <div class="level-item">
+                  <b-button type="is-primary"
+                            icon-pack="fas"
+                            icon-left="redo"
+                            @click="modelImportTest()">
+                    Refresh / Test Import
+                  </b-button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="showStep('write-model')">
+          Back
+        </b-button>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="check"
+                  @click="showStep('write-revision')"
+                  :disabled="!modelImported">
+          Model class looks good!
+        </b-button>
+        <b-button icon-pack="fas"
+                  icon-left="arrow-right"
+                  @click="showStep('write-revision')">
+          Skip
+        </b-button>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="4"
+                 value="write-revision"
+                 label="Write Revision"
+                 clickable>
+      <h3 class="is-size-3 block">
+        Write Revision
+      </h3>
+      <p class="block">
+        You said the model class looked good, so next we will generate
+        a revision script, used to modify DB schema.
+      </p>
+
+      <b-field label="Schema Branch"
+               message="Leave this set to your custom app branch, unless you know what you're doing.">
+        <b-select v-model="alembicBranch">
+          <option v-for="branch in alembicBranchOptions"
+                  :key="branch"
+                  :value="branch">
+            {{ branch }}
+          </option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Message"
+               message="Human-friendly brief description of the changes">
+        <b-input v-model="revisionMessage"></b-input>
+      </b-field>
+
+      <br />
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="showStep('review-model')">
+          Back
+        </b-button>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="save"
+                  @click="writeRevisionScript()"
+                  :disabled="writingRevisionScript">
+          {{ writingRevisionScript ? "Working, please wait..." : "Generate revision script" }}
+        </b-button>
+        <b-button icon-pack="fas"
+                  icon-left="arrow-right"
+                  @click="showStep('review-revision')">
+          Skip
+        </b-button>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="5"
+                 value="review-revision"
+                 label="Review Revision">
+      <h3 class="is-size-3 block">
+        Review Revision
+      </h3>
+
+      <p class="block">
+        Revision script was generated to file:
+      </p>
+
+      <p class="block is-family-code" style="padding-left: 3rem;">
+        {{ revisionScript }}
+      </p>
+
+      <p class="block">
+        Please review that code and adjust to your liking.
+      </p>
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="showStep('write-revision')">
+          Back
+        </b-button>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="check"
+                  @click="showStep('upgrade-db')">
+          Revision script looks good!
+        </b-button>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="6"
+                 value="upgrade-db"
+                 label="Upgrade DB"
+                 clickable>
+      <h3 class="is-size-3 block">
+        Upgrade DB
+      </h3>
+      <p class="block">
+        You said the revision script looked good, so next we will use
+        it to upgrade your actual database.
+      </p>
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="showStep('review-revision')">
+          Back
+        </b-button>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="arrow-up"
+                  @click="upgradeDB()"
+                  :disabled="upgradingDB">
+          {{ upgradingDB ? "Working, please wait..." : "Upgrade database" }}
+        </b-button>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="7"
+                 value="review-db"
+                 label="Review DB"
+                 clickable>
+      <h3 class="is-size-3 block">
+        Review DB
+      </h3>
+
+      <p class="block">
+        At this point your new table should be present in the DB.
+        Test below.
+      </p>
+
+      <div class="card block">
+        <header class="card-header">
+          <p class="card-header-title">
+            Table Status
+          </p>
+        </header>
+        <div class="card-content">
+          <div class="content">
+            <div class="level">
+              <div class="level-left">
+
+                <div class="level-item">
+                  <span v-if="!tableCheckAttempted">
+                    check not yet attempted
+                  </span>
+                  <span v-if="tableCheckAttempted && !tableCheckProblem"
+                        class="has-text-success has-text-weight-bold">
+                    table exists!
+                  </span>
+                  <span v-if="tableCheckProblem"
+                        class="has-text-danger">
+                    {{ tableCheckProblem }}
+                  </span>
+                </div>
+              </div>
+              <div class="level-right">
+                <div class="level-item">
+                  <b-field horizontal label="Table Name">
+                    <b-input v-model="tableName"></b-input>
+                  </b-field>
+                </div>
+                <div class="level-item">
+                  <b-button type="is-primary"
+                            icon-pack="fas"
+                            icon-left="redo"
+                            @click="tableCheck()">
+                    Test for Table
+                  </b-button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="showStep('upgrade-db')">
+          Back
+        </b-button>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="check"
+                  @click="showStep('commit-code')"
+                  :disabled="!tableCheckAttempted || tableCheckProblem">
+          DB looks good!
+        </b-button>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="8"
+                 value="commit-code"
+                 label="Commit Code">
+      <h3 class="is-size-3 block">
+        Commit Code
+      </h3>
+
+      <p class="block">
+        Hope you're having a great day.
+      </p>
+
+      <p class="block">
+        Don't forget to commit code changes to your source repo.
+      </p>
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="showStep('review-db')">
+          Back
+        </b-button>
+        <once-button type="is-primary"
+                     tag="a" :href="tableURL"
+                     icon-left="arrow-right"
+                     :text="`Show me my new table: ${'$'}{tableName}`">
+        </once-button>
+      </div>
+    </b-step-item>
+  </b-steps>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    // nb. for warning user they may lose changes if leaving page
+    ThisPageData.dirty = false
+
+    ThisPageData.activeStep = null
+    ThisPageData.alembicBranchOptions = ${json.dumps(branch_name_options)|n}
+
+    ThisPageData.existingTables = ${json.dumps(existing_tables)|n}
+
+    ThisPageData.alembicBranch = ${json.dumps(branch_name)|n}
+    ThisPageData.tableName = '${rattail_app.get_table_prefix()}_widget'
+    ThisPageData.tableModelName = '${rattail_app.get_class_prefix()}Widget'
+    ThisPageData.tableModelTitle = 'Widget'
+    ThisPageData.tableModelTitlePlural = 'Widgets'
+    ThisPageData.tableDescription = "Represents a cool widget."
+    ThisPageData.tableVersioned = true
+
+    ThisPageData.tableColumns = [{
+        name: 'uuid',
+        data_type: {
+            type: 'String',
+            length: 32,
+        },
+        nullable: false,
+        description: "UUID primary key",
+        versioned: true,
+    }]
+
+    ThisPageData.editingColumnShowDialog = false
+    ThisPageData.editingColumn = null
+    ThisPageData.editingColumnName = null
+    ThisPageData.editingColumnDataType = null
+    ThisPageData.editingColumnDataTypeLength = null
+    ThisPageData.editingColumnDataTypePrecision = null
+    ThisPageData.editingColumnDataTypeScale = null
+    ThisPageData.editingColumnDataTypeReference = null
+    ThisPageData.editingColumnDataTypeLiteral = null
+    ThisPageData.editingColumnNullable = true
+    ThisPageData.editingColumnDescription = null
+    ThisPageData.editingColumnVersioned = true
+    ThisPageData.editingColumnRelationship = null
+
+    ThisPage.methods.showStep = function(step) {
+        this.activeStep = step
+
+        // scroll so top of page is shown
+        this.$nextTick(() => {
+            this.$refs['showme'].scrollIntoView(true)
+        })
+    }
+
+    ThisPage.methods.tableAddColumn = function() {
+        this.editingColumn = null
+        this.editingColumnName = null
+        this.editingColumnDataType = null
+        this.editingColumnDataTypeLength = null
+        this.editingColumnDataTypePrecision = null
+        this.editingColumnDataTypeScale = null
+        this.editingColumnDataTypeReference = null
+        this.editingColumnDataTypeLiteral = null
+        this.editingColumnNullable = true
+        this.editingColumnDescription = null
+        this.editingColumnVersioned = true
+        this.editingColumnRelationship = null
+        this.editingColumnShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.editingColumnName.focus()
+        })
+    }
+
+    ThisPage.methods.tableEditColumn = function(column) {
+        this.editingColumn = column
+        this.editingColumnName = column.name
+        this.editingColumnDataType = column.data_type.type
+        this.editingColumnDataTypeLength = column.data_type.length
+        this.editingColumnDataTypePrecision = column.data_type.precision
+        this.editingColumnDataTypeScale = column.data_type.scale
+        this.editingColumnDataTypeReference = column.data_type.reference
+        this.editingColumnDataTypeLiteral = column.data_type.literal
+        this.editingColumnNullable = column.nullable
+        this.editingColumnDescription = column.description
+        this.editingColumnVersioned = column.versioned
+        this.editingColumnRelationship = column.relationship
+        this.editingColumnShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.editingColumnName.focus()
+        })
+    }
+
+    ThisPage.methods.formatDataType = function(dataType) {
+        if (dataType.type == 'String') {
+            return `sa.String(length=${'$'}{dataType.length})`
+        } else if (dataType.type == 'Numeric') {
+            return `sa.Numeric(precision=${'$'}{dataType.precision}, scale=${'$'}{dataType.scale})`
+        } else if (dataType.type == '_fk_uuid_') {
+            return 'sa.String(length=32)'
+        } else if (dataType.type == '_other_') {
+            return dataType.literal
+        } else {
+            return `sa.${'$'}{dataType.type}()`
+        }
+    }
+
+    ThisPage.watch.editingColumnDataTypeReference = function(newval, oldval) {
+        this.editingColumnRelationship = newval
+        if (newval && !this.editingColumnName) {
+            this.editingColumnName = `${'$'}{newval}_uuid`
+        }
+    }
+
+    ThisPage.methods.editingColumnSave = function() {
+        let column
+        if (this.editingColumn) {
+            column = this.editingColumn
+        } else {
+            column = {}
+            this.tableColumns.push(column)
+        }
+
+        column.name = this.editingColumnName
+
+        let dataType = {type: this.editingColumnDataType}
+        if (dataType.type == 'String') {
+            dataType.length = this.editingColumnDataTypeLength
+        } else if (dataType.type == 'Numeric') {
+            dataType.precision = this.editingColumnDataTypePrecision
+            dataType.scale = this.editingColumnDataTypeScale
+        } else if (dataType.type == '_fk_uuid_') {
+            dataType.reference = this.editingColumnDataTypeReference
+        } else if (dataType.type == '_other_') {
+            dataType.literal = this.editingColumnDataTypeLiteral
+        }
+        column.data_type = dataType
+
+        column.nullable = this.editingColumnNullable
+        column.description = this.editingColumnDescription
+        column.versioned = this.editingColumnVersioned
+        column.relationship = this.editingColumnRelationship
+
+        this.dirty = true
+        this.editingColumnShowDialog = false
+    }
+
+    ThisPage.methods.tableDeleteColumn = function(index) {
+        if (confirm("Really delete this column?")) {
+            this.tableColumns.splice(index, 1)
+            this.dirty = true
+        }
+    }
+
+    ThisPageData.tableModelFile = '${model_dir}widget.py'
+    ThisPageData.tableModelFileOverwrite = false
+    ThisPageData.writingModelFile = false
+
+    ThisPage.methods.writeModelFile = function() {
+        this.writingModelFile = true
+
+        this.modelImportName = this.tableModelName
+        this.modelImported = false
+        this.modelImportStatus = "import not yet attempted"
+        this.modelImportProblem = false
+
+        for (let column of this.tableColumns) {
+            column.formatted_data_type = this.formatDataType(column.data_type)
+        }
+
+        let url = '${url('{}.write_model_file'.format(route_prefix))}'
+        let params = {
+            branch_name: this.alembicBranch,
+            table_name: this.tableName,
+            model_name: this.tableModelName,
+            model_title: this.tableModelTitle,
+            model_title_plural: this.tableModelTitlePlural,
+            description: this.tableDescription,
+            versioned: this.tableVersioned,
+            columns: this.tableColumns,
+            module_file: this.tableModelFile,
+            overwrite: this.tableModelFileOverwrite,
+        }
+        this.submitForm(url, params, response => {
+            this.writingModelFile = false
+            this.activeStep = 'review-model'
+        }, response => {
+            this.writingModelFile = false
+        })
+    }
+
+    ThisPageData.modelImportName = '${rattail_app.get_class_prefix()}Widget'
+    ThisPageData.modelImportStatus = "import not yet attempted"
+    ThisPageData.modelImported = false
+    ThisPageData.modelImportProblem = false
+
+    ThisPage.computed.tableModelFileModuleName = function() {
+        let path = this.tableModelFile
+        path = path.replace(/^.*\//, '')
+        path = path.replace(/\.py$/, '')
+        return path
+    }
+
+    ThisPage.methods.modelImportTest = function() {
+        let url = '${url('{}.check_model'.format(route_prefix))}'
+        let params = {model_name: this.modelImportName}
+        this.submitForm(url, params, response => {
+            if (response.data.problem) {
+                this.modelImportProblem = true
+                this.modelImported = false
+                this.modelImportStatus = response.data.problem
+            } else {
+                this.modelImportProblem = false
+                this.modelImported = true
+                this.revisionMessage = `add table for ${'$'}{this.tableModelTitlePlural}`
+            }
+        })
+    }
+
+    ThisPageData.writingRevisionScript = false
+    ThisPageData.revisionMessage = null
+    ThisPageData.revisionScript = null
+
+    ThisPage.methods.writeRevisionScript = function() {
+        this.writingRevisionScript = true
+
+        let url = '${url('{}.write_revision_script'.format(route_prefix))}'
+        let params = {
+            branch: this.alembicBranch,
+            message: this.revisionMessage,
+        }
+        this.submitForm(url, params, response => {
+            this.writingRevisionScript = false
+            this.revisionScript = response.data.script
+            this.activeStep = 'review-revision'
+        }, response => {
+            this.writingRevisionScript = false
+        })
+    }
+
+    ThisPageData.upgradingDB = false
+
+    ThisPage.methods.upgradeDB = function() {
+        this.upgradingDB = true
+
+        let url = '${url('{}.upgrade_db'.format(route_prefix))}'
+        let params = {}
+        this.submitForm(url, params, response => {
+            this.upgradingDB = false
+            this.activeStep = 'review-db'
+        }, response => {
+            this.upgradingDB = false
+        })
+    }
+
+    ThisPageData.tableCheckAttempted = false
+    ThisPageData.tableCheckProblem = null
+
+    ThisPageData.tableURL = null
+
+    ThisPage.methods.tableCheck = function() {
+        let url = '${url('{}.check_table'.format(route_prefix))}'
+        let params = {table_name: this.tableName}
+        this.submitForm(url, params, response => {
+            if (response.data.problem) {
+                this.tableCheckProblem = response.data.problem
+            } else {
+                this.tableURL = response.data.url
+            }
+            this.tableCheckAttempted = true
+        })
+    }
+
+    // cf. https://stackoverflow.com/a/56551646
+    ThisPage.methods.beforeWindowUnload = function(e) {
+
+        // warn user if navigating away would lose changes
+        if (this.dirty) {
+            e.preventDefault()
+            e.returnValue = ''
+        }
+    }
+
+    ThisPage.created = function() {
+        window.addEventListener('beforeunload', this.beforeWindowUnload)
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/reports/generated/index.mako b/tailbone/templates/tables/index.mako
similarity index 54%
rename from tailbone/templates/reports/generated/index.mako
rename to tailbone/templates/tables/index.mako
index 63a5b9b5..b13f0785 100644
--- a/tailbone/templates/reports/generated/index.mako
+++ b/tailbone/templates/tables/index.mako
@@ -3,9 +3,10 @@
 
 <%def name="context_menu_items()">
   ${parent.context_menu_items()}
-  % if request.has_perm('{}.generate'.format(permission_prefix)):
-      <li>${h.link_to("Generate new Report", url('generate_report'))}</li>
+  % if master.has_perm('migrations'):
+      <li>${h.link_to("View / Apply Migrations", url('{}.migrations'.format(route_prefix)))}</li>
   % endif
 </%def>
 
+
 ${parent.body()}
diff --git a/tailbone/templates/tables/migrations.mako b/tailbone/templates/tables/migrations.mako
new file mode 100644
index 00000000..af1734eb
--- /dev/null
+++ b/tailbone/templates/tables/migrations.mako
@@ -0,0 +1,11 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">Schema Migrations</%def>
+
+<%def name="render_this_page()">
+  <h3 class="is-size-3">TODO: show current revisions and allow DB upgrades</h3>
+</%def>
+
+
+${parent.body()}
diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako
index bbaa0e3f..a55af922 100644
--- a/tailbone/templates/tempmon/appliances/view.mako
+++ b/tailbone/templates/tempmon/appliances/view.mako
@@ -8,5 +8,9 @@
   % endif
 </%def>
 
-
-${parent.body()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako
index ab65bac6..434da4c8 100644
--- a/tailbone/templates/tempmon/clients/view.mako
+++ b/tailbone/templates/tempmon/clients/view.mako
@@ -1,20 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-    $(function() {
-        $('#restart-client').click(function() {
-            disable_button(this);
-            location.href = '${url('tempmon.clients.restart', uuid=instance.uuid)}';
-        });
-    });
-  </script>
-  % endif
-</%def>
-
 <%def name="context_menu_items()">
   ${parent.context_menu_items()}
   % if request.has_perm('tempmon.appliances.dashboard'):
@@ -27,17 +13,18 @@
       <div class="object-helper">
         <h3>Client Tools</h3>
         <div class="object-helper-content">
-          % if use_buefy:
-              <once-button tag="a" href="${url('{}.restart'.format(route_prefix), uuid=instance.uuid)}"
-                           type="is-primary"
-                           text="Restart tempmon-client daemon">
-              </once-button>
-          % else:
-          <button type="button" id="restart-client">Restart tempmon-client daemon</button>
-          % endif
+          <once-button tag="a" href="${url('{}.restart'.format(route_prefix), uuid=instance.uuid)}"
+                       type="is-primary"
+                       text="Restart tempmon-client daemon">
+          </once-button>
         </div>
       </div>
   % endif
 </%def>
 
-${parent.body()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako
index 815eb89e..befaf8b4 100644
--- a/tailbone/templates/tempmon/dashboard.mako
+++ b/tailbone/templates/tempmon/dashboard.mako
@@ -8,33 +8,85 @@
 <%def name="extra_javascript()">
   ${parent.extra_javascript()}
   <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.bundle.min.js"></script>
-  % if not use_buefy:
-  <script type="text/javascript">
+</%def>
 
-    var contexts = {};
-    var charts = {};
+<%def name="render_this_page()">
+  ${h.form(request.current_route_url(), ref='applianceForm')}
+  ${h.csrf_token(request)}
+  <div class="level-left">
 
-    function fetchReadings(appliance_uuid) {
-        if (appliance_uuid === undefined) {
-            appliance_uuid = $('#appliance_uuid').val();
+    <div class="level-item">
+      <b-field label="Appliance" horizontal>
+        <b-select name="appliance_uuid"
+                  v-model="applianceUUID"
+                  @input="$refs.applianceForm.submit()">
+          <option v-for="appliance in appliances"
+                  :key="appliance.uuid"
+                  :value="appliance.uuid">
+            {{ appliance.name }}
+          </option>
+        </b-select>
+      </b-field>
+    </div>
+
+    % if appliance:
+        <div class="level-item">
+          <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}">
+            ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")}
+          </a>
+        </div>
+    % endif
+
+  </div>
+  ${h.end_form()}
+
+  % if appliance and appliance.probes:
+      % for probe in appliance.probes:
+          <h4 class="is-size-4">
+            Probe:&nbsp; ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))}
+            (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]})
+          </h4>
+          % if probe.enabled:
+              <canvas ref="tempchart-${probe.uuid}" width="400" height="60"></canvas>
+          % else:
+              <p>This probe is not enabled.</p>
+          % endif
+      % endfor
+  % elif appliance:
+      <h3>This appliance has no probes configured!</h3>
+  % else:
+      <h3>Please choose an appliance.</h3>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.appliances = ${json.dumps(appliances_data)|n}
+    ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n}
+    ThisPageData.charts = {}
+
+    ThisPage.methods.fetchReadings = function(uuid) {
+
+        if (!uuid) {
+            uuid = this.applianceUUID
         }
 
-        $('.form-wrapper').mask("Fetching data");
-
-        if (Object.keys(charts).length) {
-            Object.keys(charts).forEach(function(key) {
-                charts[key].destroy();
-                delete charts[key];
-            });
+        for (let chart in this.charts) {
+            chart.destroy()
         }
+        this.charts = []
 
-        var url = '${url("tempmon.dashboard.readings")}';
-        var params = {'appliance_uuid': appliance_uuid};
-        $.get(url, params, function(data) {
+        let url = '${url('tempmon.dashboard.readings')}'
+        let params = {appliance_uuid: uuid}
+        this.$http.get(url, {params: params}).then(response => {
+            if (response.data.probes) {
 
-            if (data.probes) {
-                data.probes.forEach(function(probe) {
-                    charts[probe.uuid] = new Chart(contexts[probe.uuid], {
+                for (let probe of response.data.probes) {
+
+                    let context = this.$refs[`tempchart-${'$'}{probe.uuid}`]
+                    this.charts[probe.uuid] = new Chart(context, {
                         type: 'scatter',
                         data: {
                             datasets: [{
@@ -51,85 +103,18 @@
                                 }]
                             }
                         }
-                    });
-                });
-            } else {
-                // TODO: should improve this
-                alert(data.error);
-            }
+                    })
+                }
 
-            $('.form-wrapper').unmask();
-        });
+            } else {
+                alert(response.data.error)
+            }
+        })
     }
 
-    $(function() {
-
-        % for probe in appliance.probes:
-            contexts['${probe.uuid}'] = $('#tempchart-${probe.uuid}');
-        % endfor
-
-        $('#appliance_uuid').selectmenu({
-            change: function(event, ui) {
-                $('.form-wrapper').mask("Fetching data");
-                $(this).parents('form').submit();
-            }
-        });
-
-        fetchReadings();
-    });
+    ThisPage.mounted = function() {
+        this.fetchReadings()
+    }
 
   </script>
-  % endif
 </%def>
-
-<%def name="render_this_page()">
-  <div style="display: flex;">
-
-    <div class="form-wrapper">
-      <div class="form">
-        ${h.form(request.current_route_url())}
-        ${h.csrf_token(request)}
-        % if use_buefy:
-            <b-field horizontal label="Appliance">
-              ${appliance_select}
-            </b-field>
-        % else:
-            <div class="field-wrapper">
-              <label>Appliance</label>
-              <div class="field">
-                ${appliance_select}
-              </div>
-            </div>
-        % endif
-        ${h.end_form()}
-      </div>
-    </div>
-
-    <a href="${url('tempmon.appliances.view', uuid=appliance.uuid)}">
-      ${h.image(url('tempmon.appliances.thumbnail', uuid=appliance.uuid), "")}
-    </a>
-  </div>
-
-  % if appliance.probes:
-      % for probe in appliance.probes:
-          <h3>
-            Probe:&nbsp; ${h.link_to(probe.description, url('tempmon.probes.graph', uuid=probe.uuid))}
-            (status: ${enum.TEMPMON_PROBE_STATUS[probe.status]})
-          </h3>
-          % if probe.enabled:
-              % if use_buefy:
-                  <canvas ref="tempchart" width="400" height="150"></canvas>
-              % else:
-                  <canvas id="tempchart-${probe.uuid}" width="400" height="60"></canvas>
-              % endif
-          % else:
-              <p>This probe is not enabled.</p>
-          % endif
-      % endfor
-  % else:
-      <h3>This appliance has no probes configured!</h3>
-  % endif
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/probes/create.mako b/tailbone/templates/tempmon/probes/create.mako
deleted file mode 100644
index 062997d7..00000000
--- a/tailbone/templates/tempmon/probes/create.mako
+++ /dev/null
@@ -1,17 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/master/create.mako" />
-
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  <script type="text/javascript">
-    $(function() {
-
-        $('.field-wrapper.client_uuid select').selectmenu();
-
-        $('.field-wrapper.appliance_type select').selectmenu();
-
-    });
-  </script>
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/probes/edit.mako b/tailbone/templates/tempmon/probes/edit.mako
deleted file mode 100644
index b9f2a6b2..00000000
--- a/tailbone/templates/tempmon/probes/edit.mako
+++ /dev/null
@@ -1,17 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/master/edit.mako" />
-
-<%def name="head_tags()">
-  ${parent.head_tags()}
-  <script type="text/javascript">
-    $(function() {
-
-        $('.field-wrapper.client_uuid select').selectmenu();
-
-        $('.field-wrapper.appliance_type select').selectmenu();
-
-    });
-  </script>
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako
index 3255edb7..94a440e0 100644
--- a/tailbone/templates/tempmon/probes/graph.mako
+++ b/tailbone/templates/tempmon/probes/graph.mako
@@ -6,71 +6,6 @@
 <%def name="extra_javascript()">
   ${parent.extra_javascript()}
   <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.bundle.min.js"></script>
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    var ctx = null;
-    var chart = null;
-
-    function fetchReadings(timeRange) {
-        if (timeRange === undefined) {
-            timeRange = $('#time-range').val();
-        }
-
-        var timeUnit;
-        if (timeRange == 'last hour') {
-            timeUnit = 'minute';
-        } else if (['last 6 hours', 'last day'].includes(timeRange)) {
-            timeUnit = 'hour';
-        } else {
-            timeUnit = 'day';
-        }
-
-        $('.form-wrapper').mask("Fetching data");
-        if (chart) {
-            chart.destroy();
-        }
-
-        $.get('${url('{}.graph_readings'.format(route_prefix), uuid=probe.uuid)}', {'time-range': timeRange}, function(data) {
-
-            chart = new Chart(ctx, {
-                type: 'scatter',
-                data: {
-                    datasets: [{
-                        label: "${probe.description}",
-                        data: data
-                    }]
-                },
-                options: {
-                    scales: {
-                        xAxes: [{
-                            type: 'time',
-                            time: {unit: timeUnit},
-                            position: 'bottom'
-                        }]
-                    }
-                }
-            });
-
-            $('.form-wrapper').unmask();
-        });
-    }
-
-    $(function() {
-
-        ctx = $('#tempchart');
-
-        $('#time-range').selectmenu({
-            change: function(event, ui) {
-                fetchReadings(ui.item.value);
-            }
-        });
-
-        fetchReadings();
-    });
-
-  </script>
-  % endif
 </%def>
 
 <%def name="context_menu_items()">
@@ -89,50 +24,29 @@
     <div class="form-wrapper">
       <div class="form">
 
-        % if use_buefy:
-            <b-field horizontal label="Appliance">
-              <div>
-                % if probe.appliance:
-                    <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a>
-                % endif
-              </div>
-            </b-field>
-        % else:
-            <div class="field-wrapper">
-              <label>Appliance</label>
-              <div class="field">
-                % if probe.appliance:
-                    <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a>
-                % endif
-              </div>
-            </div>
-        % endif
+        <b-field horizontal label="Appliance">
+          <div>
+            % if probe.appliance:
+                <a href="${url('tempmon.appliances.view', uuid=probe.appliance.uuid)}">${probe.appliance}</a>
+            % endif
+          </div>
+        </b-field>
 
-        % if use_buefy:
-            <b-field horizontal label="Probe Location">
-              <div>
-                ${probe.location or ""}
-              </div>
-            </b-field>
-        % else:
-            <div class="field-wrapper">
-              <label>Probe Location</label>
-              <div class="field">${probe.location or ""}</div>
-            </div>
-        % endif
+        <b-field horizontal label="Probe Location">
+          <div>
+            ${probe.location or ""}
+          </div>
+        </b-field>
 
-        % if use_buefy:
-            <b-field horizontal label="Showing">
-              ${time_range}
-            </b-field>
-        % else:
-            <div class="field-wrapper">
-              <label>Showing</label>
-              <div class="field">
-                ${time_range}
-              </div>
-            </div>
-        % endif
+        <b-field horizontal label="Showing">
+          <b-select v-model="currentTimeRange"
+                    @input="timeRangeChanged">
+            <option value="last hour">Last Hour</option>
+            <option value="last 6 hours">Last 6 Hours</option>
+            <option value="last day">Last Day</option>
+            <option value="last week">Last Week</option>
+          </b-select>
+        </b-field>
 
       </div>
     </div>
@@ -149,16 +63,12 @@
 
   </div>
 
-  % if use_buefy:
-      <canvas ref="tempchart" width="400" height="150"></canvas>
-  % else:
-      <canvas id="tempchart" width="400" height="150"></canvas>
-  % endif
+  <canvas ref="tempchart" width="400" height="150"></canvas>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n}
     ThisPageData.chart = null
@@ -182,7 +92,9 @@
             this.chart.destroy()
         }
 
-        this.$http.get('${url('{}.graph_readings'.format(route_prefix), uuid=probe.uuid)}', {params: {'time-range': timeRange}}).then(({ data }) => {
+        let url = '${url(f'{route_prefix}.graph_readings', uuid=probe.uuid)}'
+        let params = {'time-range': timeRange}
+        this.$http.get(url, {params: params}).then(({ data }) => {
 
             this.chart = new Chart(this.$refs.tempchart, {
                 type: 'scatter',
@@ -216,6 +128,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako
index 1e309129..7afd2427 100644
--- a/tailbone/templates/tempmon/probes/view.mako
+++ b/tailbone/templates/tempmon/probes/view.mako
@@ -1,87 +1,30 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="render_form_complete()">
-  % if use_buefy:
-
-      ## ${self.render_form()}
-
-      <script type="text/x-template" id="form-page-template">
-
-        <div style="display: flex; justify-content: space-between;">
-
-          <div class="form-wrapper">
-
-            <div style="display: flex; flex-direction: column;">
-
-              <nav class="panel" id="probe-main">
-                <p class="panel-heading">General</p>
-                <div class="panel-block">
-                  <div>
-                    ${self.render_main_fields(form)}
-                  </div>
-                </div>
-              </nav>
-
-              <div style="display: flex;">
-                <div class="panel-wrapper">
-                  ${self.left_column()}
-                </div>
-                <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
-                  ${self.right_column()}
-                </div>
-              </div>
-
-            </div>
-          </div>
-
-          <ul id="context-menu">
-            ${self.context_menu_items()}
-          </ul>
-
-        </div>
-      </script>
-
-      <div id="form-page-app">
-        <form-page></form-page>
-      </div>
-
-  % else:
-      ## legacy / not buefy
-
-      <div style="display: flex; justify-content: space-between;">
-
-        <div class="form-wrapper">
-
-          <div style="display: flex; flex-direction: column;">
-
-            <div class="panel" id="probe-main">
-              <h2>General</h2>
-              <div class="panel-body">
-                <div>
-                  ${self.render_main_fields(form)}
-                </div>
-              </div>
-            </div>
-
-            <div style="display: flex;">
-              <div class="panel-wrapper">
-                ${self.left_column()}
-              </div>
-              <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
-                ${self.right_column()}
-              </div>
-            </div>
+<%def name="page_content()">
+  <div class="form-wrapper">
+    <div style="display: flex; flex-direction: column;">
 
+      <nav class="panel" id="probe-main">
+        <p class="panel-heading">General</p>
+        <div class="panel-block">
+          <div>
+            ${self.render_main_fields(form)}
           </div>
         </div>
+      </nav>
 
-        <ul id="context-menu">
-          ${self.context_menu_items()}
-        </ul>
-
+      <div style="display: flex;">
+        <div class="panel-wrapper">
+          ${self.left_column()}
+        </div>
+        <div class="panel-wrapper" style="margin-left: 1em;"> <!-- right column -->
+          ${self.right_column()}
+        </div>
       </div>
-  % endif
+
+    </div>
+  </div>
 </%def>
 
 
@@ -113,43 +56,25 @@
 </%def>
 
 <%def name="left_column()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">Temperatures</p>
-        <div class="panel-block">
-          <div>
-            ${self.render_temperature_fields(form)}
-          </div>
-        </div>
-      </nav>
-  % else:
-  <div class="panel">
-    <h2>Temperatures</h2>
-    <div class="panel-body">
-      ${self.render_temperature_fields(form)}
+  <nav class="panel">
+    <p class="panel-heading">Temperatures</p>
+    <div class="panel-block">
+      <div>
+        ${self.render_temperature_fields(form)}
+      </div>
     </div>
-  </div>
-  % endif
+  </nav>
 </%def>
 
 <%def name="right_column()">
-  % if use_buefy:
-      <nav class="panel">
-        <p class="panel-heading">Timeouts</p>
-        <div class="panel-block">
-          <div>
-            ${self.render_timeout_fields(form)}
-          </div>
-        </div>
-      </nav>
-  % else:
-  <div class="panel">
-    <h2>Timeouts</h2>
-    <div class="panel-body">
-      ${self.render_timeout_fields(form)}
+  <nav class="panel">
+    <p class="panel-heading">Timeouts</p>
+    <div class="panel-block">
+      <div>
+        ${self.render_timeout_fields(form)}
+      </div>
     </div>
-  </div>
-  % endif
+  </nav>
 </%def>
 
 <%def name="render_temperature_fields(form)">
diff --git a/tailbone/templates/themes/bobcat/base.mako b/tailbone/templates/themes/bobcat/base.mako
deleted file mode 100644
index d67b390f..00000000
--- a/tailbone/templates/themes/bobcat/base.mako
+++ /dev/null
@@ -1,311 +0,0 @@
-## -*- coding: utf-8; -*-
-<%namespace file="/grids/nav.mako" import="grid_index_nav" />
-<%namespace file="/feedback_dialog.mako" import="feedback_dialog" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
-    <title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
-    ${base_meta.favicon()}
-    ${self.header_core()}
-
-    % if background_color:
-        <style type="text/css">
-          body, .navbar, .footer {
-              background-color: ${background_color};
-          }
-        </style>
-    % endif
-
-    % if not request.rattail_config.production():
-        <style type="text/css">
-          body, .navbar, .footer {
-            background-image: url(${request.static_url('tailbone:static/img/testing.png')});
-          }
-        </style>
-    % endif
-
-    ${self.head_tags()}
-  </head>
-
-  <body>
-    <header>
-
-      <nav class="navbar" role="navigation" aria-label="main navigation">
-        <div class="navbar-menu">
-          <div class="navbar-start">
-
-            % for topitem in menus:
-                % if topitem.is_link:
-                    ${h.link_to(topitem.title, topitem.url, target=topitem.target, class_='navbar-item')}
-                % else:
-                    <div class="navbar-item has-dropdown is-hoverable">
-                      <a class="navbar-link">${topitem.title}</a>
-                      <div class="navbar-dropdown">
-                        % for subitem in topitem.items:
-                            % if subitem.is_sep:
-                                <hr class="navbar-divider">
-                            % else:
-                                ${h.link_to(subitem.title, subitem.url, class_='navbar-item', target=subitem.target)}
-                            % endif
-                        % endfor
-                      </div>
-                    </div>
-                % endif
-            % endfor
-
-          </div><!-- navbar-start -->
-          <div class="navbar-end">
-
-            ## User Menu
-            % if request.user:
-                <div class="navbar-item has-dropdown is-hoverable">
-                  % if messaging_enabled:
-                      <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a>
-                  % else:
-                      <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a>
-                  % endif
-                  <div class="navbar-dropdown">
-                    % if request.is_root:
-                        ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
-                    % elif request.is_admin:
-                        ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
-                    % endif
-                    % if messaging_enabled:
-                        ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
-                    % endif
-                    ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
-                    ${h.link_to("Logout", url('logout'), class_='navbar-item')}
-                  </div>
-                </div>
-            % else:
-                ${h.link_to("Login", url('login'), class_='navbar-item')}
-            % endif
-
-          </div><!-- navbar-end -->
-        </div>
-      </nav>
-
-      <nav class="level">
-        <div class="level-left">
-
-          ## App Logo / Name
-          <div class="level-item">
-            <a class="home" href="${url('home')}">
-              <div id="header-logo">${base_meta.header_logo()}</div>
-              <span class="global-title">${base_meta.global_title()}</span>
-            </a>
-          </div>
-
-          ## Current Context
-          <div id="current-context" class="level-item">
-            % if master:
-                <span>&raquo;</span>
-                % if master.listing:
-                    <span>${index_title}</span>
-                % else:
-                    ${h.link_to(index_title, index_url)}
-                    % if parent_url is not Undefined:
-                        <span>&raquo;</span>
-                        ${h.link_to(parent_title, parent_url)}
-                    % elif instance_url is not Undefined:
-                        <span>&raquo;</span>
-                        ${h.link_to(instance_title, instance_url)}
-                    % endif
-                    % if master.viewing and grid_index:
-                        ${grid_index_nav()}
-                    % endif
-                % endif
-            % elif index_title:
-                <span>&raquo;</span>
-                <span>${index_title}</span>
-            % endif
-          </div>
-
-        </div><!-- level-left -->
-        <div class="level-right">
-
-          ## Theme Picker
-          % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-              <div class="level-item">
-                ${h.form(url('change_theme'), method="post")}
-                ${h.csrf_token(request)}
-                Theme:
-                <div class="theme-picker">
-                  <div class="select">
-                    ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')}
-                  </div>
-                </div>
-                ${h.end_form()}
-              </div>
-          % endif
-
-          ## Help Button
-          % if help_url is not Undefined and help_url:
-              <div class="level-item">
-                ${h.link_to("Help", help_url, target='_blank', class_='button')}
-              </div>
-          % endif
-
-          ## Feedback Button
-          <div class="level-item">
-            <button type="button" class="button is-primary" id="feedback">Feedback</button>
-          </div>
-
-        </div><!-- level-right -->
-      </nav><!-- level -->
-    </header>
-
-    ## Page Title
-    <section id="content-title" class="hero is-primary">
-      <div class="container">
-        % if capture(self.content_title):
-
-            % if show_prev_next is not Undefined and show_prev_next:
-                <div style="float: right;">
-                  % if prev_url:
-                      ${h.link_to("« Older", prev_url, class_='button autodisable')}
-                  % else:
-                      ${h.link_to("« Older", '#', class_='button', disabled='disabled')}
-                  % endif
-                  % if next_url:
-                      ${h.link_to("Newer »", next_url, class_='button autodisable')}
-                  % else:
-                      ${h.link_to("Newer »", '#', class_='button', disabled='disabled')}
-                  % endif
-                </div>
-            % endif
-
-            <h1 class="title">${self.content_title()}</h1>
-        % endif
-      </div>
-    </section>
-
-    <div class="content-wrapper">
-
-    ## Page Body
-    <section id="page-body">
-
-      % if request.session.peek_flash('error'):
-          % for error in request.session.pop_flash('error'):
-              <div class="notification is-warning">
-                <!-- <button class="delete"></button> -->
-                ${error}
-              </div>
-          % endfor
-      % endif
-
-      % if request.session.peek_flash():
-          % for msg in request.session.pop_flash():
-              <div class="notification is-info">
-                <!-- <button class="delete"></button> -->
-                ${msg}
-              </div>
-          % endfor
-      % endif
-
-      ${self.body()}
-    </section>
-
-    ## Feedback Dialog
-    ${feedback_dialog()}
-
-    ## Footer
-    <footer class="footer">
-      <div class="content">
-        ${base_meta.footer()}
-      </div>
-    </footer>
-
-    </div><!-- content-wrapper -->
-
-  </body>
-</html>
-
-<%def name="title()"></%def>
-
-<%def name="content_title()">
-  ${self.title()}
-</%def>
-
-<%def name="header_core()">
-
-  ${self.core_javascript()}
-  ${self.extra_javascript()}
-  ${self.core_styles()}
-  ${self.extra_styles()}
-
-  ## TODO: should this be elsewhere / more customizable?
-  % if dform is not Undefined:
-      <% resources = dform.get_widget_resources() %>
-      % for path in resources['js']:
-          ${h.javascript_link(request.static_url(path))}
-      % endfor
-      % for path in resources['css']:
-          ${h.stylesheet_link(request.static_url(path))}
-      % endfor
-  % endif
-</%def>
-
-<%def name="core_javascript()">
-  ${self.jquery()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.timepicker.js'))}
-  <script type="text/javascript">
-    var session_timeout = ${request.get_session_timeout() or 'null'};
-    var logout_url = '${request.route_url('logout')}';
-    var noop_url = '${request.route_url('noop')}';
-    % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-        $(function() {
-            $('#theme-picker').change(function() {
-                $(this).parents('form:first').submit();
-            });
-        });
-    % endif
-  </script>
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js') + '?ver={}'.format(tailbone.__version__))}
-</%def>
-
-<%def name="jquery()">
-  ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')}
-  ${h.javascript_link('https://code.jquery.com/ui/{}/jquery-ui.min.js'.format(request.rattail_config.get('tailbone', 'jquery_ui.version', default='1.11.4')))}
-</%def>
-
-<%def name="extra_javascript()"></%def>
-
-<%def name="core_styles()">
-
-  ${h.stylesheet_link('https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css')}
-
-  ${self.jquery_theme()}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.timepicker.css'))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.tailbone.css') + '?ver={}'.format(tailbone.__version__))}
-
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/base.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
-</%def>
-
-<%def name="jquery_theme()">
-  ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')}
-</%def>
-
-<%def name="extra_styles()"></%def>
-
-<%def name="head_tags()"></%def>
-
-<%def name="wtfield(form, name, **kwargs)">
-  <div class="field-wrapper${' error' if form[name].errors else ''}">
-    <label for="${name}">${form[name].label}</label>
-    <div class="field">
-      ${form[name](**kwargs)}
-    </div>
-  </div>
-</%def>
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
new file mode 100644
index 00000000..b69eacfb
--- /dev/null
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -0,0 +1,1245 @@
+## -*- coding: utf-8; -*-
+<%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace name="page_help" file="/page_help.mako" />
+<%namespace file="/field-components.mako" import="make_field_components" />
+<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
+<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
+<%namespace file="/buefy-components.mako" import="make_buefy_components" />
+<%namespace file="/buefy-plugin.mako" import="make_buefy_plugin" />
+<%namespace file="/http-plugin.mako" import="make_http_plugin" />
+## <%namespace file="/grids/nav.mako" import="grid_index_nav" />
+## <%namespace name="multi_file_upload" file="/multi_file_upload.mako" />
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+    <title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
+    ${base_meta.favicon()}
+    ${self.header_core()}
+    ${self.head_tags()}
+  </head>
+
+  <body>
+    <div id="app" style="height: 100%;">
+      <whole-page></whole-page>
+    </div>
+
+    ## TODO: this must come before the self.body() call..but why?
+    ${declare_formposter_mixin()}
+
+    ## content body from derived/child template
+    ${self.body()}
+
+    ## Vue app
+    ${self.render_vue_templates()}
+    ${self.modify_vue_vars()}
+    ${self.make_vue_components()}
+    ${self.make_vue_app()}
+  </body>
+</html>
+
+<%def name="title()"></%def>
+
+<%def name="content_title()">
+  ${self.title()}
+</%def>
+
+<%def name="header_core()">
+  ${self.core_javascript()}
+  ${self.core_styles()}
+</%def>
+
+<%def name="core_javascript()">
+  <script type="importmap">
+    {
+        ## TODO: eventually version / url should be configurable
+        "imports": {
+            "vue": "${h.get_liburl(request, 'bb_vue', prefix='tailbone')}",
+            "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga', prefix='tailbone')}",
+            "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma', prefix='tailbone')}",
+            "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core', prefix='tailbone')}",
+            "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons', prefix='tailbone')}",
+            "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome', prefix='tailbone')}"
+        }
+    }
+  </script>
+  <script>
+    // empty stub to avoid errors for older buefy templates
+    const Vue = {
+        component(tagname, classname) {},
+    }
+  </script>
+</%def>
+
+<%def name="core_styles()">
+  % if user_css:
+      ${h.stylesheet_link(user_css)}
+  % else:
+      ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))}
+  % endif
+</%def>
+
+<%def name="head_tags()">
+  ${self.extra_javascript()}
+  ${self.extra_styles()}
+</%def>
+
+<%def name="extra_javascript()">
+##   ## some commonly-useful logic for detecting (non-)numeric input
+##   ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))}
+## 
+##   ## debounce, for better autocomplete performance
+##   ${h.javascript_link(request.static_url('tailbone:static/js/debounce.js') + '?ver={}'.format(tailbone.__version__))}
+
+##   ## Tailbone / Buefy stuff
+##   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))}
+
+##   <script type="text/javascript">
+## 
+##     ## NOTE: this code was copied from
+##     ## https://bulma.io/documentation/components/navbar/#navbar-menu
+## 
+##     document.addEventListener('DOMContentLoaded', () => {
+## 
+##         // Get all "navbar-burger" elements
+##         const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0)
+## 
+##         // Add a click event on each of them
+##         $navbarBurgers.forEach( el => {
+##             el.addEventListener('click', () => {
+## 
+##                 // Get the target from the "data-target" attribute
+##                 const target = el.dataset.target
+##                 const $target = document.getElementById(target)
+## 
+##                 // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
+##                 el.classList.toggle('is-active')
+##                 $target.classList.toggle('is-active')
+## 
+##             })
+##         })
+##     })
+## 
+##   </script>
+</%def>
+
+<%def name="extra_styles()">
+
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
+##   ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
+
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))}
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
+
+  ## nb. this is used (only?) in /generate-feature page
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))}
+
+  <style>
+
+    /* ****************************** */
+    /* page */
+    /* ****************************** */
+
+    /* nb. helps force footer to bottom of screen */
+    html, body {
+        height: 100%;
+    }
+
+    ## maybe add testing watermark
+    % if not request.rattail_config.production():
+        html, .navbar, .footer {
+          background-image: url(${request.static_url('tailbone:static/img/testing.png')});
+        }
+    % endif
+
+    ## maybe force global background color
+    % if background_color:
+        body, .navbar, .footer {
+            background-color: ${background_color};
+        }
+    % endif
+
+    #content-title h1 {
+        max-width: 50%;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+    }
+
+    ## TODO: is this a good idea?
+    h1.title {
+        font-size: 2rem;
+        font-weight: bold;
+        margin-bottom: 0 !important;
+    }
+
+    #context-menu {
+        margin-bottom: 1em;
+        /* margin-left: 1em; */
+        text-align: right;
+        /* white-space: nowrap; */
+    }
+
+    ## TODO: ugh why is this needed to center modal on screen?
+    .modal .modal-content .modal-card {
+        margin: auto;
+    }
+
+    .object-helpers .panel {
+        margin: 1rem;
+        margin-bottom: 1.5rem;
+    }
+
+    /* ****************************** */
+    /* grids */
+    /* ****************************** */
+
+    .filters .filter-fieldname .button {
+        min-width: ${filter_fieldname_width};
+        justify-content: left;
+    }
+    .filters .filter-verb {
+        min-width: ${filter_verb_width};
+    }
+
+    .grid-tools {
+        display: flex;
+        gap: 0.5rem;
+        justify-content: end;
+    }
+
+    a.grid-action {
+        align-items: center;
+        display: inline-flex;
+        gap: 0.1rem;
+        white-space: nowrap;
+    }
+
+    /**************************************************
+     * grid rows which are "checked" (selected)
+     **************************************************/
+
+    /* TODO: this references some color values, whereas it would be preferable
+     * to refer to some sort of "state" instead, color of which was
+     * configurable.  b/c these are just the default Buefy theme colors. */
+
+    tr.is-checked {
+        background-color: #7957d5;
+        color: white;
+    }
+
+    tr.is-checked:hover {
+        color: #363636;
+    }
+
+    tr.is-checked a {
+        color: white;
+    }
+
+    tr.is-checked:hover a {
+        color: #7957d5;
+    }
+
+    /* ****************************** */
+    /* forms */
+    /* ****************************** */
+
+    /* note that these should only apply to "normal" primary forms */
+
+    .form {
+        padding-left: 5em;
+    }
+
+    /* .form-wrapper .form .field.is-horizontal .field-label .label, */
+    .form-wrapper .field.is-horizontal .field-label {
+        text-align: left;
+        white-space: nowrap;
+        min-width: 18em;
+    }
+
+    .form-wrapper .form .field.is-horizontal .field-body {
+        min-width: 30em;
+    }
+
+    .form-wrapper .form .field.is-horizontal .field-body .autocomplete,
+    .form-wrapper .form .field.is-horizontal .field-body .autocomplete .dropdown-trigger,
+    .form-wrapper .form .field.is-horizontal .field-body .select,
+    .form-wrapper .form .field.is-horizontal .field-body .select select {
+        width: 100%;
+    }
+
+    .form-wrapper .form .buttons {
+        padding-left: 10rem;
+    }
+
+    /******************************
+     * fix datepicker within modals
+     * TODO: someday this may not be necessary? cf.
+     * https://github.com/buefy/buefy/issues/292#issuecomment-347365637
+     ******************************/
+
+    /* TODO: this does change some things, but does not actually work 100% */
+    /* right for oruga 0.8.7 or 0.8.9 */
+
+    .modal .animation-content .modal-card {
+        overflow: visible !important;
+    }
+
+    .modal-card-body {
+        overflow: visible !important;
+    }
+
+    /* TODO: a simpler option we might try sometime instead?  */
+    /* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
+
+    /* .dropdown-content{ */
+    /*     position: fixed; */
+    /* } */
+
+  </style>
+  ${base_meta.extra_styles()}
+</%def>
+
+<%def name="make_feedback_component()">
+  <% request.register_component('feedback-form', 'FeedbackForm') %>
+  <script type="text/x-template" id="feedback-form-template">
+    <div>
+
+      <o-button variant="primary"
+                @click="showFeedback()"
+                icon-left="comment">
+        Feedback
+      </o-button>
+
+      <o-modal v-model:active="showDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">
+              User Feedback
+            </p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              Questions, suggestions, comments, complaints, etc.
+              <span class="red">regarding this website</span> are
+              welcome and may be submitted below.
+            </p>
+
+            <b-field label="User Name">
+              <b-input v-model="userName"
+                       % if request.user:
+                       disabled
+                       % endif
+                       expanded>
+              </b-input>
+            </b-field>
+
+            <b-field label="Referring URL">
+              <b-input
+                 v-model="referrer"
+                 disabled expanded>
+              </b-input>
+            </b-field>
+
+            <o-field label="Message">
+              <o-input type="textarea"
+                       v-model="message"
+                       ref="message"
+                       expanded>
+              </o-input>
+            </o-field>
+
+            % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'):
+                <div class="level">
+                  <div class="level-left">
+                    <div class="level-item">
+                      <b-checkbox v-model="pleaseReply"
+                                  @input="pleaseReplyChanged">
+                        Please email me back{{ pleaseReply ? " at: " : "" }}
+                      </b-checkbox>
+                    </div>
+                    <div class="level-item" v-show="pleaseReply">
+                      <b-input v-model="userEmail"
+                               ref="userEmail">
+                      </b-input>
+                    </div>
+                  </div>
+                </div>
+            % endif
+
+          </section>
+
+          <footer class="modal-card-foot">
+            <o-button @click="showDialog = false">
+              Cancel
+            </o-button>
+            <o-button variant="primary"
+                      @click="sendFeedback()"
+                      :disabled="sending || !message?.trim()">
+              {{ sending ? "Working, please wait..." : "Send Message" }}
+            </o-button>
+          </footer>
+        </div>
+      </o-modal>
+    </div>
+  </script>
+  <script>
+
+    const FeedbackForm = {
+        template: '#feedback-form-template',
+        mixins: [SimpleRequestMixin],
+
+        props: {
+            action: String,
+        },
+
+        data() {
+            return {
+                referrer: null,
+                % if request.user:
+                    userUUID: ${json.dumps(request.user.uuid)|n},
+                    userName: ${json.dumps(str(request.user))|n},
+                % else:
+                    userUUID: null,
+                    userName: null,
+                % endif
+                message: null,
+                pleaseReply: false,
+                userEmail: null,
+                showDialog: false,
+                sending: false,
+            }
+        },
+
+        methods: {
+
+            pleaseReplyChanged(value) {
+                this.$nextTick(() => {
+                    this.$refs.userEmail.focus()
+                })
+            },
+
+            showFeedback() {
+                this.referrer = location.href
+                this.message = null
+                this.showDialog = true
+                this.$nextTick(function() {
+                    this.$refs.message.focus()
+                })
+            },
+
+            sendFeedback() {
+                this.sending = true
+
+                const params = {
+                    referrer: this.referrer,
+                    user: this.userUUID,
+                    user_name: this.userName,
+                    please_reply_to: this.pleaseReply ? this.userEmail : '',
+                    message: this.message?.trim(),
+                }
+
+                this.simplePOST(this.action, params, response => {
+
+                    this.$buefy.toast.open({
+                        message: "Message sent!  Thank you for your feedback.",
+                        type: 'is-info',
+                        duration: 4000, // 4 seconds
+                    })
+
+                    this.sending = false
+                    this.showDialog = false
+
+                }, response => {
+                    this.sending = false
+                })
+            },
+        }
+    }
+
+  </script>
+</%def>
+
+<%def name="make_menu_search_component()">
+  <% request.register_component('menu-search', 'MenuSearch') %>
+  <script type="text/x-template" id="menu-search-template">
+    <div style="display: flex;">
+
+      <a v-show="!searchActive"
+         href="${url('home')}"
+         class="navbar-item"
+         style="display: flex; gap: 0.5rem;">
+        ${base_meta.header_logo()}
+        <div id="global-header-title">
+          ${base_meta.global_title()}
+        </div>
+      </a>
+
+      <div v-show="searchActive"
+           class="navbar-item">
+        <o-autocomplete ref="searchAutocomplete"
+                        v-model="searchTerm"
+                        :data="searchFilteredData"
+                        field="label"
+                        open-on-focus
+                        keep-first
+                        icon-pack="fas"
+                        clearable
+                        @select="searchSelect">
+        </o-autocomplete>
+      </div>
+    </div>
+  </script>
+  <script>
+
+    const MenuSearch = {
+        template: '#menu-search-template',
+
+        props: {
+            searchData: Array,
+        },
+
+        data() {
+            return {
+                searchActive: false,
+                searchTerm: null,
+                searchInput: null,
+            }
+        },
+
+        computed: {
+
+            searchFilteredData() {
+                if (!this.searchTerm || !this.searchTerm.length) {
+                    return this.searchData
+                }
+
+                let terms = []
+                for (let term of this.searchTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+                if (!terms.length) {
+                    return this.searchData
+                }
+
+                // all terms must match
+                return this.searchData.filter((option) => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+            },
+        },
+
+        mounted() {
+            this.searchInput = this.$refs.searchAutocomplete.$el.querySelector('input')
+            this.searchInput.addEventListener('keydown', this.searchKeydown)
+        },
+
+        beforeDestroy() {
+            this.searchInput.removeEventListener('keydown', this.searchKeydown)
+        },
+
+        methods: {
+
+            searchInit() {
+                this.searchTerm = ''
+                this.searchActive = true
+                this.$nextTick(() => {
+                    this.$refs.searchAutocomplete.focus()
+                })
+            },
+
+            searchKeydown(event) {
+                // ESC will dismiss searchbox
+                if (event.which == 27) {
+                    this.searchActive = false
+                }
+            },
+
+            searchSelect(option) {
+                location.href = option.url
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="render_vue_template_whole_page()">
+  <script type="text/x-template" id="whole-page-template">
+    <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
+
+      <div class="header-wrapper">
+
+        <header>
+
+          <!-- this main menu, with search -->
+          <nav class="navbar" role="navigation" aria-label="main navigation"
+               style="display: flex; align-items: center;">
+
+            <div class="navbar-brand">
+              <menu-search :search-data="globalSearchData"
+                           ref="menuSearch" />
+              <a role="button" class="navbar-burger" data-target="navbarMenu" aria-label="menu" aria-expanded="false">
+                <span aria-hidden="true"></span>
+                <span aria-hidden="true"></span>
+                <span aria-hidden="true"></span>
+                <span aria-hidden="true"></span>
+              </a>
+            </div>
+
+            <div class="navbar-menu" id="navbarMenu"
+                 style="display: flex; align-items: center;"
+                 >
+              <div class="navbar-start">
+
+                ## global search button
+                <div v-if="globalSearchData.length"
+                     class="navbar-item">
+                  <o-button variant="primary"
+                            size="small"
+                            @click="globalSearchInit()">
+                    <o-icon icon="search" size="small" />
+                  </o-button>
+                </div>
+
+                ## main menu
+                % for topitem in menus:
+                    % if topitem['is_link']:
+                        ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')}
+                    % else:
+                        <div class="navbar-item has-dropdown is-hoverable">
+                          <a class="navbar-link">${topitem['title']}</a>
+                          <div class="navbar-dropdown">
+                            % for item in topitem['items']:
+                                % if item['is_menu']:
+                                    <% item_hash = id(item) %>
+                                    <% toggle = f'menu_{item_hash}_shown' %>
+                                    <div>
+                                      <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')">
+                                        ${item['title']}
+                                      </a>
+                                    </div>
+                                    % for subitem in item['items']:
+                                        % if subitem['is_sep']:
+                                            <hr class="navbar-divider" v-show="${toggle}">
+                                        % else:
+                                            ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})}
+                                        % endif
+                                    % endfor
+                                % else:
+                                    % if item['is_sep']:
+                                        <hr class="navbar-divider">
+                                    % else:
+                                        ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])}
+                                    % endif
+                                % endif
+                            % endfor
+                          </div>
+                        </div>
+                    % endif
+                % endfor
+
+              </div><!-- navbar-start -->
+              ${self.render_navbar_end()}
+            </div>
+          </nav>
+
+          <!-- nb. this has index title, help button etc. -->
+          <nav style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem;">
+
+            ## Current Context
+            <div style="display: flex; gap: 0.5rem; align-items: center;">
+              % if master:
+                  % if master.listing:
+                      <h1 class="title">
+                        ${index_title}
+                      </h1>
+                      % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
+                          <once-button type="is-primary"
+                                       tag="a" href="${url('{}.create'.format(route_prefix))}"
+                                       icon-left="plus"
+                                       style="margin-left: 1rem;"
+                                       text="Create New">
+                          </once-button>
+                      % endif
+                  % elif index_url:
+                      <h1 class="title">
+                        ${h.link_to(index_title, index_url)}
+                      </h1>
+                      % if parent_url is not Undefined:
+                          <h1 class="title">
+                            &nbsp;&raquo;
+                          </h1>
+                          <h1 class="title">
+                            ${h.link_to(parent_title, parent_url)}
+                          </h1>
+                      % elif instance_url is not Undefined:
+                          <h1 class="title">
+                            &nbsp;&raquo;
+                          </h1>
+                          <h1 class="title">
+                            ${h.link_to(instance_title, instance_url)}
+                          </h1>
+                      % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
+                          % if not request.matched_route.name.endswith('.create'):
+                              <once-button type="is-primary"
+                                           tag="a" href="${url('{}.create'.format(route_prefix))}"
+                                           icon-left="plus"
+                                           style="margin-left: 1rem;"
+                                           text="Create New">
+                              </once-button>
+                          % endif
+                      % endif
+##                         % if master.viewing and grid_index:
+##                             ${grid_index_nav()}
+##                         % endif
+                  % else:
+                      <h1 class="title">
+                        ${index_title}
+                      </h1>
+                  % endif
+              % elif index_title:
+                  % if index_url:
+                      <h1 class="title">
+                        ${h.link_to(index_title, index_url)}
+                      </h1>
+                  % else:
+                      <h1 class="title">
+                        ${index_title}
+                      </h1>
+                  % endif
+              % endif
+
+              % if expose_db_picker is not Undefined and expose_db_picker:
+                  <span>DB:</span>
+                  ${h.form(url('change_db_engine'), ref='dbPickerForm')}
+                  ${h.csrf_token(request)}
+                  ${h.hidden('engine_type', value=master.engine_type_key)}
+                  <input type="hidden" name="referrer" :value="referrer" />
+                  <b-select name="dbkey"
+                            v-model="dbSelected"
+                            @input="changeDB()">
+                    % for option in db_picker_options:
+                        <option value="${option.value}">
+                          ${option.label}
+                        </option>
+                    % endfor
+                  </b-select>
+                  ${h.end_form()}
+              % endif
+
+            </div>
+
+            <div style="display: flex; gap: 0.5rem;">
+
+              ## Quickie Lookup
+              % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
+                  ${h.form(quickie.url, method='get', style='display: flex; gap: 0.5rem; margin-right: 1rem;')}
+                    <b-input name="entry"
+                             placeholder="${quickie.placeholder}"
+                             autocomplete="off">
+                    </b-input>
+                    <o-button variant="primary"
+                              native-type="submit"
+                              icon-left="search">
+                      Lookup
+                    </o-button>
+                  ${h.end_form()}
+              % endif
+
+              % if master and master.configurable and master.has_perm('configure'):
+                  % if not request.matched_route.name.endswith('.configure'):
+                      <once-button type="is-primary"
+                                   tag="a"
+                                   href="${url('{}.configure'.format(route_prefix))}"
+                                   icon-left="cog"
+                                   text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}">
+                      </once-button>
+                  % endif
+              % endif
+
+              ## Theme Picker
+              % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+                  ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
+                    ${h.csrf_token(request)}
+                    <input type="hidden" name="referrer" :value="referrer" />
+                    <div style="display: flex; align-items: center; gap: 0.5rem;">
+                      <span>Theme:</span>
+                      <b-select name="theme"
+                                v-model="globalTheme"
+                                @input="changeTheme()">
+                        % for option in theme_picker_options:
+                            <option value="${option.value}">
+                              ${option.label}
+                            </option>
+                        % endfor
+                      </b-select>
+                    </div>
+                  ${h.end_form()}
+              % endif
+
+              % if help_url or help_markdown or can_edit_help:
+                  <page-help
+                    % if can_edit_help:
+                    @configure-fields-help="configureFieldsHelp = true"
+                    % endif
+                    >
+                  </page-help>
+              % endif
+
+              ## Feedback Button / Dialog
+              % if request.has_perm('common.feedback'):
+                  <feedback-form action="${url('feedback')}" />
+              % endif
+            </div>
+          </nav>
+        </header>
+
+        ## Page Title
+        % if capture(self.content_title):
+            <section class="has-background-primary"
+                     ## TODO: id is only for css, do we need it?
+                     id="content-title"
+                     style="padding: 0.5rem; padding-left: 1rem;">
+              <div style="display: flex; align-items: center; gap: 1rem;">
+
+                <h1 class="title has-text-white" v-html="contentTitleHTML" />
+
+                <div style="flex-grow: 1; display: flex; gap: 0.5rem;">
+                  ${self.render_instance_header_title_extras()}
+                </div>
+
+                <div style="display: flex; gap: 0.5rem;">
+                  ${self.render_instance_header_buttons()}
+                </div>
+
+              </div>
+            </section>
+        % endif
+
+      </div> <!-- header-wrapper -->
+
+      <div class="content-wrapper"
+           style="flex-grow: 1; padding: 0.5rem;">
+
+        ## Page Body
+        <section id="page-body">
+
+          % if request.session.peek_flash('error'):
+              % for error in request.session.pop_flash('error'):
+                  <b-notification type="is-warning">
+                    ${error}
+                  </b-notification>
+              % endfor
+          % endif
+
+          % if request.session.peek_flash('warning'):
+              % for msg in request.session.pop_flash('warning'):
+                  <b-notification type="is-warning">
+                    ${msg}
+                  </b-notification>
+              % endfor
+          % endif
+
+          % if request.session.peek_flash():
+              % for msg in request.session.pop_flash():
+                  <b-notification type="is-info">
+                    ${msg}
+                  </b-notification>
+              % endfor
+          % endif
+
+          ## true page content
+          <div>
+            ${self.render_this_page_component()}
+          </div>
+        </section>
+      </div><!-- content-wrapper -->
+
+      ## Footer
+      <footer class="footer">
+        <div class="content">
+          ${base_meta.footer()}
+        </div>
+      </footer>
+    </div>
+  </script>
+</%def>
+
+<%def name="render_this_page_component()">
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+             :configure-fields-help="configureFieldsHelp"
+             % endif
+             >
+  </this-page>
+</%def>
+
+<%def name="render_navbar_end()">
+  <div class="navbar-end">
+    ${self.render_user_menu()}
+  </div>
+</%def>
+
+<%def name="render_user_menu()">
+  % if request.user:
+      <div class="navbar-item has-dropdown is-hoverable">
+        % if messaging_enabled:
+            <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a>
+        % else:
+            <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a>
+        % endif
+        <div class="navbar-dropdown">
+          % if request.is_root:
+              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.stopBeingRootForm.submit()"
+                 class="navbar-item has-background-danger has-text-white">
+                Stop being root
+              </a>
+              ${h.end_form()}
+          % elif request.is_admin:
+              ${h.form(url('become_root'), ref='startBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.startBeingRootForm.submit()"
+                 class="navbar-item has-background-danger has-text-white">
+                Become root
+              </a>
+              ${h.end_form()}
+          % endif
+          % if messaging_enabled:
+              ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
+          % endif
+          % if request.is_root or not request.user.prevent_password_change:
+              ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
+          % endif
+          ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
+          ${h.link_to("Logout", url('logout'), class_='navbar-item')}
+        </div>
+      </div>
+  % else:
+      ${h.link_to("Login", url('login'), class_='navbar-item')}
+  % endif
+</%def>
+
+<%def name="render_instance_header_title_extras()"></%def>
+
+<%def name="render_instance_header_buttons()">
+  ${self.render_crud_header_buttons()}
+  ${self.render_prevnext_header_buttons()}
+</%def>
+
+<%def name="render_crud_header_buttons()">
+% if master and master.viewing and not getattr(master, 'cloning', False):
+      ## TODO: is there a better way to check if viewing parent?
+      % if parent_instance is Undefined:
+          % if master.editable and instance_editable and master.has_perm('edit'):
+              <once-button tag="a" href="${master.get_action_url('edit', instance)}"
+                           icon-left="edit"
+                           text="Edit This">
+              </once-button>
+          % endif
+          % if not getattr(master, 'cloning', False) and getattr(master, 'cloneable', False) and master.has_perm('clone'):
+              <once-button tag="a" href="${master.get_action_url('clone', instance)}"
+                           icon-left="object-ungroup"
+                           text="Clone This">
+              </once-button>
+          % endif
+          % if master.deletable and instance_deletable and master.has_perm('delete'):
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
+          % endif
+      % else:
+          ## viewing row
+          % if instance_deletable and master.has_perm('delete_row'):
+              <once-button tag="a" href="${master.get_action_url('delete', instance)}"
+                           type="is-danger"
+                           icon-left="trash"
+                           text="Delete This">
+              </once-button>
+          % endif
+      % endif
+  % elif master and master.editing:
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
+      % if master.deletable and instance_deletable and master.has_perm('delete'):
+          <once-button tag="a" href="${master.get_action_url('delete', instance)}"
+                       type="is-danger"
+                       icon-left="trash"
+                       text="Delete This">
+          </once-button>
+      % endif
+  % elif master and master.deleting:
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
+      % if master.editable and instance_editable and master.has_perm('edit'):
+          <once-button tag="a" href="${master.get_action_url('edit', instance)}"
+                       icon-left="edit"
+                       text="Edit This">
+          </once-button>
+      % endif
+  % elif master and getattr(master, 'cloning', False):
+      % if master.viewable and master.has_perm('view'):
+          <once-button tag="a" href="${master.get_action_url('view', instance)}"
+                       icon-left="eye"
+                       text="View This">
+          </once-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="render_prevnext_header_buttons()">
+  % if show_prev_next is not Undefined and show_prev_next:
+      % if prev_url:
+          <b-button tag="a" href="${prev_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % endif
+      % if next_url:
+          <b-button tag="a" href="${next_url}"
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="render_vue_script_whole_page()">
+  <script>
+
+    const WholePage = {
+        template: '#whole-page-template',
+        mixins: [SimpleRequestMixin],
+        computed: {},
+
+        mounted() {
+            window.addEventListener('keydown', this.globalKey)
+            for (let hook of this.mountedHooks) {
+                hook(this)
+            }
+        },
+        beforeDestroy() {
+            window.removeEventListener('keydown', this.globalKey)
+        },
+
+        methods: {
+
+            changeContentTitle(newTitle) {
+                this.contentTitleHTML = newTitle
+            },
+
+            % if expose_db_picker is not Undefined and expose_db_picker:
+                changeDB() {
+                    this.$refs.dbPickerForm.submit()
+                },
+            % endif
+
+            % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+                changeTheme() {
+                    this.$refs.themePickerForm.submit()
+                },
+            % endif
+
+            globalKey(event) {
+
+                // Ctrl+8 opens global search
+                if (event.target.tagName == 'BODY') {
+                    if (event.ctrlKey && event.key == '8') {
+                        this.globalSearchInit()
+                    }
+                }
+            },
+
+            globalSearchInit() {
+                this.$refs.menuSearch.searchInit()
+            },
+
+            toggleNestedMenu(hash) {
+                const key = 'menu_' + hash + '_shown'
+                this[key] = !this[key]
+            },
+        },
+    }
+
+    const WholePageData = {
+        contentTitleHTML: ${json.dumps(capture(self.content_title))|n},
+        globalSearchData: ${json.dumps(global_search_data)|n},
+        mountedHooks: [],
+
+        % if expose_db_picker is not Undefined and expose_db_picker:
+            dbSelected: ${json.dumps(db_picker_selected)|n},
+        % endif
+
+        % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+            globalTheme: ${json.dumps(theme)|n},
+            referrer: location.href,
+        % endif
+
+        % if can_edit_help:
+            configureFieldsHelp: false,
+        % endif
+    }
+
+    ## declare nested menu visibility toggle flags
+    % for topitem in menus:
+        % if topitem['is_menu']:
+            % for item in topitem['items']:
+                % if item['is_menu']:
+                    WholePageData.menu_${id(item)}_shown = false
+                % endif
+            % endfor
+        % endif
+    % endfor
+
+  </script>
+</%def>
+
+##############################
+## vue components + app
+##############################
+
+<%def name="render_vue_templates()">
+##   ${multi_file_upload.render_template()}
+##   ${multi_file_upload.declare_vars()}
+
+  ## global components used by various (but not all) pages
+  ${make_field_components()}
+  ${make_grid_filter_components()}
+
+  ## global components for buefy-based template compatibility
+  ${make_http_plugin()}
+  ${make_buefy_plugin()}
+  ${make_buefy_components()}
+
+  ## special global components, used by WholePage
+  ${self.make_menu_search_component()}
+  ${page_help.render_template()}
+  ${page_help.declare_vars()}
+  % if request.has_perm('common.feedback'):
+      ${self.make_feedback_component()}
+  % endif
+
+  ## DEPRECATED; called for back-compat
+  ${self.render_whole_page_template()}
+
+  ## DEPRECATED; called for back-compat
+  ${self.declare_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_whole_page_template()">
+  ${self.render_vue_template_whole_page()}
+  ${self.render_vue_script_whole_page()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ## DEPRECATED; called for back-compat
+  ${self.modify_whole_page_vars()}
+</%def>
+
+<%def name="make_vue_components()">
+  ${page_help.make_component()}
+  ## ${multi_file_upload.make_component()}
+
+  ## DEPRECATED; called for back-compat (?)
+  ${self.make_whole_page_component()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_component()">
+  <script>
+    WholePage.data = () => { return WholePageData }
+  </script>
+  <% request.register_component('whole-page', 'WholePage') %>
+</%def>
+
+<%def name="make_vue_app()">
+  ## DEPRECATED; called for back-compat
+  ${self.make_whole_page_app()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_app()">
+  <script type="module">
+    import {createApp} from 'vue'
+    import {Oruga} from '@oruga-ui/oruga-next'
+    import {bulmaConfig} from '@oruga-ui/theme-bulma'
+    import { library } from "@fortawesome/fontawesome-svg-core"
+    import { fas } from "@fortawesome/free-solid-svg-icons"
+    import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
+    library.add(fas)
+
+    const app = createApp()
+    app.component('vue-fontawesome', FontAwesomeIcon)
+
+    % if hasattr(request, '_tailbone_registered_components'):
+        % for tagname, classname in request._tailbone_registered_components.items():
+            app.component('${tagname}', ${classname})
+        % endfor
+    % endif
+
+    app.use(Oruga, {
+        ...bulmaConfig,
+        iconComponent: 'vue-fontawesome',
+        iconPack: 'fas',
+    })
+
+    app.use(HttpPlugin)
+    app.use(BuefyPlugin)
+
+    app.mount('#app')
+  </script>
+</%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="declare_whole_page_vars()"></%def>
+
+<%def name="modify_whole_page_vars()"></%def>
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
new file mode 100644
index 00000000..3a2cd798
--- /dev/null
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -0,0 +1,759 @@
+
+<%def name="make_buefy_components()">
+  ${self.make_b_autocomplete_component()}
+  ${self.make_b_button_component()}
+  ${self.make_b_checkbox_component()}
+  ${self.make_b_collapse_component()}
+  ${self.make_b_datepicker_component()}
+  ${self.make_b_dropdown_component()}
+  ${self.make_b_dropdown_item_component()}
+  ${self.make_b_field_component()}
+  ${self.make_b_icon_component()}
+  ${self.make_b_input_component()}
+  ${self.make_b_loading_component()}
+  ${self.make_b_modal_component()}
+  ${self.make_b_notification_component()}
+  ${self.make_b_radio_component()}
+  ${self.make_b_select_component()}
+  ${self.make_b_steps_component()}
+  ${self.make_b_step_item_component()}
+  ${self.make_b_table_component()}
+  ${self.make_b_table_column_component()}
+  ${self.make_b_tooltip_component()}
+  ${self.make_once_button_component()}
+</%def>
+
+<%def name="make_b_autocomplete_component()">
+  <script type="text/x-template" id="b-autocomplete-template">
+    <o-autocomplete v-model="orugaValue"
+                    :data="data"
+                    :field="field"
+                    :open-on-focus="openOnFocus"
+                    :keep-first="keepFirst"
+                    :clearable="clearable"
+                    :clear-on-select="clearOnSelect"
+                    :formatter="customFormatter"
+                    :placeholder="placeholder"
+                    @update:model-value="orugaValueUpdated"
+                    ref="autocomplete">
+    </o-autocomplete>
+  </script>
+  <script>
+    const BAutocomplete = {
+        template: '#b-autocomplete-template',
+        props: {
+            modelValue: String,
+            data: Array,
+            field: String,
+            openOnFocus: Boolean,
+            keepFirst: Boolean,
+            clearable: Boolean,
+            clearOnSelect: Boolean,
+            customFormatter: null,
+            placeholder: String,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                if (this.orugaValue != to) {
+                    this.orugaValue = to
+                }
+            },
+        },
+        methods: {
+            focus() {
+                const input = this.$refs.autocomplete.$el.querySelector('input')
+                input.focus()
+            },
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-autocomplete', 'BAutocomplete') %>
+</%def>
+
+<%def name="make_b_button_component()">
+  <script type="text/x-template" id="b-button-template">
+    <o-button :variant="variant"
+              :size="orugaSize"
+              :native-type="nativeType"
+              :tag="tag"
+              :href="href"
+              :icon-left="iconLeft">
+      <slot />
+    </o-button>
+  </script>
+  <script>
+    const BButton = {
+        template: '#b-button-template',
+        props: {
+            type: String,
+            nativeType: String,
+            tag: String,
+            href: String,
+            size: String,
+            iconPack: String, // ignored
+            iconLeft: String,
+        },
+        computed: {
+            orugaSize() {
+                if (this.size) {
+                    return this.size.replace(/^is-/, '')
+                }
+            },
+            variant() {
+                if (this.type) {
+                    return this.type.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-button', 'BButton') %>
+</%def>
+
+<%def name="make_b_checkbox_component()">
+  <script type="text/x-template" id="b-checkbox-template">
+    <o-checkbox v-model="orugaValue"
+                @update:model-value="orugaValueUpdated"
+                :name="name"
+                :native-value="nativeValue">
+      <slot />
+    </o-checkbox>
+  </script>
+  <script>
+    const BCheckbox = {
+        template: '#b-checkbox-template',
+        props: {
+            modelValue: null,
+            name: String,
+            nativeValue: null,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-checkbox', 'BCheckbox') %>
+</%def>
+
+<%def name="make_b_collapse_component()">
+  <script type="text/x-template" id="b-collapse-template">
+    <o-collapse :open="open">
+      <slot name="trigger" />
+      <slot />
+    </o-collapse>
+  </script>
+  <script>
+    const BCollapse = {
+        template: '#b-collapse-template',
+        props: {
+            open: Boolean,
+        },
+    }
+  </script>
+  <% request.register_component('b-collapse', 'BCollapse') %>
+</%def>
+
+<%def name="make_b_datepicker_component()">
+  <script type="text/x-template" id="b-datepicker-template">
+    <o-datepicker :name="name"
+                  v-model="orugaValue"
+                  @update:model-value="orugaValueUpdated"
+                  :value="value"
+                  :placeholder="placeholder"
+                  :date-formatter="dateFormatter"
+                  :date-parser="dateParser"
+                  :disabled="disabled"
+                  :editable="editable"
+                  :icon="icon"
+                  :close-on-click="false">
+    </o-datepicker>
+  </script>
+  <script>
+    const BDatepicker = {
+        template: '#b-datepicker-template',
+        props: {
+            dateFormatter: null,
+            dateParser: null,
+            disabled: Boolean,
+            editable: Boolean,
+            icon: String,
+            // iconPack: String,   // ignored
+            modelValue: Date,
+            name: String,
+            placeholder: String,
+            value: null,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                if (this.orugaValue != to) {
+                    this.orugaValue = to
+                }
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                if (this.modelValue != value) {
+                    this.$emit('update:modelValue', value)
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-datepicker', 'BDatepicker') %>
+</%def>
+
+<%def name="make_b_dropdown_component()">
+  <script type="text/x-template" id="b-dropdown-template">
+    <o-dropdown :position="buefyPosition"
+                :triggers="triggers">
+      <slot name="trigger" />
+      <slot />
+    </o-dropdown>
+  </script>
+  <script>
+    const BDropdown = {
+        template: '#b-dropdown-template',
+        props: {
+            position: String,
+            triggers: Array,
+        },
+        computed: {
+            buefyPosition() {
+                if (this.position) {
+                    return this.position.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-dropdown', 'BDropdown') %>
+</%def>
+
+<%def name="make_b_dropdown_item_component()">
+  <script type="text/x-template" id="b-dropdown-item-template">
+    <o-dropdown-item :label="label">
+      <slot />
+    </o-dropdown-item>
+  </script>
+  <script>
+    const BDropdownItem = {
+        template: '#b-dropdown-item-template',
+        props: {
+            label: String,
+        },
+    }
+  </script>
+  <% request.register_component('b-dropdown-item', 'BDropdownItem') %>
+</%def>
+
+<%def name="make_b_field_component()">
+  <script type="text/x-template" id="b-field-template">
+    <o-field :grouped="grouped"
+             :label="label"
+             :horizontal="horizontal"
+             :expanded="expanded"
+             :variant="variant">
+      <slot />
+    </o-field>
+  </script>
+  <script>
+    const BField = {
+        template: '#b-field-template',
+        props: {
+            expanded: Boolean,
+            grouped: Boolean,
+            horizontal: Boolean,
+            label: String,
+            type: String,
+        },
+        computed: {
+            variant() {
+                if (this.type) {
+                    return this.type.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-field', 'BField') %>
+</%def>
+
+<%def name="make_b_icon_component()">
+  <script type="text/x-template" id="b-icon-template">
+    <o-icon :icon="icon"
+            :size="orugaSize" />
+  </script>
+  <script>
+    const BIcon = {
+        template: '#b-icon-template',
+        props: {
+            icon: String,
+            size: String,
+        },
+        computed: {
+            orugaSize() {
+                if (this.size) {
+                    return this.size.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-icon', 'BIcon') %>
+</%def>
+
+<%def name="make_b_input_component()">
+  <script type="text/x-template" id="b-input-template">
+    <o-input :type="type"
+             :disabled="disabled"
+             v-model="orugaValue"
+             @update:modelValue="val => $emit('update:modelValue', val)"
+             :autocomplete="autocomplete"
+             ref="input"
+             :expanded="expanded">
+      <slot />
+    </o-input>
+  </script>
+  <script>
+    const BInput = {
+        template: '#b-input-template',
+        props: {
+            modelValue: null,
+            type: String,
+            autocomplete: String,
+            disabled: Boolean,
+            expanded: Boolean,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                if (this.orugaValue != to) {
+                    this.orugaValue = to
+                }
+            },
+        },
+        methods: {
+            focus() {
+                if (this.type == 'textarea') {
+                    // TODO: this does not always work right?
+                    this.$refs.input.$el.querySelector('textarea').focus()
+                } else {
+                    // TODO: pretty sure we can rely on the <o-input> focus()
+                    // here, but not sure why we weren't already doing that?
+                    //this.$refs.input.$el.querySelector('input').focus()
+                    this.$refs.input.focus()
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-input', 'BInput') %>
+</%def>
+
+<%def name="make_b_loading_component()">
+  <script type="text/x-template" id="b-loading-template">
+    <o-loading :full-page="isFullPage">
+      <slot />
+    </o-loading>
+  </script>
+  <script>
+    const BLoading = {
+        template: '#b-loading-template',
+        props: {
+            isFullPage: Boolean,
+        },
+    }
+  </script>
+  <% request.register_component('b-loading', 'BLoading') %>
+</%def>
+
+<%def name="make_b_modal_component()">
+  <script type="text/x-template" id="b-modal-template">
+    <o-modal v-model:active="trueActive"
+             @update:active="activeChanged">
+      <slot />
+    </o-modal>
+  </script>
+  <script>
+    const BModal = {
+        template: '#b-modal-template',
+        props: {
+            active: Boolean,
+            hasModalCard: Boolean, // nb. this is ignored
+        },
+        data() {
+            return {
+                trueActive: this.active,
+            }
+        },
+        watch: {
+            active(to, from) {
+                this.trueActive = to
+            },
+            trueActive(to, from) {
+                if (this.active != to) {
+                    this.tellParent(to)
+                }
+            },
+        },
+        methods: {
+
+            tellParent(active) {
+                // TODO: this does not work properly
+                this.$emit('update:active', active)
+            },
+
+            activeChanged(active) {
+                this.tellParent(active)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-modal', 'BModal') %>
+</%def>
+
+<%def name="make_b_notification_component()">
+  <script type="text/x-template" id="b-notification-template">
+    <o-notification :variant="variant"
+                    :closable="closable">
+      <slot />
+    </o-notification>
+  </script>
+  <script>
+    const BNotification = {
+        template: '#b-notification-template',
+        props: {
+            type: String,
+            closable: {
+                type: Boolean,
+                default: true,
+            },
+        },
+        computed: {
+            variant() {
+                if (this.type) {
+                    return this.type.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-notification', 'BNotification') %>
+</%def>
+
+<%def name="make_b_radio_component()">
+  <script type="text/x-template" id="b-radio-template">
+    <o-radio v-model="orugaValue"
+             @update:model-value="orugaValueUpdated"
+             :native-value="nativeValue">
+      <slot />
+    </o-radio>
+  </script>
+  <script>
+    const BRadio = {
+        template: '#b-radio-template',
+        props: {
+            modelValue: null,
+            nativeValue: null,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-radio', 'BRadio') %>
+</%def>
+
+<%def name="make_b_select_component()">
+  <script type="text/x-template" id="b-select-template">
+    <o-select :name="name"
+              ref="select"
+              v-model="orugaValue"
+              @update:model-value="orugaValueUpdated"
+              :expanded="expanded"
+              :multiple="multiple"
+              :size="orugaSize"
+              :native-size="nativeSize">
+      <slot />
+    </o-select>
+  </script>
+  <script>
+    const BSelect = {
+        template: '#b-select-template',
+        props: {
+            expanded: Boolean,
+            modelValue: null,
+            multiple: Boolean,
+            name: String,
+            nativeSize: null,
+            size: null,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        computed: {
+            orugaSize() {
+                if (this.size) {
+                    return this.size.replace(/^is-/, '')
+                }
+            },
+        },
+        methods: {
+            focus() {
+                this.$refs.select.focus()
+            },
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+                this.$emit('input', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-select', 'BSelect') %>
+</%def>
+
+<%def name="make_b_steps_component()">
+  <script type="text/x-template" id="b-steps-template">
+    <o-steps v-model="orugaValue"
+             @update:model-value="orugaValueUpdated"
+             :animated="animated"
+             :rounded="rounded"
+             :has-navigation="hasNavigation"
+             :vertical="vertical">
+      <slot />
+    </o-steps>
+  </script>
+  <script>
+    const BSteps = {
+        template: '#b-steps-template',
+        props: {
+            modelValue: null,
+            animated: Boolean,
+            rounded: Boolean,
+            hasNavigation: Boolean,
+            vertical: Boolean,
+        },
+        data() {
+            return {
+                orugaValue: this.modelValue,
+            }
+        },
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+        methods: {
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+                this.$emit('input', value)
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-steps', 'BSteps') %>
+</%def>
+
+<%def name="make_b_step_item_component()">
+  <script type="text/x-template" id="b-step-item-template">
+    <o-step-item :step="step"
+                 :value="value"
+                 :label="label"
+                 :clickable="clickable">
+      <slot />
+    </o-step-item>
+  </script>
+  <script>
+    const BStepItem = {
+        template: '#b-step-item-template',
+        props: {
+            step: null,
+            value: null,
+            label: String,
+            clickable: Boolean,
+        },
+    }
+  </script>
+  <% request.register_component('b-step-item', 'BStepItem') %>
+</%def>
+
+<%def name="make_b_table_component()">
+  <script type="text/x-template" id="b-table-template">
+    <o-table :data="data">
+      <slot />
+    </o-table>
+  </script>
+  <script>
+    const BTable = {
+        template: '#b-table-template',
+        props: {
+            data: Array,
+        },
+    }
+  </script>
+  <% request.register_component('b-table', 'BTable') %>
+</%def>
+
+<%def name="make_b_table_column_component()">
+  <script type="text/x-template" id="b-table-column-template">
+    <o-table-column :field="field"
+                    :label="label"
+                    v-slot="props">
+      ## TODO: this does not seem to really work for us...
+      <slot :props="props" />
+    </o-table-column>
+  </script>
+  <script>
+    const BTableColumn = {
+        template: '#b-table-column-template',
+        props: {
+            field: String,
+            label: String,
+        },
+    }
+  </script>
+  <% request.register_component('b-table-column', 'BTableColumn') %>
+</%def>
+
+<%def name="make_b_tooltip_component()">
+  <script type="text/x-template" id="b-tooltip-template">
+    <o-tooltip :label="label"
+               :position="orugaPosition"
+               :multiline="multilined">
+      <slot />
+    </o-tooltip>
+  </script>
+  <script>
+    const BTooltip = {
+        template: '#b-tooltip-template',
+        props: {
+            label: String,
+            multilined: Boolean,
+            position: String,
+        },
+        computed: {
+            orugaPosition() {
+                if (this.position) {
+                    return this.position.replace(/^is-/, '')
+                }
+            },
+        },
+    }
+  </script>
+  <% request.register_component('b-tooltip', 'BTooltip') %>
+</%def>
+
+<%def name="make_once_button_component()">
+  <script type="text/x-template" id="once-button-template">
+    <b-button :type="type"
+              :native-type="nativeType"
+              :tag="tag"
+              :href="href"
+              :title="title"
+              :disabled="buttonDisabled"
+              @click="clicked"
+              icon-pack="fas"
+              :icon-left="iconLeft">
+      {{ buttonText }}
+    </b-button>
+  </script>
+  <script>
+    const OnceButton = {
+        template: '#once-button-template',
+        props: {
+            type: String,
+            nativeType: String,
+            tag: String,
+            href: String,
+            text: String,
+            title: String,
+            iconLeft: String,
+            working: String,
+            workingText: String,
+            disabled: Boolean,
+        },
+        data() {
+            return {
+                currentText: null,
+                currentDisabled: null,
+            }
+        },
+        computed: {
+            buttonText: function() {
+                return this.currentText || this.text
+            },
+            buttonDisabled: function() {
+                if (this.currentDisabled !== null) {
+                    return this.currentDisabled
+                }
+                return this.disabled
+            },
+        },
+        methods: {
+
+            clicked(event) {
+                this.currentDisabled = true
+                if (this.workingText) {
+                    this.currentText = this.workingText
+                } else if (this.working) {
+                    this.currentText = this.working + ", please wait..."
+                } else {
+                    this.currentText = "Working, please wait..."
+                }
+                // this.$nextTick(function() {
+                //     this.$emit('click', event)
+                // })
+            }
+        },
+    }
+  </script>
+  <% request.register_component('once-button', 'OnceButton') %>
+</%def>
diff --git a/tailbone/templates/themes/butterball/buefy-plugin.mako b/tailbone/templates/themes/butterball/buefy-plugin.mako
new file mode 100644
index 00000000..4cbedfea
--- /dev/null
+++ b/tailbone/templates/themes/butterball/buefy-plugin.mako
@@ -0,0 +1,32 @@
+
+<%def name="make_buefy_plugin()">
+  <script>
+
+    const BuefyPlugin = {
+        install(app, options) {
+            app.config.globalProperties.$buefy = {
+
+                toast: {
+                    open(options) {
+
+                        let variant = null
+                        if (options.type) {
+                            variant = options.type.replace(/^is-/, '')
+                        }
+
+                        const opts = {
+                            duration: options.duration,
+                            message: options.message,
+                            position: 'top',
+                            variant,
+                        }
+
+                        const oruga = app.config.globalProperties.$oruga
+                        oruga.notification.open(opts)
+                    },
+                },
+            }
+        },
+    }
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako
new file mode 100644
index 00000000..917083c4
--- /dev/null
+++ b/tailbone/templates/themes/butterball/field-components.mako
@@ -0,0 +1,542 @@
+## -*- coding: utf-8; -*-
+
+<%def name="make_field_components()">
+  ${self.make_numeric_input_component()}
+  ${self.make_tailbone_autocomplete_component()}
+  ${self.make_tailbone_datepicker_component()}
+  ${self.make_tailbone_timepicker_component()}
+</%def>
+
+<%def name="make_numeric_input_component()">
+  <% request.register_component('numeric-input', 'NumericInput') %>
+  ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + f'?ver={tailbone.__version__}')}
+  <script type="text/x-template" id="numeric-input-template">
+    <o-input v-model="orugaValue"
+             @update:model-value="orugaValueUpdated"
+             ref="input"
+             :disabled="disabled"
+             :icon="icon"
+             :name="name"
+             :placeholder="placeholder"
+             :size="size"
+             />
+  </script>
+  <script>
+
+    const NumericInput = {
+        template: '#numeric-input-template',
+
+        props: {
+            modelValue: [Number, String],
+            allowEnter: Boolean,
+            disabled: Boolean,
+            icon: String,
+            iconPack: String,   // ignored
+            name: String,
+            placeholder: String,
+            size: String,
+        },
+
+        data() {
+            return {
+                orugaValue: this.modelValue,
+                inputElement: null,
+            }
+        },
+
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = to
+            },
+        },
+
+        mounted() {
+            this.inputElement = this.$refs.input.$el.querySelector('input')
+            this.inputElement.addEventListener('keydown', this.keyDown)
+        },
+
+        beforeDestroy() {
+            this.inputElement.removeEventListener('keydown', this.keyDown)
+        },
+
+        methods: {
+
+            focus() {
+                this.$refs.input.focus()
+            },
+
+            keyDown(event) {
+                // by default we only allow numeric keys, and general navigation
+                // keys, but we might also allow Enter key
+                if (!key_modifies(event) && !key_allowed(event)) {
+                    if (!this.allowEnter || event.which != 13) {
+                        event.preventDefault()
+                    }
+                }
+            },
+
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+                this.$emit('input', value)
+            },
+
+            select() {
+                this.$el.children[0].select()
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_tailbone_autocomplete_component()">
+  <% request.register_component('tailbone-autocomplete', 'TailboneAutocomplete') %>
+  <script type="text/x-template" id="tailbone-autocomplete-template">
+    <div>
+
+      <o-button v-if="modelValue"
+                style="width: 100%; justify-content: left;"
+                @click="clearSelection(true)"
+                expanded>
+        {{ internalLabel }} (click to change)
+      </o-button>
+
+      <o-autocomplete ref="autocompletex"
+                      v-show="!modelValue"
+                      v-model="orugaValue"
+                      :placeholder="placeholder"
+                      :data="filteredData"
+                      :field="field"
+                      :formatter="customFormatter"
+                      @input="inputChanged"
+                      @select="optionSelected"
+                      keep-first
+                      open-on-focus
+                      :expanded="expanded"
+                      :clearable="clearable"
+                      :clear-on-select="clearOnSelect">
+        <template #default="{ option }">
+          {{ option.label }}
+        </template>
+      </o-autocomplete>
+
+      <input type="hidden" :name="name" :value="modelValue" />
+    </div>
+  </script>
+  <script>
+
+    const TailboneAutocomplete = {
+        template: '#tailbone-autocomplete-template',
+
+        props: {
+
+            // this is the "input" field name essentially.  primarily
+            // is useful for "traditional" tailbone forms; it normally
+            // is not used otherwise.  it is passed as-is to the oruga
+            // autocomplete component `name` prop
+            name: String,
+
+            // static data set; used when serviceUrl is not provided
+            data: Array,
+
+            // the url from which search results are to be obtained.  the
+            // url should expect a GET request with a query string with a
+            // single `term` parameter, and return results as a JSON array
+            // containing objects with `value` and `label` properties.
+            serviceUrl: String,
+
+            // callers do not specify this directly but rather by way of
+            // the `v-model` directive.  this component will emit `input`
+            // events when the value changes
+            modelValue: String,
+
+            // callers may set an initial label if needed.  this is useful
+            // in cases where the autocomplete needs to "already have a
+            // value" on page load.  for instance when a user fills out
+            // the autocomplete field, but leaves other required fields
+            // blank and submits the form; page will re-load showing
+            // errors but the autocomplete field should remain "set" -
+            // normally it is only given a "value" (e.g. uuid) but this
+            // allows for the "label" to display correctly as well
+            initialLabel: String,
+
+            // while the `initialLabel` above is useful for setting the
+            // *initial* label (of course), it cannot be used to
+            // arbitrarily update the label during the component's life.
+            // if you do need to *update* the label after initial page
+            // load, then you should set `assignedLabel` instead.  one
+            // place this happens is in /custorders/create page, where
+            // product autocomplete shows some results, and user clicks
+            // one, but then handler logic can forcibly "swap" the
+            // selection, causing *different* product data to come back
+            // from the server, and autocomplete label should be updated
+            // to match.  this feels a bit awkward still but does work..
+            assignedLabel: String,
+
+            // simple placeholder text for the input box
+            placeholder: String,
+
+            // these are passed as-is to <o-autocomplete>
+            clearable: Boolean,
+            clearOnSelect: Boolean,
+            customFormatter: null,
+            expanded: Boolean,
+            field: String,
+        },
+
+        data() {
+
+            const internalLabel = this.assignedLabel || this.initialLabel
+
+            // we want to track the "currently selected option" - which
+            // should normally be `null` to begin with, unless we were
+            // given a value, in which case we use `initialLabel` to
+            // complete the option
+            let selected = null
+            if (this.modelValue) {
+                selected = {
+                    value: this.modelValue,
+                    label: internalLabel,
+                }
+            }
+
+            return {
+
+                // this contains the search results; its contents may
+                // change over time as new searches happen.  the
+                // "currently selected option" should be one of these,
+                // unless it is null
+                fetchedData: [],
+
+                // this tracks our "currently selected option" - per above
+                selected,
+
+                // since we are wrapping a component which also makes
+                // use of the "value" paradigm, we must separate the
+                // concerns.  so we use our own `modelValue` prop to
+                // interact with the caller, but then we use this
+                // `orugaValue` data point to communicate with the
+                // oruga autocomplete component.  note that
+                // `this.modelValue` will always be either a uuid or
+                // null, whereas `this.orugaValue` may be raw text as
+                // entered by the user.
+                // orugaValue: this.modelValue,
+                orugaValue: null,
+
+                // this stores the "internal" label for the button
+                internalLabel,
+            }
+        },
+
+        computed: {
+
+            filteredData() {
+
+                // do not filter if data comes from backend
+                if (this.serviceUrl) {
+                    return this.fetchedData
+                }
+
+                if (!this.orugaValue || !this.orugaValue.length) {
+                    return this.data
+                }
+
+                const terms = []
+                for (let term of this.orugaValue.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+                if (!terms.length) {
+                    return this.data
+                }
+
+                // all terms must match
+                return this.data.filter((option) => {
+                    const label = option.label.toLowerCase()
+                    for (const term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+            },
+        },
+
+        watch: {
+
+            assignedLabel(to, from) {
+                // update button label when caller changes it
+                this.internalLabel = to
+            },
+        },
+
+        methods: {
+
+            inputChanged(entry) {
+                if (this.serviceUrl) {
+                    this.getAsyncData(entry)
+                }
+            },
+
+            // fetch new search results from the server.  this is
+            // invoked via the `@input` event from oruga autocomplete
+            // component.
+            getAsyncData(entry) {
+
+                // since the `@input` event from oruga component does
+                // not "self-regulate" in any way (?), we skip the
+                // search unless we have at least 3 characters of
+                // input from user
+                if (entry.length < 3) {
+                    this.fetchedData = []
+                    return
+                }
+
+                // and perform the search
+                this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry))
+                    .then(({ data }) => {
+                        this.fetchedData = data
+                    })
+                    .catch((error) => {
+                        this.fetchedData = []
+                        throw error
+                    })
+            },
+
+            // this method is invoked via the `@select` event of the
+            // oruga autocomplete component.  the `option` received
+            // will be one of:
+            // - object with (at least) `value` and `label` keys
+            // - simple string (e.g. when data set is static)
+            // - null
+            optionSelected(option) {
+
+                this.selected = option
+                this.internalLabel = option?.label || option
+
+                // reset the internal value for oruga autocomplete
+                // component.  note that this value will normally hold
+                // either the raw text entered by the user, or a uuid.
+                // we will not be needing either of those b/c they are
+                // not visible to user once selection is made, and if
+                // the selection is cleared we want user to start over
+                // anyway
+                this.orugaValue = null
+                this.fetchedData = []
+
+                // here is where we alert callers to the new value
+                if (option) {
+                    this.$emit('newLabel', option.label)
+                }
+                const value = option?.[this.field || 'value'] || option
+                this.$emit('update:modelValue', value)
+                // this.$emit('select', option)
+                // this.$emit('input', value)
+            },
+
+##             // set selection to the given option, which should a simple
+##             // object with (at least) `value` and `label` properties
+##             setSelection(option) {
+##                 this.$refs.autocomplete.setSelected(option)
+##             },
+
+            // clear the field of any value, i.e. set the "currently
+            // selected option" to null.  this is invoked when you click
+            // the button, which is visible while the field has a value.
+            // but callers can invoke it directly as well.
+            clearSelection(focus) {
+
+                this.$emit('update:modelValue', null)
+                this.$emit('input', null)
+                this.$emit('newLabel', null)
+                this.internalLabel = null
+                this.selected = null
+                this.orugaValue = null
+
+##                 // clear selection for the oruga autocomplete component
+##                 this.$refs.autocomplete.setSelected(null)
+
+                // maybe set focus to our (autocomplete) component
+                if (focus) {
+                    this.$nextTick(function() {
+                        this.focus()
+                    })
+                }
+            },
+
+            // nb. this used to be relevant but now is here only for sake
+            // of backward-compatibility (for callers)
+            getDisplayText() {
+                return this.internalLabel
+            },
+
+            // set focus to this component, which will just set focus
+            // to the oruga autocomplete component
+            focus() {
+                // TODO: why is this ref null?!
+                if (this.$refs.autocompletex) {
+                    this.$refs.autocompletex.focus()
+                }
+            },
+
+            // returns the "raw" user input from the underlying oruga
+            // autocomplete component
+            getUserInput() {
+                return this.orugaValue
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_tailbone_datepicker_component()">
+  <% request.register_component('tailbone-datepicker', 'TailboneDatepicker') %>
+  <script type="text/x-template" id="tailbone-datepicker-template">
+    <o-datepicker placeholder="Click to select ..."
+                  icon="calendar-alt"
+                  :date-formatter="formatDate"
+                  :date-parser="parseDate"
+                  v-model="orugaValue"
+                  @update:model-value="orugaValueUpdated"
+                  :disabled="disabled"
+                  ref="trueDatePicker">
+    </o-datepicker>
+  </script>
+  <script>
+
+    const TailboneDatepicker = {
+        template: '#tailbone-datepicker-template',
+
+        props: {
+            modelValue: [Date, String],
+            disabled: Boolean,
+        },
+
+        data() {
+            return {
+                orugaValue: this.parseDate(this.modelValue),
+            }
+        },
+
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = this.parseDate(to)
+            },
+        },
+
+        methods: {
+
+            formatDate(date) {
+                if (date === null) {
+                    return null
+                }
+                if (typeof(date) == 'string') {
+                    return date
+                }
+                // just need to convert to simple ISO date format here, seems
+                // like there should be a more obvious way to do that?
+                var year = date.getFullYear()
+                var month = date.getMonth() + 1
+                var day = date.getDate()
+                month = month < 10 ? '0' + month : month
+                day = day < 10 ? '0' + day : day
+                return year + '-' + month + '-' + day
+            },
+
+            parseDate(value) {
+                if (typeof(value) == 'object') {
+                    // nb. we are assuming it is a Date here
+                    return value
+                }
+                if (value) {
+                    // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
+                    const parts = value.split('-')
+                    return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
+                }
+                return null
+            },
+
+            orugaValueUpdated(date) {
+                this.$emit('update:modelValue', date)
+            },
+
+            focus() {
+                this.$refs.trueDatePicker.focus()
+            },
+        },
+    }
+
+  </script>
+</%def>
+
+<%def name="make_tailbone_timepicker_component()">
+  <% request.register_component('tailbone-timepicker', 'TailboneTimepicker') %>
+  <script type="text/x-template" id="tailbone-timepicker-template">
+    <o-timepicker :name="name"
+                  v-model="orugaValue"
+                  @update:model-value="orugaValueUpdated"
+                  placeholder="Click to select ..."
+                  icon="clock"
+                  hour-format="12"
+                  :time-formatter="formatTime" />
+  </script>
+  <script>
+
+    const TailboneTimepicker = {
+        template: '#tailbone-timepicker-template',
+
+        props: {
+            modelValue: [Date, String],
+            name: String,
+        },
+
+        data() {
+            return {
+                orugaValue: this.parseTime(this.modelValue),
+            }
+        },
+
+        watch: {
+            modelValue(to, from) {
+                this.orugaValue = this.parseTime(to)
+            },
+        },
+
+        methods: {
+
+            formatTime(value) {
+                if (!value) {
+                    return null
+                }
+
+                return value.toLocaleTimeString('en-US')
+            },
+
+            parseTime(value) {
+                if (!value) {
+                    return value
+                }
+
+                if (value.getHours) {
+                    return value
+                }
+
+                let found = value.match(/^(\d\d):(\d\d):\d\d$/)
+                if (found) {
+                    return new Date(null, null, null,
+                                    parseInt(found[1]), parseInt(found[2]))
+                }
+            },
+
+            orugaValueUpdated(value) {
+                this.$emit('update:modelValue', value)
+            },
+        },
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/butterball/http-plugin.mako b/tailbone/templates/themes/butterball/http-plugin.mako
new file mode 100644
index 00000000..06afc2bb
--- /dev/null
+++ b/tailbone/templates/themes/butterball/http-plugin.mako
@@ -0,0 +1,100 @@
+
+<%def name="make_http_plugin()">
+  <script>
+
+    const HttpPlugin = {
+
+        install(app, options) {
+            app.config.globalProperties.$http = {
+
+                get(url, options) {
+                    if (options === undefined) {
+                        options = {}
+                    }
+
+                    if (options.params) {
+                        // convert params to query string
+                        const data = new URLSearchParams()
+                        for (let [key, value] of Object.entries(options.params)) {
+                            // nb. all values get converted to string here, so
+                            // fallback to empty string to avoid null value
+                            // from being interpreted as "null" string
+                            if (value === null) {
+                                value = ''
+                            }
+                            data.append(key, value)
+                        }
+                        // TODO: this should be smarter in case query string already exists
+                        url += '?' + data.toString()
+                        // params is not a valid arg for options to fetch()
+                        delete options.params
+                    }
+
+                    return new Promise((resolve, reject) => {
+                        fetch(url, options).then(response => {
+                            // original response does not contain 'data'
+                            // attribute, so must use a "mock" response
+                            // which does contain everything
+                            response.json().then(json => {
+                                resolve({
+                                    data: json,
+                                    headers: response.headers,
+                                    ok: response.ok,
+                                    redirected: response.redirected,
+                                    status: response.status,
+                                    statusText: response.statusText,
+                                    type: response.type,
+                                    url: response.url,
+                                })
+                            }, json => {
+                                reject(response)
+                            })
+                        }, response => {
+                            reject(response)
+                        })
+                    })
+                },
+
+                post(url, params, options) {
+
+                    if (params) {
+
+                        // attach params as json
+                        options.body = JSON.stringify(params)
+
+                        // and declare content-type
+                        options.headers = new Headers(options.headers)
+                        options.headers.append('Content-Type', 'application/json')
+                    }
+
+                    options.method = 'POST'
+
+                    return new Promise((resolve, reject) => {
+                        fetch(url, options).then(response => {
+                            // original response does not contain 'data'
+                            // attribute, so must use a "mock" response
+                            // which does contain everything
+                            response.json().then(json => {
+                                resolve({
+                                    data: json,
+                                    headers: response.headers,
+                                    ok: response.ok,
+                                    redirected: response.redirected,
+                                    status: response.status,
+                                    statusText: response.statusText,
+                                    type: response.type,
+                                    url: response.url,
+                                })
+                            }, json => {
+                                reject(response)
+                            })
+                        }, response => {
+                            reject(response)
+                        })
+                    })
+                },
+            }
+        },
+    }
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/butterball/progress.mako b/tailbone/templates/themes/butterball/progress.mako
new file mode 100644
index 00000000..1c389fb8
--- /dev/null
+++ b/tailbone/templates/themes/butterball/progress.mako
@@ -0,0 +1,244 @@
+## -*- coding: utf-8; -*-
+<%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace file="/base.mako" import="core_javascript" />
+<%namespace file="/base.mako" import="core_styles" />
+<%namespace file="/http-plugin.mako" import="make_http_plugin" />
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+    ${base_meta.favicon()}
+    <title>${initial_msg or "Working"}...</title>
+    ${core_javascript()}
+    ${core_styles()}
+    ${self.extra_styles()}
+  </head>
+
+  <body>
+    <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
+      <whole-page></whole-page>
+    </div>
+
+    ${make_http_plugin()}
+    ${self.make_whole_page_component()}
+    ${self.modify_whole_page_vars()}
+    ${self.make_whole_page_app()}
+  </body>
+</html>
+
+<%def name="extra_styles()"></%def>
+
+<%def name="make_whole_page_component()">
+  <script type="text/x-template" id="whole-page-template">
+    <section class="hero is-fullheight">
+      <div class="hero-body">
+        <div class="container">
+
+          <div style="display: flex; flex-direction: column; justify-content: center;">
+            <div style="margin: auto; display: flex; gap: 1rem; align-items: end;">
+
+              <div style="display: flex; flex-direction: column; gap: 1rem;">
+
+                <div style="display: flex; gap: 3rem;">
+                  <span>{{ progressMessage }} ... {{ totalDisplay }}</span>
+                  <span>{{ percentageDisplay }}</span>
+                </div>
+
+                <div style="display: flex; gap: 1rem; align-items: center;">
+
+                  <div>
+                    <progress class="progress is-large"
+                              style="width: 400px;"
+                              :max="progressMax"
+                              :value="progressValue" />
+                  </div>
+
+                  % if can_cancel:
+                      <o-button v-show="canCancel"
+                                @click="cancelProgress()"
+                                :disabled="cancelingProgress"
+                                icon-left="ban">
+                        {{ cancelingProgress ? "Canceling, please wait..." : "Cancel" }}
+                      </o-button>
+                  % endif
+
+                </div>
+              </div>
+
+            </div>
+          </div>
+
+          ${self.after_progress()}
+
+        </div>
+      </div>
+    </section>
+  </script>
+  <script>
+
+    const WholePage = {
+        template: '#whole-page-template',
+
+        computed: {
+
+            percentageDisplay() {
+                if (!this.progressMax) {
+                    return
+                }
+
+                const percent = this.progressValue / this.progressMax
+                return percent.toLocaleString(undefined, {
+                    style: 'percent',
+                    minimumFractionDigits: 0})
+            },
+
+            totalDisplay() {
+
+                % if can_cancel:
+                    if (!this.stillInProgress && !this.cancelingProgress) {
+                        return "done!"
+                    }
+                % else:
+                    if (!this.stillInProgress) {
+                        return "done!"
+                    }
+                % endif
+
+                if (this.progressMaxDisplay) {
+                    return `(${'$'}{this.progressMaxDisplay} total)`
+                }
+            },
+        },
+
+        mounted() {
+
+            // fetch first progress data, one second from now
+            setTimeout(() => {
+                this.updateProgress()
+            }, 1000)
+
+            // custom logic if applicable
+            this.mountedCustom()
+        },
+
+        methods: {
+
+            mountedCustom() {},
+
+            updateProgress() {
+
+                this.$http.get(this.progressURL).then(response => {
+
+                    if (response.data.error) {
+                        // errors stop the show, we redirect to "cancel" page
+                        location.href = '${cancel_url}'
+
+                    } else {
+
+                        if (response.data.complete || response.data.maximum) {
+                            this.progressMessage = response.data.message
+                            this.progressMaxDisplay = response.data.maximum_display
+
+                            if (response.data.complete) {
+                                this.progressValue = this.progressMax
+                                this.stillInProgress = false
+                                % if can_cancel:
+                                this.canCancel = false
+                                % endif
+
+                                location.href = response.data.success_url
+
+                            } else {
+                                this.progressValue = response.data.value
+                                this.progressMax = response.data.maximum
+                            }
+                        }
+
+                        // custom logic if applicable
+                        this.updateProgressCustom(response)
+
+                        if (this.stillInProgress) {
+
+                            // fetch progress data again, in one second from now
+                            setTimeout(() => {
+                                this.updateProgress()
+                            }, 1000)
+                        }
+                    }
+                })
+            },
+
+            updateProgressCustom(response) {},
+
+            % if can_cancel:
+
+                cancelProgress() {
+
+                    if (confirm("Do you really wish to cancel this operation?")) {
+
+                        this.cancelingProgress = true
+                        this.stillInProgress = false
+
+                        let params = {cancel_msg: ${json.dumps(cancel_msg)|n}}
+                        this.$http.get(this.cancelURL, {params: params}).then(response => {
+                            location.href = ${json.dumps(cancel_url)|n}
+                        })
+                    }
+
+                },
+
+            % endif
+        }
+    }
+
+    const WholePageData = {
+
+        progressURL: '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}',
+        progressMessage: "${(initial_msg or "Working").replace('"', '\\"')} (please wait)",
+        progressMax: null,
+        progressMaxDisplay: null,
+        progressValue: null,
+        stillInProgress: true,
+
+        % if can_cancel:
+        canCancel: true,
+        cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}',
+        cancelingProgress: false,
+        % endif
+    }
+
+  </script>
+</%def>
+
+<%def name="after_progress()"></%def>
+
+<%def name="modify_whole_page_vars()"></%def>
+
+<%def name="make_whole_page_app()">
+  <script type="module">
+    import {createApp} from 'vue'
+    import {Oruga} from '@oruga-ui/oruga-next'
+    import {bulmaConfig} from '@oruga-ui/theme-bulma'
+    import { library } from "@fortawesome/fontawesome-svg-core"
+    import { fas } from "@fortawesome/free-solid-svg-icons"
+    import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
+    library.add(fas)
+
+    const app = createApp()
+
+    app.component('vue-fontawesome', FontAwesomeIcon)
+
+    WholePage.data = () => { return WholePageData }
+    app.component('whole-page', WholePage)
+
+    app.use(Oruga, {
+        ...bulmaConfig,
+        iconComponent: 'vue-fontawesome',
+        iconPack: 'fas',
+    })
+
+    app.use(HttpPlugin)
+
+    app.mount('#app')
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/dodo/base.mako b/tailbone/templates/themes/dodo/base.mako
deleted file mode 100644
index a5cfa3ec..00000000
--- a/tailbone/templates/themes/dodo/base.mako
+++ /dev/null
@@ -1,231 +0,0 @@
-## -*- coding: utf-8; -*-
-## largely copied from https://github.com/dansup/bulma-templates/blob/master/templates/admin.html
-## <%namespace file="/feedback_dialog.mako" import="feedback_dialog" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <meta charset="utf-8">
-  ## <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
-
-  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
-  <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,700" rel="stylesheet">
-  <!-- Bulma Version 0.7.4-->
-  <link rel="stylesheet" href="https://unpkg.com/bulma@0.7.4/css/bulma.min.css" />
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/dodo/css/admin.css') + '?ver={}'.format(tailbone.__version__))}
-
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/dodo/css/base.css') + '?ver={}'.format(tailbone.__version__))}
-
-  % if background_color:
-      <style type="text/css">
-        html, body {
-            background-color: ${background_color};
-        }
-      </style>
-  % endif
-
-  % if not request.rattail_config.production():
-      <style type="text/css">
-        html, body, body > .navbar {
-          background-image: url(${request.static_url('tailbone:static/img/testing.png')});
-        }
-      </style>
-  % endif
-
-  ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')}
-  <script type="text/javascript">
-    var session_timeout = ${request.get_session_timeout() or 'null'};
-    var logout_url = '${request.route_url('logout')}';
-    var noop_url = '${request.route_url('noop')}';
-    % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-        $(function() {
-            $('#theme-picker').change(function() {
-                $(this).parents('form:first').submit();
-            });
-        });
-    % endif
-  </script>
-
-
-</head>
-
-<body>
-
-    <!-- START NAV -->
-    <nav class="navbar is-white">
-        <div class="container">
-            <div class="navbar-brand">
-                <a class="navbar-item brand-text" href="${url('home')}">
-                  ${base_meta.header_logo()}
-                  ${base_meta.global_title()}
-                </a>
-
-                <div class="navbar-burger burger" data-target="navMenu">
-                    <span></span>
-                    <span></span>
-                    <span></span>
-                </div>
-            </div>
-            <div id="navMenu" class="navbar-menu">
-                <div class="navbar-start">
-
-                  ## User Menu
-                  % if request.user:
-                      <div class="navbar-item has-dropdown is-hoverable">
-                        % if messaging_enabled:
-                            <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a>
-                        % else:
-                            <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a>
-                        % endif
-                        <div class="navbar-dropdown">
-                          % if request.is_root:
-                              ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
-                          % elif request.is_admin:
-                              ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
-                          % endif
-                          % if messaging_enabled:
-                              ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
-                          % endif
-                          ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
-                          ${h.link_to("Logout", url('logout'), class_='navbar-item')}
-                        </div>
-                      </div>
-                  % else:
-                      ${h.link_to("Login", url('login'), class_='navbar-item')}
-                  % endif
-
-                </div><!-- navbar-start -->
-
-                <div class="navbar-end">
-
-                  ## Theme Picker
-                  % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-                      <div class="level-item">
-                        ${h.form(url('change_theme'), method="post")}
-                        ${h.csrf_token(request)}
-                        <div class="columns is-vcentered">
-                          <div class="column">
-                            Theme:
-                          </div>
-                          <div class="column theme-picker">
-                            <div class="select">
-                              ${h.select('theme', theme, options=theme_picker_options, id='theme-picker')}
-                            </div>
-                          </div>
-                        </div>
-                        ${h.end_form()}
-                      </div>
-                  % endif
-                  
-                </div><!-- navbar-end -->
-
-            </div><!-- navbar-menu -->
-        </div>
-    </nav>
-    <!-- END NAV -->
-    <div class="container">
-        <div class="columns">
-            <div class="column is-3 ">
-                <aside class="menu is-hidden-mobile">
-
-                  % for topitem in menus:
-                      % if topitem.is_link:
-                          ${h.link_to(topitem.title, topitem.url, target=topitem.target, class_='navbar-item')}
-                      % else:
-                          <p class="menu-label">${topitem.title}</p>
-                          <ul class="menu-list">
-                            % for subitem in topitem.items:
-                                % if not subitem.is_sep:
-                                    <li>${h.link_to(subitem.title, subitem.url, target=subitem.target)}</li>
-                                % endif
-                            % endfor
-                          </ul>
-                      % endif
-                  % endfor
-
-                </aside>
-            </div>
-            <div class="column is-9">
-                <nav class="breadcrumb" aria-label="breadcrumbs">
-                    <ul>
-
-                      ## Current Context
-                      % if master:
-                          % if master.listing:
-                              <li>${index_title}</li>
-                          % else:
-                              <li>${h.link_to(index_title, index_url)}</li>
-                              % if parent_url is not Undefined:
-                                  <li>${h.link_to(parent_title, parent_url)}</li>
-                              % elif instance_url is not Undefined:
-                                  <li>${h.link_to(instance_title, instance_url)}</li>
-                              % endif
-##                                 % if master.viewing and grid_index:
-##                                     ${grid_index_nav()}
-##                                 % endif
-                          % endif
-                      % elif index_title:
-                          <li>${index_title}</li>
-                      % endif
-
-                      % if capture(self.content_title):
-
-##                           % if show_prev_next is not Undefined and show_prev_next:
-##                               <div style="float: right;">
-##                                 % if prev_url:
-##                                     ${h.link_to("« Older", prev_url, class_='button autodisable')}
-##                                 % else:
-##                                     ${h.link_to("« Older", '#', class_='button', disabled='disabled')}
-##                                 % endif
-##                                 % if next_url:
-##                                     ${h.link_to("Newer »", next_url, class_='button autodisable')}
-##                                 % else:
-##                                     ${h.link_to("Newer »", '#', class_='button', disabled='disabled')}
-##                                 % endif
-##                               </div>
-##                           % endif
-
-                          <li class="is-active"><a href="#" aria-current="page">${self.content_title()}</a></li>
-                      % endif
-
-                    </ul>
-                </nav>
-              <section id="page-body">
-
-                % if request.session.peek_flash('error'):
-                    % for error in request.session.pop_flash('error'):
-                        <div class="notification is-warning">
-                          <!-- <button class="delete"></button> -->
-                          ${error}
-                        </div>
-                    % endfor
-                % endif
-
-                % if request.session.peek_flash():
-                    % for msg in request.session.pop_flash():
-                        <div class="notification is-info">
-                          <!-- <button class="delete"></button> -->
-                          ${msg}
-                        </div>
-                    % endfor
-                % endif
-                
-                ${self.body()}
-
-              </section>
-
-            </div>
-        </div>
-    </div>
-    ${h.javascript_link(request.static_url('tailbone:static/themes/dodo/js/bulma.js'), async='true')}
-</body>
-
-</html>
-
-<%def name="title()"></%def>
-
-<%def name="content_title()">
-  ${self.title()}
-</%def>
diff --git a/tailbone/templates/themes/excite-bike/base.mako b/tailbone/templates/themes/excite-bike/base.mako
deleted file mode 100644
index d4328621..00000000
--- a/tailbone/templates/themes/excite-bike/base.mako
+++ /dev/null
@@ -1,8 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="tailbone:templates/base.mako" />
-
-<%def name="jquery_theme()">
-  ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/excite-bike/jquery-ui.css')}
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/themes/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako
index 713d9547..1869043b 100644
--- a/tailbone/templates/themes/falafel/base.mako
+++ b/tailbone/templates/themes/falafel/base.mako
@@ -1,587 +1,4 @@
 ## -*- coding: utf-8; -*-
-<%namespace file="/grids/nav.mako" import="grid_index_nav" />
-<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
-    <title>${base_meta.global_title()} &raquo; ${capture(self.title)|n}</title>
-    ${base_meta.favicon()}
-    ${self.header_core()}
+<%inherit file="tailbone:templates/base.mako" />
 
-    % if background_color:
-        <style type="text/css">
-          body, .navbar, .footer {
-              background-color: ${background_color};
-          }
-        </style>
-    % endif
-
-    % if not request.rattail_config.production():
-        <style type="text/css">
-          body, .navbar, .footer {
-            background-image: url(${request.static_url('tailbone:static/img/testing.png')});
-          }
-        </style>
-    % endif
-
-    ${self.head_tags()}
-  </head>
-
-  <body>
-    ${self.body()}
-
-    <div id="whole-page-app">
-      <whole-page></whole-page>
-    </div>
-
-    ${self.render_whole_page_template()}
-    ${self.make_whole_page_component()}
-    ${self.make_whole_page_app()}
-  </body>
-</html>
-
-<%def name="title()"></%def>
-
-<%def name="content_title()">
-  ${self.title()}
-</%def>
-
-<%def name="header_core()">
-
-  ${self.core_javascript()}
-  ${self.extra_javascript()}
-  ${self.core_styles()}
-  ${self.extra_styles()}
-
-  ## TODO: should this be elsewhere / more customizable?
-  % if dform is not Undefined:
-      <% resources = dform.get_widget_resources() %>
-      % for path in resources['js']:
-          ${h.javascript_link(request.static_url(path))}
-      % endfor
-      % for path in resources['css']:
-          ${h.stylesheet_link(request.static_url(path))}
-      % endfor
-  % endif
-</%def>
-
-<%def name="core_javascript()">
-  ${self.jquery()}
-  ${self.vuejs()}
-  ${self.buefy()}
-  ${self.fontawesome()}
-
-  ## some commonly-useful logic for detecting (non-)numeric input
-  ${h.javascript_link(request.static_url('tailbone:static/js/numeric.js') + '?ver={}'.format(tailbone.__version__))}
-
-  ## Tailbone / Buefy stuff
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))}
-
-  <script type="text/javascript">
-    var session_timeout = ${request.get_session_timeout() or 'null'};
-    var logout_url = '${request.route_url('logout')}';
-    var noop_url = '${request.route_url('noop')}';
-    $(function() {
-        ## NOTE: this code was copied from
-        ## https://bulma.io/documentation/components/navbar/#navbar-menu
-        $('.navbar-burger').click(function() {
-            $('.navbar-burger').toggleClass('is-active');
-            $('.navbar-menu').toggleClass('is-active');
-        });
-    });
-  </script>
-</%def>
-
-<%def name="jquery()">
-  ## jQuery 1.12.4
-  ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')}
-</%def>
-
-<%def name="vuejs()">
-  ## Vue.js (last known good @ 2.6.10)
-  ${h.javascript_link('https://unpkg.com/vue/dist/vue.min.js')}
-
-  ## vue-resource
-  ## (needed for e.g. this.$http.get() calls, used by grid at least)
-  ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-resource@1.5.1')}
-</%def>
-
-<%def name="buefy()">
-  ## Buefy (last known good @ 0.8.2)
-  ## ${h.javascript_link('https://unpkg.com/buefy/dist/buefy.min.js')}
-  ${h.javascript_link('https://unpkg.com/buefy@0.8.2/dist/buefy.min.js')}
-</%def>
-
-<%def name="fontawesome()">
-  <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
-</%def>
-
-<%def name="extra_javascript()"></%def>
-
-<%def name="core_styles()">
-
-  ${self.buefy_styles()}
-
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/base.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/layout.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/grids.rowstatus.css') + '?ver={}'.format(tailbone.__version__))}
-##   ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/filters.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/bobcat/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/themes/falafel/css/forms.css') + '?ver={}'.format(tailbone.__version__))}
-  ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
-
-  <style type="text/css">
-    .filters .filter-fieldname {
-        min-width: ${filter_fieldname_width};
-        justify-content: left;
-    }
-    .filters .filter-verb {
-        min-width: ${filter_verb_width};
-    }
-  </style>
-</%def>
-
-<%def name="buefy_styles()">
-  ## Buefy 0.7.4
-  ${h.stylesheet_link('https://unpkg.com/buefy@0.7.4/dist/buefy.min.css')}
-</%def>
-
-## TODO: this is only being referenced by the progress template i think?
-## (so, should make a Buefy progress page at least)
-<%def name="jquery_theme()">
-  ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')}
-</%def>
-
-<%def name="extra_styles()"></%def>
-
-<%def name="head_tags()"></%def>
-
-<%def name="render_whole_page_template()">
-  <script type="text/x-template" id="whole-page-template">
-    <div>
-      <header>
-
-        <nav class="navbar" role="navigation" aria-label="main navigation">
-
-          <div class="navbar-brand">
-            <a class="navbar-item" href="${url('home')}">
-              ${base_meta.header_logo()}
-              ${base_meta.global_title()}
-            </a>
-            <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
-              <span aria-hidden="true"></span>
-              <span aria-hidden="true"></span>
-              <span aria-hidden="true"></span>
-            </a>
-          </div>
-
-          <div class="navbar-menu">
-            <div class="navbar-start">
-
-              % for topitem in menus:
-                  % if topitem.is_link:
-                      ${h.link_to(topitem.title, topitem.url, target=topitem.target, class_='navbar-item')}
-                  % else:
-                      <div class="navbar-item has-dropdown is-hoverable">
-                        <a class="navbar-link">${topitem.title}</a>
-                        <div class="navbar-dropdown">
-                          % for subitem in topitem.items:
-                              % if subitem.is_sep:
-                                  <hr class="navbar-divider">
-                              % else:
-                                  ${h.link_to(subitem.title, subitem.url, class_='navbar-item', target=subitem.target)}
-                              % endif
-                          % endfor
-                        </div>
-                      </div>
-                  % endif
-              % endfor
-
-            </div><!-- navbar-start -->
-            <div class="navbar-end">
-
-              ## User Menu
-              % if request.user:
-                  <div class="navbar-item has-dropdown is-hoverable">
-                    % if messaging_enabled:
-                        <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a>
-                    % else:
-                        <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a>
-                    % endif
-                    <div class="navbar-dropdown">
-                      % if request.is_root:
-                          ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
-                      % elif request.is_admin:
-                          ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
-                      % endif
-                      % if messaging_enabled:
-                          ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
-                      % endif
-                      ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
-                      ${h.link_to("Logout", url('logout'), class_='navbar-item')}
-                    </div>
-                  </div>
-              % else:
-                  ${h.link_to("Login", url('login'), class_='navbar-item')}
-              % endif
-
-            </div><!-- navbar-end -->
-          </div>
-        </nav>
-
-        <nav class="level" style="margin: 0.5rem auto;">
-          <div class="level-left">
-
-            ## Current Context
-            <div id="current-context" class="level-item">
-              % if master:
-                  % if master.listing:
-                      <span>${index_title}</span>
-                  % elif index_url:
-                      ${h.link_to(index_title, index_url)}
-                      % if parent_url is not Undefined:
-                          <span>&nbsp;&raquo;</span>
-                          ${h.link_to(parent_title, parent_url)}
-                      % elif instance_url is not Undefined:
-                          <span>&nbsp;&raquo;</span>
-                          ${h.link_to(instance_title, instance_url)}
-                      % endif
-                      % if master.viewing and grid_index:
-                          ${grid_index_nav()}
-                      % endif
-                  % else:
-                      <span>${index_title}</span>
-                  % endif
-              % elif index_title:
-                  <span>${index_title}</span>
-              % endif
-            </div>
-
-            % if expose_db_picker is not Undefined and expose_db_picker:
-                <div class="level-item">
-                  <p>DB:</p>
-                </div>
-                <div class="level-item">
-                  ${h.form(url('change_db_engine'), ref='dbPickerForm')}
-                  ${h.csrf_token(request)}
-                  ${h.hidden('engine_type', value=master.engine_type_key)}
-                  <div class="select">
-                    ${h.select('dbkey', db_picker_selected, db_picker_options, **{'@change': 'changeDB()'})}
-                  </div>
-                  ${h.end_form()}
-                </div>
-            % endif
-
-          </div><!-- level-left -->
-          <div class="level-right">
-
-            ## Quickie Lookup
-            % if quickie is not Undefined and quickie and request.has_perm(quickie.perm):
-                <div class="level-item">
-                  ${h.form(quickie.url, method="get")}
-                  <div class="level">
-                    <div class="level-right">
-                      <div class="level-item">
-                        ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')}
-                      </div>
-                      <div class="level-item">
-                        <button type="submit" class="button is-primary">
-                          <span class="icon is-small">
-                            <i class="fas fa-search"></i>
-                          </span>
-                          <span>Lookup</span>
-                        </button>
-                      </div>
-                    </div>
-                  </div>
-                  ${h.end_form()}
-                </div>
-            % endif
-
-            ## Theme Picker
-            % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-                <div class="level-item">
-                  ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
-                  ${h.csrf_token(request)}
-                  Theme:
-                  <div class="theme-picker">
-                    <div class="select">
-                      ${h.select('theme', theme, theme_picker_options, **{'@change': 'changeTheme()'})}
-                    </div>
-                  </div>
-                  ${h.end_form()}
-                </div>
-            % endif
-
-            ## Help Button
-            % if help_url is not Undefined and help_url:
-                <div class="level-item">
-                  ${h.link_to("Help", help_url, target='_blank', class_='button')}
-                </div>
-            % endif
-
-            ## Feedback Button / Dialog
-            % if request.has_perm('common.feedback'):
-                <feedback-form
-                   action="${url('feedback')}">
-                </feedback-form>
-            % endif
-
-          </div><!-- level-right -->
-        </nav><!-- level -->
-      </header>
-
-      ## Page Title
-      % if capture(self.content_title):
-          <section id="content-title" class="hero is-primary">
-            <div class="level">
-              <div class="level-left">
-                <div class="level-item">
-                  <h1 class="title" v-html="contentTitleHTML"></h1>
-                </div>
-              </div>
-              % if show_prev_next is not Undefined and show_prev_next:
-                  <div class="level-right">
-                    % if prev_url:
-                        <div class="level-item">
-                          ${h.link_to(u"« Older", prev_url, class_='button autodisable')}
-                        </div>
-                    % else:
-                        <div class="level-item">
-                          ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')}
-                        </div>
-                    % endif
-                    % if next_url:
-                        <div class="level-item">
-                          ${h.link_to(u"Newer »", next_url, class_='button autodisable')}
-                        </div>
-                    % else:
-                        <div class="level-item">
-                          ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')}
-                        </div>
-                    % endif
-                  </div>
-              % endif
-            </div>
-          </section>
-      % endif
-
-      <div class="content-wrapper">
-
-      ## Page Body
-      <section id="page-body">
-
-        % if request.session.peek_flash('error'):
-            % for error in request.session.pop_flash('error'):
-                <b-notification type="is-warning">
-                  ${error}
-                </b-notification>
-            % endfor
-        % endif
-
-        % if request.session.peek_flash():
-            % for msg in request.session.pop_flash():
-                <b-notification type="is-info">
-                  ${msg}
-                </b-notification>
-            % endfor
-        % endif
-
-        <this-page
-           v-on:change-content-title="changeContentTitle">
-        </this-page>
-      </section>
-
-      ## Footer
-      <footer class="footer">
-        <div class="content">
-          ${base_meta.footer()}
-        </div>
-      </footer>
-
-      </div><!-- content-wrapper -->
-    </div>
-  </script>
-
-  <script type="text/x-template" id="feedback-template">
-    <div>
-
-      <div class="level-item">
-        <b-button type="is-primary"
-                  @click="showFeedback()"
-                  icon-pack="fas"
-                  icon-left="fas fa-comment">
-          Feedback
-        </b-button>
-      </div>
-
-      <b-modal has-modal-card
-               :active.sync="showDialog">
-        <div class="modal-card">
-
-          <header class="modal-card-head">
-            <p class="modal-card-title">User Feedback</p>
-          </header>
-
-          <section class="modal-card-body">
-            <p>
-              Questions, suggestions, comments, complaints, etc.
-              <span class="red">regarding this website</span> are
-              welcome and may be submitted below.
-            </p>
-
-            <b-field label="User Name">
-              <b-input v-model="userName"
-                       % if request.user:
-                       disabled
-                       % endif
-                       >
-              </b-input>
-            </b-field>
-
-            <b-field label="Referring URL">
-              <b-input
-                 v-model="referrer"
-                 disabled="true">
-              </b-input>
-            </b-field>
-
-            <b-field label="Message">
-              <b-input type="textarea"
-                       v-model="message"
-                       ref="textarea">
-              </b-input>
-            </b-field>
-
-          </section>
-
-          <footer class="modal-card-foot">
-            <b-button @click="showDialog = false">
-              Cancel
-            </b-button>
-            <once-button type="is-primary"
-                         @click="sendFeedback()"
-                         :disabled="!message.trim()"
-                         text="Send Message">
-            </once-button>
-          </footer>
-        </div>
-      </b-modal>
-
-    </div>
-  </script>
-
-  ${tailbone_autocomplete_template()}
-</%def>
-
-<%def name="declare_whole_page_vars()">
-  ${h.javascript_link(request.static_url('tailbone:static/themes/falafel/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
-  <script type="text/javascript">
-
-    let WholePage = {
-        template: '#whole-page-template',
-        methods: {
-
-            changeContentTitle(newTitle) {
-                this.contentTitleHTML = newTitle
-            },
-
-            % if expose_db_picker is not Undefined and expose_db_picker:
-                changeDB() {
-                    this.$refs.dbPickerForm.submit()
-                },
-            % endif
-
-            % if expose_theme_picker and request.has_perm('common.change_app_theme'):
-                changeTheme() {
-                    this.$refs.themePickerForm.submit()
-                },
-            % endif
-        },
-    }
-
-    let WholePageData = {
-        contentTitleHTML: ${json.dumps(capture(self.content_title))|n}
-    }
-
-  </script>
-</%def>
-
-<%def name="modify_whole_page_vars()">
-  <script type="text/javascript">
-
-    FeedbackFormData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
-    FeedbackFormData.referrer = location.href
-
-    % if request.user:
-    FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
-    FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n}
-    % endif
-
-  </script>
-</%def>
-
-<%def name="finalize_whole_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
-</%def>
-
-<%def name="make_whole_page_component()">
-  ${self.declare_whole_page_vars()}
-  ${self.modify_whole_page_vars()}
-  ${self.finalize_whole_page_vars()}
-
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
-
-  <script type="text/javascript">
-
-    FeedbackForm.data = function() { return FeedbackFormData }
-
-    Vue.component('feedback-form', FeedbackForm)
-
-    WholePage.data = function() { return WholePageData }
-
-    Vue.component('whole-page', WholePage)
-
-  </script>
-</%def>
-
-<%def name="make_whole_page_app()">
-  <script type="text/javascript">
-
-    new Vue({
-        el: '#whole-page-app'
-    })
-
-  </script>
-</%def>
-
-<%def name="wtfield(form, name, **kwargs)">
-  <div class="field-wrapper${' error' if form[name].errors else ''}">
-    <label for="${name}">${form[name].label}</label>
-    <div class="field">
-      ${form[name](**kwargs)}
-    </div>
-  </div>
-</%def>
-
-<%def name="simple_field(label, value)">
-  ## TODO: keep this? only used by personal profile view currently
-  ## (although could be useful for any readonly scenario)
-  <div class="field-wrapper">
-    <div class="field-row">
-      <label>${label}</label>
-      <div class="field">
-        ${'' if value is None else value}
-      </div>
-    </div>
-  </div>
-</%def>
+${parent.body()}
diff --git a/tailbone/templates/themes/falafel/progress.mako b/tailbone/templates/themes/falafel/progress.mako
new file mode 100644
index 00000000..4bb27014
--- /dev/null
+++ b/tailbone/templates/themes/falafel/progress.mako
@@ -0,0 +1,4 @@
+## -*- coding: utf-8; -*-
+<%inherit file="tailbone:templates/progress.mako" />
+
+${parent.body()}
diff --git a/tailbone/templates/themes/falafel/upgrade.mako b/tailbone/templates/themes/falafel/upgrade.mako
new file mode 100644
index 00000000..b7562653
--- /dev/null
+++ b/tailbone/templates/themes/falafel/upgrade.mako
@@ -0,0 +1,4 @@
+## -*- coding: utf-8; -*-
+<%inherit file="tailbone:templates/upgrade.mako" />
+
+${parent.body()}
diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako
new file mode 100644
index 00000000..774479ba
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/base.mako
@@ -0,0 +1,504 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/base.mako" />
+<%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
+<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
+<%namespace name="page_help" file="/page_help.mako" />
+
+<%def name="base_styles()">
+  ${parent.base_styles()}
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
+  <style>
+
+    .filters .filter-fieldname .field,
+    .filters .filter-fieldname .field label {
+        width: 100%;
+    }
+
+    .filters .filter-fieldname,
+    .filters .filter-fieldname .field label,
+    .filters .filter-fieldname .button {
+        justify-content: left;
+    }
+
+    .filters .filter-verb .select,
+    .filters .filter-verb .select select {
+        width: 100%;
+    }
+
+    % if filter_fieldname_width is not Undefined:
+
+        .filters .filter-fieldname,
+        .filters .filter-fieldname .button {
+            min-width: ${filter_fieldname_width};
+        }
+
+        .filters .filter-verb {
+            min-width: ${filter_verb_width};
+        }
+
+    % endif
+
+  </style>
+</%def>
+
+<%def name="before_content()">
+  ## TODO: this must come before the self.body() call..but why?
+  ${declare_formposter_mixin()}
+</%def>
+
+<%def name="render_navbar_brand()">
+  <div class="navbar-brand">
+    <a class="navbar-item" href="${url('home')}"
+       v-show="!menuSearchActive">
+      <div style="display: flex; align-items: center;">
+        ${base_meta.header_logo()}
+        <div id="navbar-brand-title">
+          ${base_meta.global_title()}
+        </div>
+      </div>
+    </a>
+    <div v-show="menuSearchActive"
+         class="navbar-item">
+      <b-autocomplete ref="menuSearchAutocomplete"
+                      v-model="menuSearchTerm"
+                      :data="menuSearchFilteredData"
+                      field="label"
+                      open-on-focus
+                      keep-first
+                      icon-pack="fas"
+                      clearable
+                      @keydown.native="menuSearchKeydown"
+                      @select="menuSearchSelect">
+      </b-autocomplete>
+    </div>
+    <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false">
+      <span aria-hidden="true"></span>
+      <span aria-hidden="true"></span>
+      <span aria-hidden="true"></span>
+    </a>
+  </div>
+</%def>
+
+<%def name="render_navbar_start()">
+  <div class="navbar-start">
+
+    <div v-if="menuSearchData.length"
+         class="navbar-item">
+      <b-button type="is-primary"
+                size="is-small"
+                @click="menuSearchInit()">
+        <span><i class="fa fa-search"></i></span>
+      </b-button>
+    </div>
+
+    % for topitem in menus:
+        % if topitem['is_link']:
+            ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')}
+        % else:
+            <div class="navbar-item has-dropdown is-hoverable">
+              <a class="navbar-link">${topitem['title']}</a>
+              <div class="navbar-dropdown">
+                % for item in topitem['items']:
+                    % if item['is_menu']:
+                        <% item_hash = id(item) %>
+                        <% toggle = 'menu_{}_shown'.format(item_hash) %>
+                        <div>
+                          <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')">
+                            ${item['title']}
+                          </a>
+                        </div>
+                        % for subitem in item['items']:
+                            % if subitem['is_sep']:
+                                <hr class="navbar-divider" v-show="${toggle}">
+                            % else:
+                                ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})}
+                            % endif
+                        % endfor
+                    % else:
+                        % if item['is_sep']:
+                            <hr class="navbar-divider">
+                        % else:
+                            ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])}
+                        % endif
+                    % endif
+                % endfor
+              </div>
+            </div>
+        % endif
+    % endfor
+
+  </div>
+</%def>
+
+<%def name="render_theme_picker()">
+  % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+      <div class="level-item">
+        ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
+          ${h.csrf_token(request)}
+          <input type="hidden" name="referrer" :value="referrer" />
+          <div style="display: flex; align-items: center; gap: 0.5rem;">
+            <span>Theme:</span>
+            <b-select name="theme"
+                      v-model="globalTheme"
+                      @input="changeTheme()">
+              % for option in theme_picker_options:
+                  <option value="${option.value}">
+                    ${option.label}
+                  </option>
+              % endfor
+            </b-select>
+          </div>
+        ${h.end_form()}
+      </div>
+  % endif
+</%def>
+
+<%def name="render_feedback_button()">
+
+  <div class="level-item">
+    <page-help
+      % if can_edit_help:
+      @configure-fields-help="configureFieldsHelp = true"
+      % endif
+      />
+  </div>
+
+  ${parent.render_feedback_button()}
+</%def>
+
+<%def name="render_crud_header_buttons()">
+  % if master:
+      % if master.viewing:
+          % if instance_editable and master.has_perm('edit'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('edit', instance)}"
+                            icon-left="edit"
+                            label="Edit This" />
+          % endif
+          % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('clone', instance)}"
+                            icon-left="object-ungroup"
+                            label="Clone This" />
+          % endif
+          % if instance_deletable and master.has_perm('delete'):
+              <wutta-button once type="is-danger"
+                            tag="a" href="${master.get_action_url('delete', instance)}"
+                            icon-left="trash"
+                            label="Delete This" />
+          % endif
+      % elif master.editing:
+          % if master.has_perm('view'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('view', instance)}"
+                            icon-left="eye"
+                            label="View This" />
+          % endif
+          % if instance_deletable and master.has_perm('delete'):
+              <wutta-button once type="is-danger"
+                            tag="a" href="${master.get_action_url('delete', instance)}"
+                            icon-left="trash"
+                            label="Delete This" />
+          % endif
+      % elif master.deleting:
+          % if master.has_perm('view'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('view', instance)}"
+                            icon-left="eye"
+                            label="View This" />
+          % endif
+          % if instance_editable and master.has_perm('edit'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('edit', instance)}"
+                            icon-left="edit"
+                            label="Edit This" />
+          % endif
+      % endif
+  % endif
+</%def>
+
+<%def name="render_prevnext_header_buttons()">
+  % if show_prev_next is not Undefined and show_prev_next:
+      % if prev_url:
+          <wutta-button once
+                        tag="a" href="${prev_url}"
+                        icon-left="arrow-left"
+                        label="Older" />
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % endif
+      % if next_url:
+          <wutta-button once
+                        tag="a" href="${next_url}"
+                        icon-left="arrow-right"
+                        label="Newer" />
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % endif
+  % endif
+</%def>
+
+<%def name="render_this_page_component()">
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+                 :configure-fields-help="configureFieldsHelp"
+             % endif
+             />
+</%def>
+
+<%def name="render_vue_template_feedback()">
+  <script type="text/x-template" id="feedback-template">
+    <div>
+
+      <div class="level-item">
+        <b-button type="is-primary"
+                  @click="showFeedback()"
+                  icon-pack="fas"
+                  icon-left="comment">
+          Feedback
+        </b-button>
+      </div>
+
+      <b-modal has-modal-card
+               :active.sync="showDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">User Feedback</p>
+          </header>
+
+          <section class="modal-card-body">
+            <p class="block">
+              Questions, suggestions, comments, complaints, etc.
+              <span class="red">regarding this website</span> are
+              welcome and may be submitted below.
+            </p>
+
+            <b-field label="User Name">
+              <b-input v-model="userName"
+                       % if request.user:
+                           disabled
+                       % endif
+                       >
+              </b-input>
+            </b-field>
+
+            <b-field label="Referring URL">
+              <b-input
+                 v-model="referrer"
+                 disabled="true">
+              </b-input>
+            </b-field>
+
+            <b-field label="Message">
+              <b-input type="textarea"
+                       v-model="message"
+                       ref="textarea">
+              </b-input>
+            </b-field>
+
+            % if config.get_bool('tailbone.feedback_allows_reply'):
+                <div class="level">
+                  <div class="level-left">
+                    <div class="level-item">
+                      <b-checkbox v-model="pleaseReply"
+                                  @input="pleaseReplyChanged">
+                        Please email me back{{ pleaseReply ? " at: " : "" }}
+                      </b-checkbox>
+                    </div>
+                    <div class="level-item" v-show="pleaseReply">
+                      <b-input v-model="userEmail"
+                               ref="userEmail">
+                      </b-input>
+                    </div>
+                  </div>
+                </div>
+            % endif
+
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button @click="showDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="paper-plane"
+                      @click="sendFeedback()"
+                      :disabled="sendingFeedback || !message || !message.trim()">
+              {{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+
+    </div>
+  </script>
+</%def>
+
+<%def name="render_vue_script_feedback()">
+  ${parent.render_vue_script_feedback()}
+  <script>
+
+    WuttaFeedbackForm.template = '#feedback-template'
+    WuttaFeedbackForm.props.message = String
+
+    % if config.get_bool('tailbone.feedback_allows_reply'):
+
+        WuttaFeedbackFormData.pleaseReply = false
+        WuttaFeedbackFormData.userEmail = null
+
+        WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) {
+            this.$nextTick(() => {
+                this.$refs.userEmail.focus()
+            })
+        }
+
+        WuttaFeedbackForm.methods.getExtraParams = function() {
+            return {
+                please_reply_to: this.pleaseReply ? this.userEmail : null,
+            }
+        }
+
+    % endif
+
+    // TODO: deprecate / remove these
+    const FeedbackForm = WuttaFeedbackForm
+    const FeedbackFormData = WuttaFeedbackFormData
+
+  </script>
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${page_help.render_template()}
+  ${page_help.declare_vars()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ##############################
+    ## menu search
+    ##############################
+
+    WholePageData.menuSearchActive = false
+    WholePageData.menuSearchTerm = ''
+    WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n}
+
+    WholePage.computed.menuSearchFilteredData = function() {
+        if (!this.menuSearchTerm.length) {
+            return this.menuSearchData
+        }
+
+        const terms = []
+        for (let term of this.menuSearchTerm.toLowerCase().split(' ')) {
+            term = term.trim()
+            if (term) {
+                terms.push(term)
+            }
+        }
+        if (!terms.length) {
+            return this.menuSearchData
+        }
+
+        // all terms must match
+        return this.menuSearchData.filter((option) => {
+            const label = option.label.toLowerCase()
+            for (const term of terms) {
+                if (label.indexOf(term) < 0) {
+                    return false
+                }
+            }
+            return true
+        })
+    }
+
+    WholePage.methods.globalKey = function(event) {
+
+        // Ctrl+8 opens menu search
+        if (event.target.tagName == 'BODY') {
+            if (event.ctrlKey && event.key == '8') {
+                this.menuSearchInit()
+            }
+        }
+    }
+
+    WholePage.mounted = function() {
+        window.addEventListener('keydown', this.globalKey)
+        for (let hook of this.mountedHooks) {
+            hook(this)
+        }
+    }
+
+    WholePage.beforeDestroy = function() {
+        window.removeEventListener('keydown', this.globalKey)
+    }
+
+    WholePage.methods.menuSearchInit = function() {
+        this.menuSearchTerm = ''
+        this.menuSearchActive = true
+        this.$nextTick(() => {
+            this.$refs.menuSearchAutocomplete.focus()
+        })
+    }
+
+    WholePage.methods.menuSearchKeydown = function(event) {
+
+        // ESC will dismiss searchbox
+        if (event.which == 27) {
+            this.menuSearchActive = false
+        }
+    }
+
+    WholePage.methods.menuSearchSelect = function(option) {
+        location.href = option.url
+    }
+
+    ##############################
+    ## theme picker
+    ##############################
+
+    % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+
+        WholePageData.globalTheme = ${json.dumps(theme or None)|n}
+        ## WholePageData.referrer = location.href
+
+        WholePage.methods.changeTheme = function() {
+            this.$refs.themePickerForm.submit()
+        }
+
+    % endif
+
+    ##############################
+    ## edit fields help
+    ##############################
+
+    % if can_edit_help:
+        WholePageData.configureFieldsHelp = false
+    % endif
+
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')}
+  ${make_grid_filter_components()}
+  ${page_help.make_component()}
+</%def>
diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako
new file mode 100644
index 00000000..7a3e5261
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/configure.mako
@@ -0,0 +1,78 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/configure.mako" />
+<%namespace name="tailbone_base" file="tailbone:templates/configure.mako" />
+
+<%def name="input_file_templates_section()">
+  ${tailbone_base.input_file_templates_section()}
+</%def>
+
+<%def name="output_file_templates_section()">
+  ${tailbone_base.output_file_templates_section()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ##############################
+    ## input file templates
+    ##############################
+
+    % if input_file_template_settings is not Undefined:
+
+        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
+        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
+        ThisPageData.inputFileTemplateUploads = {
+            % for key in input_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateInputFileTemplateSettings = function() {
+            % for tmpl in input_file_templates.values():
+                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings)
+
+    % endif
+
+    ##############################
+    ## output file templates
+    ##############################
+
+    % if output_file_template_settings is not Undefined:
+
+        ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n}
+        ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n}
+        ThisPageData.outputFileTemplateUploads = {
+            % for key in output_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateOutputFileTemplateSettings = function() {
+            % for tmpl in output_file_templates.values():
+                if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.outputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings)
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako
new file mode 100644
index 00000000..f88d6821
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/form.mako
@@ -0,0 +1,10 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/form.mako" />
+
+<%def name="render_vue_template_form()">
+  % if form is not Undefined:
+      ${form.render_vue_template(buttons=capture(self.render_form_buttons))}
+  % endif
+</%def>
+
+<%def name="render_form_buttons()"></%def>
diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako
new file mode 100644
index 00000000..51da5b0a
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/configure.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/configure.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/create.mako b/tailbone/templates/themes/waterpark/master/create.mako
new file mode 100644
index 00000000..23399b9e
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/create.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/create.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/delete.mako b/tailbone/templates/themes/waterpark/master/delete.mako
new file mode 100644
index 00000000..a15dfaf8
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/delete.mako
@@ -0,0 +1,46 @@
+## -*- coding: utf-8; -*-
+<%inherit file="tailbone:templates/form.mako" />
+
+<%def name="title()">Delete ${model_title}: ${instance_title}</%def>
+
+<%def name="render_form()">
+  <br />
+  <b-notification type="is-danger" :closable="false">
+    You are about to delete the following ${model_title} and all associated data:
+  </b-notification>
+  ${parent.render_form()}
+</%def>
+
+<%def name="render_form_buttons()">
+  <br />
+  <b-notification type="is-danger" :closable="false">
+    Are you sure about this?
+  </b-notification>
+  <br />
+
+  ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})}
+  ${h.csrf_token(request)}
+    <div class="buttons">
+      <wutta-button once tag="a" href="${form.cancel_url}"
+                    label="Whoops, nevermind..." />
+      <b-button type="is-primary is-danger"
+                native-type="submit"
+                :disabled="formSubmitting">
+        {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
+      </b-button>
+    </div>
+  ${h.end_form()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ${form.vue_component}Data.formSubmitting = false
+
+    ${form.vue_component}.methods.submitForm = function() {
+        this.formSubmitting = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/master/edit.mako b/tailbone/templates/themes/waterpark/master/edit.mako
new file mode 100644
index 00000000..18a2fa2f
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/edit.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/edit.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/form.mako b/tailbone/templates/themes/waterpark/master/form.mako
new file mode 100644
index 00000000..db56843b
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/form.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/form.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako
new file mode 100644
index 00000000..e6702599
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/index.mako
@@ -0,0 +1,299 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/index.mako" />
+
+<%def name="grid_tools()">
+
+  ## grid totals
+  % if getattr(master, 'supports_grid_totals', False):
+      <div style="display: flex; align-items: center;">
+        <b-button v-if="gridTotalsDisplay == null"
+                  :disabled="gridTotalsFetching"
+                  @click="gridTotalsFetch()">
+          {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }}
+        </b-button>
+        <div v-if="gridTotalsDisplay != null"
+             class="control">
+          Totals: {{ gridTotalsDisplay }}
+        </div>
+      </div>
+  % endif
+
+  ## download search results
+  % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
+      <div>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="download"
+                  @click="showDownloadResultsDialog = true"
+                  :disabled="!total">
+          Download Results
+        </b-button>
+
+        ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')}
+        ${h.csrf_token(request)}
+        <input type="hidden" name="fmt" :value="downloadResultsFormat" />
+        <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" />
+        ${h.end_form()}
+
+        <b-modal :active.sync="showDownloadResultsDialog">
+          <div class="card">
+
+            <div class="card-content">
+              <p>
+                There are
+                <span class="is-size-4 has-text-weight-bold">
+                  {{ total.toLocaleString('en') }} ${model_title_plural}
+                </span>
+                matching your current filters.
+              </p>
+              <p>
+                You may download this set as a single data file if you like.
+              </p>
+              <br />
+
+              <b-notification type="is-warning" :closable="false"
+                              v-if="downloadResultsFormat == 'xlsx' && total >= 1000">
+                Excel downloads for large data sets can take a long time to
+                generate, and bog down the server in the meantime.  You are
+                encouraged to choose CSV for a large data set, even though
+                the end result (file size) may be larger with CSV.
+              </b-notification>
+
+              <div style="display: flex; justify-content: space-between">
+
+                <div>
+                  <b-field label="Format">
+                    <b-select v-model="downloadResultsFormat">
+                      % for key, label in master.download_results_supported_formats().items():
+                      <option value="${key}">${label}</option>
+                      % endfor
+                    </b-select>
+                  </b-field>
+                </div>
+
+                <div>
+
+                  <div v-show="downloadResultsFieldsMode != 'choose'"
+                       class="has-text-right">
+                    <p v-if="downloadResultsFieldsMode == 'default'">
+                      Will use DEFAULT fields.
+                    </p>
+                    <p v-if="downloadResultsFieldsMode == 'all'">
+                      Will use ALL fields.
+                    </p>
+                    <br />
+                  </div>
+
+                  <div class="buttons is-right">
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'default'"
+                              @click="downloadResultsUseDefaultFields()">
+                      Use Default Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'all'"
+                              @click="downloadResultsUseAllFields()">
+                      Use All Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'choose'"
+                              @click="downloadResultsFieldsMode = 'choose'">
+                      Choose Fields
+                    </b-button>
+                  </div>
+
+                  <div v-show="downloadResultsFieldsMode == 'choose'">
+                    <div style="display: flex;">
+                      <div>
+                        <b-field label="Excluded Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsExcludedFieldsSelected"
+                                    ref="downloadResultsExcludedFields">
+                            <option v-for="field in downloadResultsFieldsExcluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                      <div>
+                        <br /><br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsExcludeFields()">
+                          &lt;
+                        </b-button>
+                        <br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsIncludeFields()">
+                          &gt;
+                        </b-button>
+                      </div>
+                      <div>
+                        <b-field label="Included Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsIncludedFieldsSelected"
+                                    ref="downloadResultsIncludedFields">
+                            <option v-for="field in downloadResultsFieldsIncluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                    </div>
+                  </div>
+
+                </div>
+              </div>
+            </div> <!-- card-content -->
+
+            <footer class="modal-card-foot">
+              <b-button @click="showDownloadResultsDialog = false">
+                Cancel
+              </b-button>
+              <once-button type="is-primary"
+                           @click="downloadResultsSubmit()"
+                           icon-pack="fas"
+                           icon-left="download"
+                           :disabled="!downloadResultsFieldsIncluded.length"
+                           text="Download Results">
+              </once-button>
+            </footer>
+          </div>
+        </b-modal>
+      </div>
+  % endif
+
+  ## download rows for search results
+  % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+      <b-button type="is-primary"
+                icon-pack="fas"
+                icon-left="download"
+                @click="downloadResultsRows()"
+                :disabled="downloadResultsRowsButtonDisabled">
+        {{ downloadResultsRowsButtonText }}
+      </b-button>
+      ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')}
+      ${h.csrf_token(request)}
+      ${h.end_form()}
+  % endif
+
+  ## merge 2 objects
+  % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
+
+      ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
+      ${h.csrf_token(request)}
+      <input type="hidden"
+             name="uuids"
+             :value="checkedRowUUIDs()" />
+      <b-button type="is-primary"
+                native-type="submit"
+                icon-pack="fas"
+                icon-left="object-ungroup"
+                :disabled="mergeFormSubmitting || checkedRows.length != 2">
+        {{ mergeFormButtonText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## enable / disable selected objects
+  % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
+
+      ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="enableSelectedDisabled"
+                @click="enableSelectedSubmit()">
+        {{ enableSelectedText }}
+      </b-button>
+      ${h.end_form()}
+
+      ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="disableSelectedDisabled"
+                @click="disableSelectedSubmit()">
+        {{ disableSelectedText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## delete selected objects
+  % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
+      ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button type="is-danger"
+                :disabled="deleteSelectedDisabled"
+                @click="deleteSelectedSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteSelectedText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## delete search results
+  % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
+      ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
+      ${h.csrf_token(request)}
+      <b-button type="is-danger"
+                :disabled="deleteResultsDisabled"
+                :title="total ? null : 'There are no results to delete'"
+                @click="deleteResultsSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteResultsText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
+<%def name="render_vue_template_grid()">
+  ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
+
+        ${grid.vue_component}Data.deleteResultsSubmitting = false
+        ${grid.vue_component}Data.deleteResultsText = "Delete Results"
+
+        ${grid.vue_component}.computed.deleteResultsDisabled = function() {
+            if (this.deleteResultsSubmitting) {
+                return true
+            }
+            if (!this.total) {
+                return true
+            }
+            return false
+        }
+
+        ${grid.vue_component}.methods.deleteResultsSubmit = function() {
+            // TODO: show "plural model title" here?
+            if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) {
+                return
+            }
+
+            this.deleteResultsSubmitting = true
+            this.deleteResultsText = "Working, please wait..."
+            this.$refs.delete_results_form.submit()
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/master/view.mako b/tailbone/templates/themes/waterpark/master/view.mako
new file mode 100644
index 00000000..99194469
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/view.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/view.mako" />
diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako
new file mode 100644
index 00000000..66ce47dc
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/page.mako
@@ -0,0 +1,48 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/page.mako" />
+
+<%def name="render_vue_template_this_page()">
+  <script type="text/x-template" id="this-page-template">
+    <div style="height: 100%;">
+      ## DEPRECATED; called for back-compat
+      ${self.render_this_page()}
+    </div>
+  </script>
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  <div style="display: flex;">
+
+    <div class="this-page-content" style="flex-grow: 1;">
+      ${self.page_content()}
+    </div>
+
+    ## DEPRECATED; remains for back-compat
+    <ul id="context-menu">
+      ${self.context_menu_items()}
+    </ul>
+  </div>
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="context_menu_items()">
+  % if context_menu_list_items is not Undefined:
+      % for item in context_menu_list_items:
+          <li>${item}</li>
+      % endfor
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
+
+    % if can_edit_help:
+        ThisPage.props.configureFieldsHelp = Boolean
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako
new file mode 100644
index 00000000..10c57e18
--- /dev/null
+++ b/tailbone/templates/trainwreck/transactions/configure.mako
@@ -0,0 +1,70 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Display</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field>
+      <b-checkbox name="tailbone.trainwreck.view_txn.autocollapse_header"
+                  v-model="simpleSettings['tailbone.trainwreck.view_txn.autocollapse_header']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Auto-collapse header when viewing transaction
+      </b-checkbox>
+    </b-field>
+  </div>
+
+  <h3 class="block is-size-3">Rotation</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="There is only one Trainwreck DB, unless rotation is used.">
+      <b-checkbox name="trainwreck.use_rotation"
+                  v-model="simpleSettings['trainwreck.use_rotation']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Rotate Databases
+      </b-checkbox>
+    </b-field>
+
+    <b-field grouped>
+      <b-field label="Current Years"
+               message="How many years (max) to keep in &quot;current&quot; DB.  Default is 2 if not set.">
+        <b-input name="trainwreck.current_years"
+                 v-model="simpleSettings['trainwreck.current_years']"
+                 @input="settingsNeedSaved = true">
+        </b-input>
+      </b-field>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Hidden Databases</h3>
+  <div class="block" style="padding-left: 2rem;">
+    <p class="block">
+      The selected DBs will be hidden from the DB picker when viewing
+      Trainwreck data.
+    </p>
+    % for key, engine in trainwreck_engines.items():
+        <b-field>
+          <b-checkbox name="hidedb_${key}"
+                      v-model="hiddenDatabases['${key}']"
+                      native-value="true"
+                      % if key == 'default':
+                      disabled
+                      % endif
+                      @input="settingsNeedSaved = true">
+            ${key}
+          </b-checkbox>
+        </b-field>
+    % endfor
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako
new file mode 100644
index 00000000..f26515b5
--- /dev/null
+++ b/tailbone/templates/trainwreck/transactions/rollover.mako
@@ -0,0 +1,56 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/page.mako" />
+
+<%def name="title()">${index_title} &raquo; Yearly Rollover</%def>
+
+<%def name="content_title()">Yearly Rollover</%def>
+
+<%def name="page_content()">
+  <br />
+
+  % if str(next_year) not in trainwreck_engines:
+      <b-notification type="is-warning">
+        You do not have a database configured for next year (${next_year}).&nbsp;
+        You should be sure to configure it before next year rolls around.
+      </b-notification>
+  % endif
+
+  <p class="block">
+    The following Trainwreck databases are configured:
+  </p>
+
+  <b-table :data="engines">
+    <b-table-column field="key"
+                    label="DB Key"
+                    v-slot="props">
+      {{ props.row.key }}
+    </b-table-column>
+    <b-table-column field="oldest_date"
+                    label="Oldest Date"
+                    v-slot="props">
+      <span v-if="props.row.error" class="has-text-danger">
+        error
+      </span>
+      <span v-if="!props.row.error">
+        {{ props.row.oldest_date }}
+      </span>
+    </b-table-column>
+    <b-table-column field="newest_date"
+                    label="Newest Date"
+                    v-slot="props">
+      <span v-if="props.row.error" class="has-text-danger">
+        error
+      </span>
+      <span v-if="!props.row.error">
+        {{ props.row.newest_date }}
+      </span>
+    </b-table-column>
+  </b-table>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.engines = ${json.dumps(engines_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako
new file mode 100644
index 00000000..630950cf
--- /dev/null
+++ b/tailbone/templates/trainwreck/transactions/view.mako
@@ -0,0 +1,11 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    % if custorder_xref_markers_data is not Undefined:
+        ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
+    % endif
+  </script>
+</%def>
diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako
new file mode 100644
index 00000000..2507492e
--- /dev/null
+++ b/tailbone/templates/trainwreck/transactions/view_row.mako
@@ -0,0 +1,11 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view_row.mako" />
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    % if discounts_data is not Undefined:
+        ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n}
+    % endif
+  </script>
+</%def>
diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako
new file mode 100644
index 00000000..4815fc79
--- /dev/null
+++ b/tailbone/templates/units-of-measure/index.mako
@@ -0,0 +1,67 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/index.mako" />
+
+<%def name="grid_tools()">
+  ${parent.grid_tools()}
+
+  % if master.has_perm('collect_wild_uoms'):
+  <b-button type="is-primary"
+            icon-pack="fas"
+            icon-left="shopping-basket"
+            @click="showingCollectWildDialog = true">
+    Collect from the Wild
+  </b-button>
+
+  ${h.form(url('{}.collect_wild_uoms'.format(route_prefix)), ref='collect-wild-uoms-form')}
+  ${h.csrf_token(request)}
+  ${h.end_form()}
+
+  <b-modal has-modal-card
+           :active.sync="showingCollectWildDialog">
+    <div class="modal-card">
+
+      <header class="modal-card-head">
+        <p class="modal-card-title">Collect from the Wild</p>
+      </header>
+
+      <section class="modal-card-body">
+        <p>
+          This tool will query some database(s) in order to discover all UOM
+          abbreviations which currently exist in your product data.
+        </p>
+        <p>
+          Depending on how it has to go about that, this could take a minute or
+          two.&nbsp; Please be patient when running it.
+        </p>
+      </section>
+
+      <footer class="modal-card-foot">
+        <b-button @click="showingCollectWildDialog = false">
+          Cancel
+        </b-button>
+        <once-button type="is-primary"
+                     @click="collectFromWild()"
+                     icon-left="shopping-basket"
+                     text="Collect from the Wild">
+        </once-button>
+      </footer>
+
+    </div>
+  </b-modal>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if master.has_perm('collect_wild_uoms'):
+      <script>
+
+        ${grid.vue_component}Data.showingCollectWildDialog = false
+
+        ${grid.vue_component}.methods.collectFromWild = function() {
+            this.$refs['collect-wild-uoms-form'].submit()
+        }
+
+      </script>
+  % endif
+</%def>
diff --git a/tailbone/templates/upgrade.mako b/tailbone/templates/upgrade.mako
index a12361c5..7cc73941 100644
--- a/tailbone/templates/upgrade.mako
+++ b/tailbone/templates/upgrade.mako
@@ -1,86 +1,69 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/progress.mako" />
 
-<%def name="update_progress_func()">
-  <script type="text/javascript">
-
-      function update_progress() {
-          $.ajax({
-              url: '${url('upgrades.execute_progress', uuid=instance.uuid)}',
-              success: function(data) {
-                  if (data.error) {
-                      location.href = '${cancel_url}';
-                  } else {
-
-                      if (data.stdout) {
-                          var stdout = $('.stdout');
-                          var height = $(window).height() - stdout.offset().top - 50;
-                          stdout.height(height);
-                          stdout.append(data.stdout);
-                          stdout.animate({scrollTop: stdout.get(0).scrollHeight - height}, 250);
-                      }
-
-                      if (data.complete || data.maximum) {
-                          $('#message').html(data.message);
-                          $('#total').html('('+data.maximum_display+' total)');
-                          $('#cancel button').show();
-                          if (data.complete) {
-                              stillInProgress = false;
-                              $('#cancel button').hide();
-                              $('#total').html('done!');
-                              $('#complete').css('width', '100%');
-                              $('#remaining').hide();
-                              $('#percentage').html('100 %');
-                              location.href = data.success_url;
-                          } else {
-                              var width = parseInt(data.value) / parseInt(data.maximum);
-                              width = Math.round(100 * width);
-                              if (width) {
-                                  $('#complete').css('width', width+'%');
-                                  $('#percentage').html(width+' %');
-                              } else {
-                                  $('#complete').css('width', '0.01%');
-                                  $('#percentage').html('0 %');
-                              }
-                              $('#remaining').css('width', 'auto');
-                          }
-                      }
-
-                      if (stillInProgress) {
-                          // fetch progress data again, in one second from now
-                          setTimeout(function() {
-                              update_progress();
-                          }, 1000);
-                      }
-                  }
-              }
-          });
-      }
-  </script>
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
-    #wrapper {
-        top: 6em;
-    }
-    .stdout {
+
+    .progress-with-textout {
         border: 1px solid Black;
-        height: 500px;
-        margin-left: 4.5%;
+        line-height: 1.2;
+        margin-top: 1rem;
         overflow: auto;
-        padding: 4px;
-        position: absolute;
-        top: 10em;
-        white-space: nowrap;
-        width: 90%;
+        padding: 1rem;
     }
+
   </style>
 </%def>
 
 <%def name="after_progress()">
-  <div class="stdout"></div>
+  <!-- <div ref="stdout" class="stdout"></div> -->
+
+  <div ref="textout"
+       class="progress-with-textout is-family-monospace is-size-7">
+    <span v-for="line in progressOutput"
+          :key="line.key"
+          v-html="line.text">
+    </span>
+
+    ## nb. we auto-scroll down to "see" this element
+    <div ref="seeme"></div>
+  </div>
+
 </%def>
 
+<%def name="modify_whole_page_vars()">
+  <script type="text/javascript">
+
+    WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}'
+    WholePageData.progressOutput = []
+    WholePageData.progressOutputCounter = 0
+
+    WholePage.methods.mountedCustom = function() {
+
+        // grow the textout area to fill most of screen
+        let textout = this.$refs.textout
+        let height = window.innerHeight - textout.offsetTop - 100
+        textout.style.height = height + 'px'
+    }
+
+    WholePage.methods.updateProgressCustom = function(response) {
+        if (response.data.stdout) {
+
+            // add lines to textout area
+            this.progressOutput.push({
+                key: ++this.progressOutputCounter,
+                text: response.data.stdout})
+
+            //  scroll down to end of textout area
+            this.$nextTick(() => {
+                this.$refs.seeme.scrollIntoView({behavior: 'smooth'})
+            })
+        }
+    }
+
+  </script>
+</%def>
+
+
 ${parent.body()}
diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako
new file mode 100644
index 00000000..9439f830
--- /dev/null
+++ b/tailbone/templates/upgrades/configure.mako
@@ -0,0 +1,163 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+  ${h.hidden('upgrade_systems', **{':value': 'JSON.stringify(upgradeSystems)'})}
+
+  <h3 class="is-size-3">Upgradable Systems</h3>
+  <div class="block" style="padding-left: 2rem; display: flex;">
+
+    <${b}-table :data="upgradeSystems"
+             sortable>
+      <${b}-table-column field="key"
+                      label="Key"
+                      v-slot="props"
+                      sortable>
+        {{ props.row.key }}
+      </${b}-table-column>
+      <${b}-table-column field="label"
+                      label="Label"
+                      v-slot="props"
+                      sortable>
+        {{ props.row.label }}
+      </${b}-table-column>
+      <${b}-table-column field="command"
+                      label="Command"
+                      v-slot="props"
+                      sortable>
+        {{ props.row.command }}
+      </${b}-table-column>
+      <${b}-table-column label="Actions"
+                      v-slot="props">
+        <a href="#"
+           @click.prevent="upgradeSystemEdit(props.row)">
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
+          Edit
+        </a>
+        &nbsp;
+        <a href="#"
+           v-if="props.row.key != 'rattail'"
+           class="has-text-danger"
+           @click.prevent="updateSystemDelete(props.row)">
+          % if request.use_oruga:
+              <o-icon icon="trash" />
+          % else:
+              <i class="fas fa-trash"></i>
+          % endif
+          Delete
+        </a>
+      </${b}-table-column>
+    </${b}-table>
+
+    <div style="margin-left: 1rem;">
+      <b-button type="is-primary"
+                icon-pack="fas"
+                icon-left="plus"
+                @click="upgradeSystemCreate()">
+        New System
+      </b-button>
+
+      <b-modal has-modal-card
+               :active.sync="upgradeSystemShowDialog">
+        <div class="modal-card">
+
+          <header class="modal-card-head">
+            <p class="modal-card-title">Upgradable System</p>
+          </header>
+
+          <section class="modal-card-body">
+            <b-field label="Key"
+                     :type="upgradeSystemKey ? null : 'is-danger'">
+              <b-input v-model.trim="upgradeSystemKey"
+                       ref="upgradeSystemKey"
+                       :disabled="upgradeSystemKey == 'rattail'"
+                       expanded />
+            </b-field>
+            <b-field label="Label"
+                     :type="upgradeSystemLabel ? null : 'is-danger'">
+              <b-input v-model.trim="upgradeSystemLabel"
+                       ref="upgradeSystemLabel"
+                       :disabled="upgradeSystemKey == 'rattail'"
+                       expanded />
+            </b-field>
+            <b-field label="Command"
+                     :type="upgradeSystemCommand ? null : 'is-danger'">
+              <b-input v-model.trim="upgradeSystemCommand"
+                       ref="upgradeSystemCommand"
+                       expanded />
+            </b-field>
+          </section>
+
+          <footer class="modal-card-foot">
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="save"
+                      @click="upgradeSystemSave()"
+                      :disabled="!upgradeSystemKey || !upgradeSystemLabel || !upgradeSystemCommand">
+              Save
+            </b-button>
+            <b-button @click="upgradeSystemShowDialog = false">
+              Cancel
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+
+    </div>
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n}
+    ThisPageData.upgradeSystemShowDialog = false
+    ThisPageData.upgradeSystem = null
+    ThisPageData.upgradeSystemKey = null
+    ThisPageData.upgradeSystemLabel = null
+    ThisPageData.upgradeSystemCommand = null
+
+    ThisPage.methods.upgradeSystemCreate = function() {
+        this.upgradeSystem = null
+        this.upgradeSystemKey = null
+        this.upgradeSystemLabel = null
+        this.upgradeSystemCommand = null
+        this.upgradeSystemShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.upgradeSystemKey.focus()
+        })
+    }
+
+    ThisPage.methods.upgradeSystemEdit = function(system) {
+        this.upgradeSystem = system
+        this.upgradeSystemKey = system.key
+        this.upgradeSystemLabel = system.label
+        this.upgradeSystemCommand = system.command
+        this.upgradeSystemShowDialog = true
+        this.$nextTick(() => {
+            this.$refs.upgradeSystemCommand.focus()
+        })
+    }
+
+    ThisPage.methods.upgradeSystemSave = function() {
+        if (this.upgradeSystem) {
+            this.upgradeSystem.key = this.upgradeSystemKey
+            this.upgradeSystem.label = this.upgradeSystemLabel
+            this.upgradeSystem.command = this.upgradeSystemCommand
+        } else {
+            let system = {key: this.upgradeSystemKey,
+                          label: this.upgradeSystemLabel,
+                          command: this.upgradeSystemCommand}
+            this.upgradeSystems.push(system)
+        }
+        this.upgradeSystemShowDialog = false
+        this.settingsNeedSaved = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako
index 03fd9b6b..c3fca81d 100644
--- a/tailbone/templates/upgrades/view.mako
+++ b/tailbone/templates/upgrades/view.mako
@@ -1,63 +1,133 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    function show_packages(type) {
-        if (type == 'all') {
-            $('.showing .diffs').css('font-weight', 'normal');
-            $('table.diff tbody tr').show();
-            $('.showing .all').css('font-weight', 'bold');
-        } else if (type == 'diffs') {
-            $('.showing .all').css('font-weight', 'normal');
-            $('table.diff tbody tr:not(.diff)').hide();
-            $('.showing .diffs').css('font-weight', 'bold');
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  % if master.has_perm('execute'):
+      <style type="text/css">
+        .progress-with-textout {
+            border: 1px solid Black;
+            line-height: 1.2;
+            overflow: auto;
+            padding: 1rem;
         }
-    }
-
-    $(function() {
-
-        show_packages('diffs');
-
-        $('.showing .all').click(function() {
-            show_packages('all');
-            return false;
-        });
-
-        $('.showing .diffs').click(function() {
-            show_packages('diffs')
-            return false;
-        });
-
-    });
-
-  </script>
+      </style>
   % endif
 </%def>
 
+<%def name="render_this_page()">
+  ${parent.render_this_page()}
+
+  % if expose_websockets and master.has_perm('execute'):
+      <${b}-modal full-screen
+                  % if request.use_oruga:
+                      v-model:active="upgradeExecuting"
+                      :cancelable="false"
+                  % else:
+                      :active.sync="upgradeExecuting"
+                      :can-cancel="false"
+                  % endif
+                  >
+        <div class="card">
+          <div class="card-content">
+
+            <div class="level">
+              <div class="level-item has-text-centered"
+                   style="display: flex; flex-direction: column;">
+                <p class="block">
+                  Upgrading ${system_title} (please wait) ...
+                  {{ executeUpgradeComplete ? "DONE!" : "" }}
+                </p>
+                % if request.use_oruga:
+                    <progress class="progress is-large"
+                              style="width: 400px;" />
+                % else:
+                <b-progress size="is-large"
+                            style="width: 400px;"
+    ##                             :value="80"
+    ##                             show-value
+    ##                             format="percent"
+                            >
+                </b-progress>
+                % endif
+              </div>
+              <div class="level-right">
+                <div class="level-item">
+                  <b-button type="is-warning"
+                            icon-pack="fas"
+                            icon-left="sad-tear"
+                            @click="declareFailureClick()">
+                    Declare Failure
+                  </b-button>
+                </div>
+              </div>
+            </div>
+
+            <div class="container progress-with-textout is-family-monospace is-size-7"
+                 ref="textout">
+              <span v-for="line in progressOutput"
+                    :key="line.key"
+                    v-html="line.text">
+              </span>
+
+              ## nb. we auto-scroll down to "see" this element
+              <div ref="seeme"></div>
+            </div>
+
+          </div>
+        </div>
+      </${b}-modal>
+  % endif
+
+  % if master.has_perm('execute'):
+      ${h.form(master.get_action_url('declare_failure', instance), ref='declareFailureForm')}
+      ${h.csrf_token(request)}
+      ${h.end_form()}
+  % endif
+</%def>
+
+<%def name="render_form()">
+  <div class="form">
+    <${form.component}
+      % if master.has_perm('execute'):
+      @declare-failure-click="declareFailureClick"
+      :declare-failure-submitting="declareFailureSubmitting"
+      % if expose_websockets:
+      % if instance_executable:
+      @execute-upgrade-click="executeUpgrade"
+      % endif
+      :upgrade-executing="upgradeExecuting"
+      % endif
+      % endif
+      >
+    </${form.component}>
+  </div>
+</%def>
+
 <%def name="render_form_buttons()">
-  % if not instance.executed and instance.status_code == enum.UPGRADE_STATUS_PENDING and request.has_perm('{}.execute'.format(permission_prefix)):
+  % if instance_executable and master.has_perm('execute'):
       <div class="buttons">
         % if instance.enabled and not instance.executing:
-            % if use_buefy:
-            ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})}
-            % else:
-            ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')}
-            % endif
-            ${h.csrf_token(request)}
-            % if use_buefy:
+            % if expose_websockets:
                 <b-button type="is-primary"
-                          native-type="submit"
-                          :disabled="formSubmitting">
-                  {{ formButtonText }}
+                          icon-pack="fas"
+                          icon-left="arrow-circle-right"
+                          :disabled="upgradeExecuting"
+                          @click="$emit('execute-upgrade-click')">
+                  {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }}
                 </b-button>
             % else:
-                ${h.submit('execute', "Execute this upgrade", class_='button is-primary')}
+                ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})}
+                ${h.csrf_token(request)}
+                <b-button type="is-primary"
+                          native-type="submit"
+                          icon-pack="fas"
+                          icon-left="arrow-circle-right"
+                          :disabled="formSubmitting">
+                  {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }}
+                </b-button>
+                ${h.end_form()}
             % endif
-            ${h.end_form()}
         % elif instance.enabled:
             <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button>
         % else:
@@ -67,22 +137,153 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.showingPackages = 'diffs'
+    ${form.vue_component}Data.showingPackages = 'diffs'
 
-    TailboneFormData.formButtonText = "Execute this upgrade"
-    TailboneFormData.formSubmitting = false
+    % if master.has_perm('execute'):
 
-    TailboneForm.methods.submitForm = function() {
-        this.formSubmitting = true
-        this.formButtonText = "Working, please wait..."
-    }
+        % if expose_websockets:
+
+            ThisPageData.ws = null
+
+            //////////////////////////////
+            // execute upgrade
+            //////////////////////////////
+
+            ${form.vue_component}.props.upgradeExecuting = {
+                type: Boolean,
+                default: false,
+            }
+
+            ThisPageData.upgradeExecuting = false
+            ThisPageData.progressOutput = []
+            ThisPageData.progressOutputCounter = 0
+            ThisPageData.executeUpgradeComplete = false
+
+            ThisPage.methods.adjustTextoutHeight = function() {
+
+                // grow the textout area to fill most of screen
+                let textout = this.$refs.textout
+                let height = window.innerHeight - textout.offsetTop - 50
+                textout.style.height = height + 'px'
+            }
+
+            ThisPage.methods.showExecuteDialog = function() {
+                this.upgradeExecuting = true
+                document.title = "Upgrading ${system_title} ..."
+                this.$nextTick(() => {
+                    this.adjustTextoutHeight()
+                })
+            }
+
+            ThisPage.methods.establishWebsocket = function() {
+
+                ## TODO: should be a cleaner way to get this url?
+                let url = '${url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}'
+                url = url.replace(/^http(s?):/, 'ws$1:')
+
+                this.ws = new WebSocket(url)
+
+                ## TODO: add support for this here?
+                // this.ws.onclose = (event) => {
+                //     // websocket closing means 1 of 2 things:
+                //     // - user navigated away from page intentionally
+                //     // - server connection was broken somehow
+                //     // only one of those is "bad" and we only want to
+                //     // display warning in 2nd case.  so we simply use a
+                //     // brief delay to "rule out" the 1st scenario
+                //     setTimeout(() => { that.websocketBroken = true },
+                //                3000)
+                // }
+
+                this.ws.onmessage = (event) => {
+                    let data = JSON.parse(event.data)
+
+                    if (data.complete) {
+
+                        // upgrade has completed; reload page to view result
+                        this.executeUpgradeComplete = true
+                        this.$nextTick(() => {
+                            location.reload()
+                        })
+
+                    } else if (data.stdout) {
+
+                        // add lines to textout area
+                        this.progressOutput.push({
+                            key: ++this.progressOutputCounter,
+                            text: data.stdout})
+
+                        //  scroll down to end of textout area
+                        this.$nextTick(() => {
+                            this.$refs.seeme.scrollIntoView({behavior: 'smooth'})
+                        })
+                    }
+                }
+            }
+
+            % if instance.executing:
+                ThisPage.mounted = function() {
+                    this.showExecuteDialog()
+                    this.establishWebsocket()
+                }
+            % endif
+
+            % if instance_executable:
+
+                ThisPage.methods.executeUpgrade = function() {
+                    this.showExecuteDialog()
+
+                    let url = '${master.get_action_url('execute', instance)}'
+                    this.submitForm(url, {ws: true}, response => {
+
+                        this.establishWebsocket()
+                    })
+                }
+
+            % endif
+
+        % else:
+            ## no websockets
+
+            //////////////////////////////
+            // execute upgrade
+            //////////////////////////////
+
+            ${form.vue_component}Data.formSubmitting = false
+
+            ${form.vue_component}.methods.submitForm = function() {
+                this.formSubmitting = true
+            }
+
+        % endif
+
+        //////////////////////////////
+        // declare failure
+        //////////////////////////////
+
+        ${form.vue_component}.props.declareFailureSubmitting = {
+            type: Boolean,
+            default: false,
+        }
+
+        ${form.vue_component}.methods.declareFailureClick = function() {
+            this.$emit('declare-failure-click')
+        }
+
+        ThisPageData.declareFailureSubmitting = false
+
+        ThisPage.methods.declareFailureClick = function() {
+            if (confirm("Really declare this upgrade a failure?")) {
+                this.declareFailureSubmitting = true
+                this.$refs.declareFailureForm.submit()
+            }
+        }
+
+    % endif
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/users/find_by_perm.mako b/tailbone/templates/users/find_by_perm.mako
deleted file mode 100644
index 59fcf643..00000000
--- a/tailbone/templates/users/find_by_perm.mako
+++ /dev/null
@@ -1,23 +0,0 @@
-## -*- coding: utf-8 -*-
-<%inherit file="/principal/find_by_perm.mako" />
-
-<%def name="principal_table()">
-  <table>
-    <thead>
-      <tr>
-        <th>Username</th>
-        <th>Person</th>
-      </tr>
-    </thead>
-    <tbody>
-      % for user in principals:
-          <tr>
-            <td>${h.link_to(user.username, url('users.view', uuid=user.uuid))}</td>
-            <td>${user.person or ''}</td>
-          </tr>
-      % endfor
-    </tbody>
-  </table>
-</%def>
-
-${parent.body()}
diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako
new file mode 100644
index 00000000..ecfdd1c7
--- /dev/null
+++ b/tailbone/templates/users/preferences.mako
@@ -0,0 +1,50 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="title()">
+  % if current_user:
+      Edit Preferences
+  % else:
+      ${index_title} &raquo; ${instance_title} &raquo; Preferences
+  % endif
+</%def>
+
+<%def name="content_title()">Preferences</%def>
+
+<%def name="intro_message()">
+  <p class="block">
+    % if current_user:
+        This page lets you modify your preferences.
+    % else:
+        This page lets you modify the preferences for ${config_title}.
+    % endif
+  </p>
+</%def>
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Display</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field label="Theme Style">
+        <b-select name="tailbone.${user.uuid}.user_css"
+                  v-model="simpleSettings['tailbone.${user.uuid}.user_css']"
+                  @input="settingsNeedSaved = true">
+          <option v-for="option in themeStyleOptions"
+                  :key="option.value"
+                  :value="option.value">
+            {{ option.label }}
+          </option>
+        </b-select>
+
+    </b-field>  
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako
index 8477ebfa..d1afd218 100644
--- a/tailbone/templates/users/view.mako
+++ b/tailbone/templates/users/view.mako
@@ -14,4 +14,123 @@
   % endif
 </%def>
 
-${parent.body()}
+<%def name="render_this_page()">
+  ${parent.render_this_page()}
+
+  % if master.has_perm('manage_api_tokens'):
+
+      <b-modal :active.sync="apiNewTokenShowDialog"
+               has-modal-card>
+        <div class="modal-card">
+          <header class="modal-card-head">
+            <p class="modal-card-title">
+              New API Token
+            </p>
+          </header>
+          <section class="modal-card-body">
+
+            <div v-if="!apiNewTokenSaved">
+              <b-field label="Description"
+                       :type="{'is-danger': !apiNewTokenDescription}">
+                <b-input v-model.trim="apiNewTokenDescription"
+                         expanded
+                         ref="apiNewTokenDescription">
+                </b-input>
+              </b-field>
+            </div>
+
+            <div v-if="apiNewTokenSaved">
+              <p class="block">
+                Your new API token is shown below.
+              </p>
+              <p class="block">
+                IMPORTANT:&nbsp; You must record this token elsewhere
+                for later reference.&nbsp; You will NOT be able to
+                recover the value if you lose it.
+              </p>
+              <b-field horizontal label="API Token">
+                {{ apiNewTokenRaw }}
+              </b-field>
+              <b-field horizontal label="Description">
+                {{ apiNewTokenDescription }}
+              </b-field>
+            </div>
+
+          </section>
+          <footer class="modal-card-foot">
+            <b-button @click="apiNewTokenShowDialog = false">
+              {{ apiNewTokenSaved ? "Close" : "Cancel" }}
+            </b-button>
+            <b-button v-if="!apiNewTokenSaved"
+                      type="is-primary"
+                      icon-pack="fas"
+                      icon-left="save"
+                      @click="apiNewTokenSave()"
+                      :disabled="!apiNewTokenDescription || apiNewTokenSaving">
+              Save
+            </b-button>
+          </footer>
+        </div>
+      </b-modal>
+
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if master.has_perm('manage_api_tokens'):
+    <script>
+
+      ${form.vue_component}.props.apiTokens = null
+
+      ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n}
+
+      ThisPageData.apiNewTokenShowDialog = false
+      ThisPageData.apiNewTokenDescription = null
+
+      ThisPage.methods.apiNewToken = function() {
+          this.apiNewTokenDescription = null
+          this.apiNewTokenSaved = false
+          this.apiNewTokenShowDialog = true
+          this.$nextTick(() => {
+              this.$refs.apiNewTokenDescription.focus()
+          })
+      }
+
+      ThisPageData.apiNewTokenSaving = false
+      ThisPageData.apiNewTokenSaved = false
+      ThisPageData.apiNewTokenRaw = null
+
+      ThisPage.methods.apiNewTokenSave = function() {
+          this.apiNewTokenSaving = true
+
+          let url = '${master.get_action_url('add_api_token', instance)}'
+          let params = {
+              description: this.apiNewTokenDescription,
+          }
+
+          this.simplePOST(url, params, response => {
+              this.apiTokens = response.data.tokens
+              this.apiNewTokenSaving = false
+              this.apiNewTokenRaw = response.data.raw_token
+              this.apiNewTokenSaved = true
+          }, response => {
+              this.apiNewTokenSaving = false
+          })
+      }
+
+      ThisPage.methods.apiTokenDelete = function(token) {
+          if (!confirm("Really delete this API token?")) {
+              return
+          }
+
+          let url = '${master.get_action_url('delete_api_token', instance)}'
+          let params = {uuid: token.uuid}
+          this.simplePOST(url, params, response => {
+              this.apiTokens = response.data.tokens
+          })
+      }
+
+    </script>
+  % endif
+</%def>
diff --git a/tailbone/templates/util.mako b/tailbone/templates/util.mako
index 5d3100ad..19a1b89d 100644
--- a/tailbone/templates/util.mako
+++ b/tailbone/templates/util.mako
@@ -2,20 +2,27 @@
 
 <%def name="view_profile_button(person)">
   <div class="buttons">
-    ${h.link_to(person, url('people.view_profile', uuid=person.uuid), class_='button is-primary')}
+    <b-button type="is-primary"
+              tag="a" href="${url('people.view_profile', uuid=person.uuid)}"
+              icon-pack="fas"
+              icon-left="user">
+      ${person}
+    </b-button>
   </div>
 </%def>
 
 <%def name="view_profiles_helper(people)">
   % if request.has_perm('people.view_profile'):
-      <div class="object-helper">
-        <h3>Profiles</h3>
-        <div class="object-helper-content">
-          <p>View full profile for:</p>
-          % for person in people:
-              ${view_profile_button(person)}
-          % endfor
+      <nav class="panel">
+        <p class="panel-heading">Profiles</p>
+        <div class="panel-block">
+          <div style="display: flex; flex-direction: column;">
+            <p class="block">View full profile for:</p>
+            % for person in people:
+                ${view_profile_button(person)}
+            % endfor
+          </div>
         </div>
-      </div>
+      </nav>
   % endif
 </%def>
diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako
new file mode 100644
index 00000000..6b135346
--- /dev/null
+++ b/tailbone/templates/vendors/configure.mako
@@ -0,0 +1,52 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Display</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If not set, vendor chooser is an autocomplete field.">
+      <b-checkbox name="rattail.vendors.choice_uses_dropdown"
+                  v-model="simpleSettings['rattail.vendors.choice_uses_dropdown']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Show vendor chooser as dropdown (select) element
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Supported Vendors</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      The following vendor "keys" are defined within various places in
+      the software.&nbsp; You must identify each explicitly with a
+      Vendor record, for things to work as designed.
+    </p>
+
+    <b-field v-for="setting in supportedVendorSettings"
+             :key="setting.key"
+             horizontal
+             :label="setting.key"
+             :type="supportedVendorSettings[setting.key].value ? null : 'is-warning'"
+             style="max-width: 75%;">
+
+      <tailbone-autocomplete :name="'rattail.vendor.' + setting.key"
+                             service-url="${url('vendors.autocomplete')}"
+                             v-model="supportedVendorSettings[setting.key].value"
+                             :initial-label="setting.label"
+                             @input="settingsNeedSaved = true">
+      </tailbone-autocomplete>
+    </b-field>
+
+  </div>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako
new file mode 100644
index 00000000..e902fd48
--- /dev/null
+++ b/tailbone/templates/views/model/create.mako
@@ -0,0 +1,336 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/create.mako" />
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style type="text/css">
+    .label {
+        white-space: nowrap;
+    }
+  </style>
+</%def>
+
+<%def name="render_this_page()">
+  <b-steps v-model="activeStep"
+           :animated="false"
+           rounded
+           :has-navigation="false"
+           vertical
+           icon-pack="fas">
+
+    <b-step-item step="1"
+                 value="enter-details"
+                 label="Enter Details"
+                 clickable>
+      <h3 class="is-size-3 block">
+        Enter Details
+      </h3>
+
+      <b-field grouped>
+
+        <b-field label="Model Name">
+          <b-select v-model="modelName">
+            <option v-for="name in modelNames"
+                    :key="name"
+                    :value="name">
+              {{ name }}
+            </option>
+          </b-select>
+        </b-field>
+
+        <b-field label="View Class Name">
+          <b-input v-model="viewClassName">
+          </b-input>
+        </b-field>
+
+        <b-field label="View Route Prefix">
+          <b-input v-model="viewRoutePrefix">
+          </b-input>
+        </b-field>
+
+      </b-field>
+
+      <br />
+
+      <div class="buttons">
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="check"
+                  @click="activeStep = 'write-view'">
+          Details are complete
+        </b-button>
+      </div>
+
+    </b-step-item>
+
+    <b-step-item step="2"
+                 value="write-view"
+                 label="Write View">
+      <h3 class="is-size-3 block">
+        Write View
+      </h3>
+
+      <b-field label="Model Name" horizontal>
+        {{ modelName }}
+      </b-field>
+
+      <b-field label="View Class" horizontal>
+        {{ viewClassName }}
+      </b-field>
+
+      <b-field horizontal label="File">
+        <b-input v-model="viewFile"></b-input>
+      </b-field>
+
+      <b-field horizontal>
+        <b-checkbox v-model="viewFileOverwrite">
+          Overwrite file if it exists
+        </b-checkbox>
+      </b-field>
+
+      <div class="form">
+        <div class="buttons">
+          <b-button icon-pack="fas"
+                    icon-left="arrow-left"
+                    @click="activeStep = 'enter-details'">
+            Back
+          </b-button>
+          <b-button type="is-primary"
+                    icon-pack="fas"
+                    icon-left="save"
+                    @click="writeViewFile()"
+                    :disabled="writingViewFile">
+            {{ writingViewFile ? "Working, please wait..." : "Write view class to file" }}
+          </b-button>
+          <b-button icon-pack="fas"
+                    icon-left="arrow-right"
+                    @click="activeStep = 'review-view'">
+            Skip
+          </b-button>
+        </div>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="3"
+                 value="review-view"
+                 label="Review View"
+                 ## clickable
+                 >
+      <h3 class="is-size-3 block">
+        Review View
+      </h3>
+
+      <p class="block">
+        View code was generated to file:
+      </p>
+
+      <p class="block is-family-code" style="padding-left: 3rem;">
+        {{ viewFile }}
+      </p>
+
+      <p class="block">
+        First, review that code and adjust to your liking.
+      </p>
+
+      <p class="block">
+        Next be sure to include the new view in your config.
+        Typically this is done by editing the file...
+      </p>
+
+      <p class="block is-family-code" style="padding-left: 3rem;">
+        ${view_dir}__init__.py
+      </p>
+
+      <p class="block">
+        ...and adding a line to the includeme() block such as:
+      </p>
+
+      <pre class="block">
+def includeme(config):
+
+    # ...existing config includes here...
+
+    ## TODO: stop hard-coding widgets
+    config.include('${pkgroot}.web.views.widgets')
+      </pre>
+
+      <p class="block">
+        Once you&apos;ve done all that, the web app must be restarted.
+        This may happen automatically depending on your setup.
+        Test the view status below.
+      </p>
+
+      <div class="card block">
+        <header class="card-header">
+          <p class="card-header-title">
+            View Status
+          </p>
+        </header>
+        <div class="card-content">
+          <div class="content">
+            <div class="level">
+              <div class="level-left">
+
+                <div class="level-item">
+                  <span v-if="!viewImportAttempted">
+                    check not yet attempted
+                  </span>
+                  <span v-if="viewImported"
+                        class="has-text-success has-text-weight-bold">
+                    route found!
+                  </span>
+                  <span v-if="viewImportAttempted && viewImportProblem"
+                        class="has-text-danger">
+                    {{ viewImportProblem }}
+                  </span>
+                </div>
+              </div>
+              <div class="level-right">
+                <div class="level-item">
+                  <b-field horizontal label="Route Prefix">
+                    <b-input v-model="viewRoutePrefix"></b-input>
+                  </b-field>
+                </div>
+                <div class="level-item">
+                  <b-button type="is-primary"
+                            icon-pack="fas"
+                            icon-left="redo"
+                            @click="testView()">
+                    Test View
+                  </b-button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="activeStep = 'write-view'">
+          Back
+        </b-button>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="check"
+                  @click="activeStep = 'commit-code'"
+                  :disabled="!viewImported">
+          View class looks good!
+        </b-button>
+        <b-button icon-pack="fas"
+                  icon-left="arrow-right"
+                  @click="activeStep = 'commit-code'">
+          Skip
+        </b-button>
+      </div>
+    </b-step-item>
+
+    <b-step-item step="4"
+                 value="commit-code"
+                 label="Commit Code">
+      <h3 class="is-size-3 block">
+        Commit Code
+      </h3>
+
+      <p class="block">
+        Hope you're having a great day.
+      </p>
+
+      <p class="block">
+        Don't forget to commit code changes to your source repo.
+      </p>
+
+      <div class="buttons">
+        <b-button icon-pack="fas"
+                  icon-left="arrow-left"
+                  @click="activeStep = 'review-view'">
+          Back
+        </b-button>
+        <once-button type="is-primary"
+                     tag="a" :href="viewURL"
+                     icon-left="arrow-right"
+                     :disabled="!viewURL"
+                     :text="`Show me my new view: ${'$'}{viewClassName}`">
+        </once-button>
+      </div>
+    </b-step-item>
+
+  </b-steps>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.activeStep = 'enter-details'
+
+    ThisPageData.modelNames = ${json.dumps(model_names)|n}
+    ThisPageData.modelName = null
+    ThisPageData.viewClassName = null
+    ThisPageData.viewRoutePrefix = null
+
+    ThisPage.watch.modelName = function(newName, oldName) {
+        this.viewClassName = `${'$'}{newName}View`
+        this.viewRoutePrefix = newName.toLowerCase()
+    }
+
+    ThisPage.mounted = function() {
+        let params = new URLSearchParams(location.search)
+        if (params.has('model_name')) {
+            this.modelName = params.get('model_name')
+        }
+    }
+
+    ThisPageData.viewFile = '${view_dir}widgets.py'
+    ThisPageData.viewFileOverwrite = false
+    ThisPageData.writingViewFile = false
+
+    ThisPage.methods.writeViewFile = function() {
+        this.writingViewFile = true
+
+        let url = '${url('{}.write_view_file'.format(route_prefix))}'
+        let params = {
+            view_file: this.viewFile,
+            overwrite: this.viewFileOverwrite,
+            view_class_name: this.viewClassName,
+            model_name: this.modelName,
+            route_prefix: this.viewRoutePrefix,
+        }
+        this.submitForm(url, params, response => {
+            this.writingViewFile = false
+            this.activeStep = 'review-view'
+        }, response => {
+            this.writingViewFile = false
+        })
+    }
+
+    ThisPageData.viewImported = false
+    ThisPageData.viewImportAttempted = false
+    ThisPageData.viewImportProblem = null
+
+    ThisPage.methods.testView = function() {
+
+        this.viewImported = false
+        this.viewImportProblem = null
+
+        let url = '${url('{}.check_view'.format(route_prefix))}'
+
+        let params = {
+            route_prefix: this.viewRoutePrefix,
+        }
+        this.submitForm(url, params, response => {
+            this.viewImportAttempted = true
+            if (response.data.problem) {
+                this.viewImportProblem = response.data.problem
+            } else {
+                this.viewImported = true
+                this.viewURL = response.data.url
+            }
+        })
+    }
+
+    ThisPageData.viewURL = null
+
+  </script>
+</%def>
diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako
new file mode 100644
index 00000000..432e011d
--- /dev/null
+++ b/tailbone/templates/workorders/view.mako
@@ -0,0 +1,218 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+## TODO: what was this about?
+<%def name="content_title()">
+  ## ${instance_title}
+  #${instance.id} for ${instance.customer} (${enum.WORKORDER_STATUS[instance.status_code]})
+</%def>
+
+<%def name="object_helpers()">
+  % if instance.status_code not in (enum.WORKORDER_STATUS_DELIVERED, enum.WORKORDER_STATUS_CANCELED):
+      ${self.render_workflow_helper()}
+  % endif
+</%def>
+
+<%def name="render_workflow_helper()">
+  <nav class="panel">
+    <p class="panel-heading">Workflow</p>
+
+    % if instance.status_code == enum.WORKORDER_STATUS_SUBMITTED:
+        <div class="panel-block">
+          <div class="buttons">
+            ${h.form(url('{}.receive'.format(route_prefix), uuid=instance.uuid), ref='receiveForm')}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-right"
+                      @click="receive()"
+                      :disabled="receiveButtonDisabled">
+              {{ receiveButtonText }}
+            </b-button>
+            ${h.end_form()}
+          </div>
+        </div>
+    % endif
+
+    % if instance.status_code == enum.WORKORDER_STATUS_RECEIVED:
+        <div class="panel-block">
+          <div class="buttons">
+            ${h.form(url('{}.await_estimate'.format(route_prefix), uuid=instance.uuid), ref='awaitEstimateForm')}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-right"
+                      @click="awaitEstimate()"
+                      :disabled="awaitEstimateButtonDisabled">
+              {{ awaitEstimateButtonText }}
+            </b-button>
+            ${h.end_form()}
+          </div>
+        </div>
+    % endif
+
+    % if instance.status_code in (enum.WORKORDER_STATUS_RECEIVED, enum.WORKORDER_STATUS_PENDING_ESTIMATE):
+        <div class="panel-block">
+          <div class="buttons">
+            ${h.form(url('{}.await_parts'.format(route_prefix), uuid=instance.uuid), ref='awaitPartsForm')}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-right"
+                      @click="awaitParts()"
+                      :disabled="awaitPartsButtonDisabled">
+              {{ awaitPartsButtonText }}
+            </b-button>
+            ${h.end_form()}
+          </div>
+        </div>
+    % endif
+
+    % if instance.status_code in (enum.WORKORDER_STATUS_RECEIVED, enum.WORKORDER_STATUS_PENDING_ESTIMATE, enum.WORKORDER_STATUS_WAITING_FOR_PARTS):
+        <div class="panel-block">
+          <div class="buttons">
+            ${h.form(url('{}.work_on_it'.format(route_prefix), uuid=instance.uuid), ref='workOnItForm')}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-right"
+                      @click="workOnIt()"
+                      :disabled="workOnItButtonDisabled">
+              {{ workOnItButtonText }}
+            </b-button>
+            ${h.end_form()}
+          </div>
+        </div>
+    % endif
+
+    % if instance.status_code == enum.WORKORDER_STATUS_WORKING_ON_IT:
+        <div class="panel-block">
+          <div class="buttons">
+            ${h.form(url('{}.release'.format(route_prefix), uuid=instance.uuid), ref='releaseForm')}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-right"
+                      @click="release()"
+                      :disabled="releaseButtonDisabled">
+              {{ releaseButtonText }}
+            </b-button>
+            ${h.end_form()}
+          </div>
+        </div>
+    % endif
+
+    % if instance.status_code == enum.WORKORDER_STATUS_RELEASED:
+        <div class="panel-block">
+          <div class="buttons">
+            ${h.form(url('{}.deliver'.format(route_prefix), uuid=instance.uuid), ref='deliverForm')}
+            ${h.csrf_token(request)}
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="arrow-right"
+                      @click="deliver()"
+                      :disabled="deliverButtonDisabled">
+              {{ deliverButtonText }}
+            </b-button>
+            ${h.end_form()}
+          </div>
+        </div>
+    % endif
+
+    % if instance.status_code not in (enum.WORKORDER_STATUS_DELIVERED, enum.WORKORDER_STATUS_CANCELED):
+        <div class="panel-block">
+          <p class="is-italic has-text-centered"
+             style="width: 100%;">
+            OR
+          </p>
+        </div>
+        <div class="panel-block">
+          <div class="buttons">
+            ${h.form(url('{}.cancel'.format(route_prefix), uuid=instance.uuid), ref='cancelForm')}
+            ${h.csrf_token(request)}
+            <b-button type="is-warning"
+                      icon-pack="fas"
+                      icon-left="ban"
+                      @click="confirmCancel()"
+                      :disabled="cancelButtonDisabled">
+              {{ cancelButtonText }}
+            </b-button>
+            ${h.end_form()}
+          </div>
+        </div>
+    % endif
+
+  </nav>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.receiveButtonDisabled = false
+    ThisPageData.receiveButtonText = "I've received the order from customer"
+
+    ThisPageData.awaitEstimateButtonDisabled = false
+    ThisPageData.awaitEstimateButtonText = "I'm waiting for estimate confirmation"
+
+    ThisPageData.awaitPartsButtonDisabled = false
+    ThisPageData.awaitPartsButtonText = "I'm waiting for parts"
+
+    ThisPageData.workOnItButtonDisabled = false
+    ThisPageData.workOnItButtonText = "I'm working on it"
+
+    ThisPageData.releaseButtonDisabled = false
+    ThisPageData.releaseButtonText = "I've sent this back to customer"
+
+    ThisPageData.deliverButtonDisabled = false
+    ThisPageData.deliverButtonText = "Customer has the completed order!"
+
+    ThisPageData.cancelButtonDisabled = false
+    ThisPageData.cancelButtonText = "Cancel this Work Order"
+
+    ThisPage.methods.receive = function() {
+        this.receiveButtonDisabled = true
+        this.receiveButtonText = "Working, please wait..."
+        this.$refs.receiveForm.submit()
+    }
+
+    ThisPage.methods.awaitEstimate = function() {
+        this.awaitEstimateButtonDisabled = true
+        this.awaitEstimateButtonText = "Working, please wait..."
+        this.$refs.awaitEstimateForm.submit()
+    }
+
+    ThisPage.methods.awaitParts = function() {
+        this.awaitPartsButtonDisabled = true
+        this.awaitPartsButtonText = "Working, please wait..."
+        this.$refs.awaitPartsForm.submit()
+    }
+
+    ThisPage.methods.workOnIt = function() {
+        this.workOnItButtonDisabled = true
+        this.workOnItButtonText = "Working, please wait..."
+        this.$refs.workOnItForm.submit()
+    }
+
+    ThisPage.methods.release = function() {
+        this.releaseButtonDisabled = true
+        this.releaseButtonText = "Working, please wait..."
+        this.$refs.releaseForm.submit()
+    }
+
+    ThisPage.methods.deliver = function() {
+        this.deliverButtonDisabled = true
+        this.deliverButtonText = "Working, please wait..."
+        this.$refs.deliverForm.submit()
+    }
+
+    ThisPage.methods.confirmCancel = function() {
+        if (confirm("Are you sure you wish to cancel this Work Order?")) {
+            this.cancelButtonDisabled = true
+            this.cancelButtonText = "Working, please wait..."
+            this.$refs.cancelForm.submit()
+        }
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/tweens.py b/tailbone/tweens.py
index f944a66f..9c06c1be 100644
--- a/tailbone/tweens.py
+++ b/tailbone/tweens.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,6 @@
 Tween Factories
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 from sqlalchemy.exc import OperationalError
 
 
@@ -64,7 +61,7 @@ def sqlerror_tween_factory(handler, registry):
                     mark_error_retryable(error)
                     raise error
                 else:
-                    raise TransientError(six.text_type(error))
+                    raise TransientError(str(error))
 
             # if connection was *not* invalid, raise original error
             raise
diff --git a/tailbone/util.py b/tailbone/util.py
index 08ffd4cd..71aa35e3 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,59 +24,112 @@
 Utilities
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
+import importlib
+import logging
+import warnings
 
-import six
-import pytz
 import humanize
+import markdown
 
-from rattail.time import timezone, make_utc
 from rattail.files import resource_path
 
 import colander
 from pyramid.renderers import get_renderer
+from pyramid.interfaces import IRoutesMapper
 from webhelpers2.html import HTML, tags
 
+from wuttaweb.util import (get_form_data as wutta_get_form_data,
+                           get_libver as wutta_get_libver,
+                           get_liburl as wutta_get_liburl,
+                           get_csrf_token as wutta_get_csrf_token,
+                           render_csrf_token)
+
+
+log = logging.getLogger(__name__)
+
+
+class SortColumn(object):
+    """
+    Generic representation of a sort column, for use with sorting grid
+    data as well as with API.
+    """
+
+    def __init__(self, field_name, model_name=None):
+        self.field_name = field_name
+        self.model_name = model_name
+
 
 def get_csrf_token(request):
-    """
-    Convenience function to retrieve the effective CSRF token for the given
-    request.
-    """
-    token = request.session.get_csrf_token()
-    if token is None:
-        token = request.session.new_csrf_token()
-    return token
+    """ """
+    warnings.warn("tailbone.util.get_csrf_token() is deprecated; "
+                  "please use wuttaweb.util.get_csrf_token() instead",
+                  DeprecationWarning, stacklevel=2)
+    return wutta_get_csrf_token(request)
 
 
 def csrf_token(request, name='_csrf'):
-    """
-    Convenience function. Returns CSRF hidden tag inside hidden DIV.
-    """
-    token = get_csrf_token(request)
-    return HTML.tag("div", tags.hidden(name, value=token), style="display:none;")
+    """ """
+    warnings.warn("tailbone.util.csrf_token() is deprecated; "
+                  "please use wuttaweb.util.render_csrf_token() instead",
+                  DeprecationWarning, stacklevel=2)
+    return render_csrf_token(request, name=name)
 
 
-def should_use_buefy(request):
+def get_form_data(request):
     """
-    Returns a flag indicating whether or not the current theme supports (and
-    therefore should use) the Buefy JS library.
+    DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()`
+    instead.
     """
-    # first check theme-specific setting, if one has been defined
-    theme = request.registry.settings['tailbone.theme']
-    buefy = request.rattail_config.getbool('tailbone', 'themes.{}.use_buefy'.format(theme))
-    if buefy is not None:
-        return buefy
+    warnings.warn("tailbone.util.get_form_data() is deprecated; "
+                  "please use wuttaweb.util.get_form_data() instead",
+                  DeprecationWarning, stacklevel=2)
+    return wutta_get_form_data(request)
 
-    # TODO: should not hard-code this surely, but works for now...
-    if theme == 'falafel':
-        return True
 
-    # TODO: probably should not use this fallback? it was the first setting
-    # i tested with, but is poorly named to say the least
-    return request.rattail_config.getbool('tailbone', 'grids.use_buefy', default=False)
+def get_global_search_options(request):
+    """
+    Returns global search options for current request.  Basically a
+    list of all "index views" minus the ones they aren't allowed to
+    access.
+    """
+    options = []
+    pages = sorted(request.registry.settings['tailbone_index_pages'],
+                   key=lambda page: page['label'])
+    for page in pages:
+        if not page['permission'] or request.has_perm(page['permission']):
+            option = dict(page)
+            option['url'] = request.route_url(page['route'])
+            options.append(option)
+    return options
+
+
+def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover
+    """
+    DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()`
+    instead.
+    """
+    warnings.warn("tailbone.util.get_libver() is deprecated; "
+                  "please use wuttaweb.util.get_libver() instead",
+                  DeprecationWarning, stacklevel=2)
+
+    return wutta_get_libver(request, key, prefix='tailbone',
+                            configured_only=not fallback,
+                            default_only=default_only)
+
+
+def get_liburl(request, key, fallback=True): # pragma: no cover
+    """
+    DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()`
+    instead.
+    """
+    warnings.warn("tailbone.util.get_liburl() is deprecated; "
+                  "please use wuttaweb.util.get_liburl() instead",
+                  DeprecationWarning, stacklevel=2)
+
+    return wutta_get_liburl(request, key, prefix='tailbone',
+                            configured_only=not fallback,
+                            default_only=False)
 
 
 def pretty_datetime(config, value):
@@ -92,16 +145,18 @@ def pretty_datetime(config, value):
     if not value:
         return ''
 
+    app = config.get_app()
+
     # Make sure we're dealing with a tz-aware value.  If we're given a naive
     # value, we assume it to be local to the UTC timezone.
     if not value.tzinfo:
-        value = pytz.utc.localize(value)
+        value = app.make_utc(value, tzinfo=True)
 
     # Calculate time diff using UTC.
-    time_ago = datetime.datetime.utcnow() - make_utc(value)
+    time_ago = datetime.datetime.utcnow() - app.make_utc(value)
 
     # Convert value to local timezone.
-    local = timezone(config)
+    local = app.get_timezone()
     value = local.normalize(value.astimezone(local))
 
     return HTML.tag('span',
@@ -122,16 +177,18 @@ def raw_datetime(config, value, verbose=False, as_date=False):
     if not value:
         return ''
 
+    app = config.get_app()
+
     # Make sure we're dealing with a tz-aware value.  If we're given a naive
     # value, we assume it to be local to the UTC timezone.
     if not value.tzinfo:
-        value = pytz.utc.localize(value)
+        value = app.make_utc(value, tzinfo=True)
 
     # Calculate time diff using UTC.
-    time_ago = datetime.datetime.utcnow() - make_utc(value)
+    time_ago = datetime.datetime.utcnow() - app.make_utc(value)
 
     # Convert value to local timezone.
-    local = timezone(config)
+    local = app.get_timezone()
     value = local.normalize(value.astimezone(local))
 
     kwargs = {}
@@ -143,12 +200,10 @@ def raw_datetime(config, value, verbose=False, as_date=False):
         else:
             kwargs['c'] = value.strftime('%Y-%m-%d %I:%M:%S %p')
     else:
-        kwargs['c'] = six.text_type(value)
+        kwargs['c'] = str(value)
 
-    # avoid humanize error when calculating huge time diff
-    time_diff = None
-    if abs(time_ago.days) < 100000:
-        time_diff = humanize.naturaltime(time_ago)
+    time_diff = app.render_time_ago(time_ago, fallback=None)
+    if time_diff is not None:
 
         # by "verbose" we mean the result text to look like "YYYY-MM-DD (X days ago)"
         if verbose:
@@ -161,6 +216,18 @@ def raw_datetime(config, value, verbose=False, as_date=False):
     return HTML.tag('span', **kwargs)
 
 
+def render_markdown(text, raw=False, **kwargs):
+    """
+    Render the given markdown text as HTML.
+    """
+    kwargs.setdefault('extensions', ['fenced_code', 'codehilite'])
+    md = markdown.markdown(text, **kwargs)
+    if raw:
+        return md
+    md = HTML.literal(md)
+    return HTML.tag('div', class_='rendered-markdown', c=[md])
+
+
 def set_app_theme(request, theme, session=None):
     """
     Set the app theme.  This modifies the *global* Mako template lookup
@@ -169,8 +236,6 @@ def set_app_theme(request, theme, session=None):
     This also saves the setting for the new theme, and updates the running app
     registry settings with the new theme.
     """
-    from rattail.db import api
-
     theme = get_effective_theme(request.rattail_config, theme=theme, session=session)
     theme_path = get_theme_template_path(request.rattail_config, theme=theme, session=session)
 
@@ -185,7 +250,16 @@ def set_app_theme(request, theme, session=None):
     # clear template cache for lookup object, so it will reload each (as needed)
     lookup._collection.clear()
 
-    api.save_setting(session, 'tailbone.theme', theme)
+    app = request.rattail_config.get_app()
+    close = False
+    if not session:
+        session = app.make_session()
+        close = True
+    app.save_setting(session, 'tailbone.theme', theme)
+    if close:
+        session.commit()
+        session.close()
+
     request.registry.settings['tailbone.theme'] = theme
 
 
@@ -199,26 +273,77 @@ def get_theme_template_path(rattail_config, theme=None, session=None):
     return resource_path(theme_path)
 
 
+def get_available_themes(rattail_config, include=None):
+    """
+    Returns a list of theme names which are available.  If config does
+    not specify, some defaults will be assumed.
+    """
+    # get available list from config, if it has one
+    available = rattail_config.getlist('tailbone', 'themes.keys')
+    if not available:
+        available = rattail_config.getlist('tailbone', 'themes',
+                                           ignore_ambiguous=True)
+        if available:
+            warnings.warn("URGENT: instead of 'tailbone.themes', "
+                          "you should set 'tailbone.themes.keys'",
+                          DeprecationWarning, stacklevel=2)
+        else:
+            available = []
+
+    # include any themes specified by caller
+    if include is not None:
+        for theme in include:
+            if theme not in available:
+                available.append(theme)
+
+    # sort the list by name
+    available.sort()
+
+    # make default theme the first option
+    i = available.index('default')
+    if i >= 0:
+        available.pop(i)
+    available.insert(0, 'default')
+
+    return available
+
+
 def get_effective_theme(rattail_config, theme=None, session=None):
     """
     Validates and returns the "effective" theme.  If you provide a theme, that
     will be used; otherwise it is read from database setting.
     """
-    from rattail.db import api
+    app = rattail_config.get_app()
 
     if not theme:
-        theme = api.get_setting(session, 'tailbone.theme') or 'default'
+        close = False
+        if not session:
+            session = app.make_session()
+            close = True
+        theme = app.get_setting(session, 'tailbone.theme') or 'default'
+        if close:
+            session.close()
 
     # confirm requested theme is available
-    available = rattail_config.getlist('tailbone', 'themes',
-                                       default=['bobcat'])
-    available.append('default')
+    available = get_available_themes(rattail_config)
     if theme not in available:
         raise ValueError("theme not available: {}".format(theme))
 
     return theme
 
 
+def should_use_oruga(request):
+    """
+    Returns a flag indicating whether or not the current theme
+    supports (and therefore should use) Oruga + Vue 3 as opposed to
+    the default of Buefy + Vue 2.
+    """
+    theme = request.registry.settings.get('tailbone.theme')
+    if theme and 'butterball' in theme:
+        return True
+    return False
+
+
 def validate_email_address(address):
     """
     Perform basic validation on the given email address.  This leverages the
@@ -239,3 +364,38 @@ def email_address_is_valid(address):
     except colander.Invalid:
         return False
     return True
+
+
+def route_exists(request, route_name):
+    """
+    Checks for existence of the given route name, within the running app
+    config.  Returns boolean indicating whether it exists.
+    """
+    reg = request.registry
+    mapper = reg.getUtility(IRoutesMapper)
+    route = mapper.get_route(route_name)
+    return bool(route)
+
+
+def include_configured_views(pyramid_config):
+    """
+    Include arbitrary additional views based on DB settings.
+    """
+    rattail_config = pyramid_config.registry.settings.get('rattail_config')
+    app = rattail_config.get_app()
+    model = app.model
+    session = app.make_session()
+
+    # fetch all include-related settings at once
+    settings = session.query(model.Setting)\
+                      .filter(model.Setting.name.like('tailbone.includes.%'))\
+                      .all()
+
+    for setting in settings:
+        if setting.value:
+            try:
+                pyramid_config.include(setting.value)
+            except:
+                log.warning("pyramid failed to include: %s", exc_info=True)
+
+    session.close()
diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py
index 135e45b9..29c73b61 100644
--- a/tailbone/views/__init__.py
+++ b/tailbone/views/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,10 +27,7 @@ Pyramid Views
 from __future__ import unicode_literals, absolute_import
 
 from .core import View
-from .master import MasterView
-
-# TODO: deprecate / remove some of this
-from .autocomplete import AutocompleteView
+from .master import MasterView, ViewSupplement
 
 
 def includeme(config):
diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py
new file mode 100644
index 00000000..33888654
--- /dev/null
+++ b/tailbone/views/asgi/__init__.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+ASGI Views
+"""
+
+from http.cookies import SimpleCookie
+
+from beaker.session import SignedCookie
+from pyramid.interfaces import ISessionFactory
+
+
+class MockRequest(dict):
+    """
+    Fake request class, needed for re-construction of the user's web
+    session.
+    """
+    environ = {}
+
+    def add_response_callback(self, func):
+        pass
+
+
+class WebsocketView:
+
+    def __init__(self, pyramid_config):
+        self.pyramid_config = pyramid_config
+        self.registry = self.pyramid_config.registry
+        app = self.get_rattail_app()
+        self.model = app.model
+
+    @property
+    def rattail_config(self):
+        return self.registry['rattail_config']
+
+    def get_rattail_app(self):
+        return self.rattail_config.get_app()
+
+    async def authorize(self, scope, receive, send, permission):
+
+        # is user authorized for this socket?
+        authorized = await self.has_permission(scope, permission)
+
+        # wait for client to connect
+        message = await receive()
+        assert message['type'] == 'websocket.connect'
+
+        # allow or deny access, per authorization
+        if authorized:
+            await send({'type': 'websocket.accept'})
+        else: # forbidden
+            await send({'type': 'websocket.close'})
+
+        return authorized
+
+    async def get_user(self, scope, session=None):
+        app = self.get_rattail_app()
+        model = self.model
+
+        # load the user's web session
+        user_session = self.get_user_session(scope)
+        if user_session:
+
+            # determine user uuid
+            user_uuid = user_session.get('auth.userid')
+            if user_uuid:
+
+                # use given db session, or make a new one
+                with app.short_session(config=self.rattail_config,
+                                       session=session) as s:
+
+                    # load user proper
+                    return s.get(model.User, user_uuid)
+
+    def get_user_session(self, scope):
+        settings = self.registry.settings
+        beaker_key = settings['beaker.session.key']
+        beaker_secret = settings['beaker.session.secret']
+
+        # get ahold of session identifier cookie
+        headers = dict(scope['headers'])
+        cookie = headers.get(b'cookie')
+        if not cookie:
+            return
+        cookie = cookie.decode('utf_8')
+        cookie = SimpleCookie(cookie)
+        morsel = cookie[beaker_key]
+
+        # simulate pyramid_beaker logic to get at the actual session
+        cookieheader = morsel.output(header='')
+        cookie = SignedCookie(beaker_secret, input=cookieheader)
+        session_id = cookie[beaker_key].value
+        factory = self.registry.queryUtility(ISessionFactory)
+        request = MockRequest()
+        # nb. cannot pass 'id' to our factory, but things still work
+        # if we assign it immediately, before load() is called
+        session = factory(request)
+        session.id = session_id
+        session.load()
+
+        return session
+
+    async def has_permission(self, scope, permission):
+        app = self.get_rattail_app()
+        auth_handler = app.get_auth_handler()
+
+        # figure out if user is authorized for this websocket
+        session = app.make_session()
+        user = await self.get_user(scope, session=session)
+        authorized = auth_handler.has_permission(session, user, permission)
+        session.close()
+
+        return authorized
diff --git a/tailbone/views/asgi/datasync.py b/tailbone/views/asgi/datasync.py
new file mode 100644
index 00000000..2dec06ea
--- /dev/null
+++ b/tailbone/views/asgi/datasync.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+DataSync Views
+"""
+
+import asyncio
+import json
+
+from tailbone.views.asgi import WebsocketView
+
+
+class DatasyncWS(WebsocketView):
+
+    async def status(self, scope, receive, send):
+        app = self.get_rattail_app()
+        datasync_handler = app.get_datasync_handler()
+
+        # is user allowed to see this?
+        if not await self.authorize(scope, receive, send, 'datasync.status'):
+            return
+
+        # this tracks when client disconnects
+        state = {'disconnected': False}
+
+        async def wait_for_disconnect():
+            message = await receive()
+            if message['type'] == 'websocket.disconnect':
+                state['disconnected'] = True
+
+        # watch for client disconnect, while we do other things
+        asyncio.create_task(wait_for_disconnect())
+
+        # do the rest forever, until client disconnects
+        while not state['disconnected']:
+
+            # give client latest supervisor process info
+            info = datasync_handler.get_supervisor_process_info()
+            await send({'type': 'websocket.send',
+                        'subtype': 'datasync.supervisor_process_info',
+                        'text': json.dumps(info)})
+
+            # pause for 1 second
+            await asyncio.sleep(1)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+
+    @classmethod
+    def _defaults(cls, config):
+
+        # status
+        config.add_tailbone_websocket('datasync.status',
+                                      cls, attr='status')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    DatasyncWS = kwargs.get('DatasyncWS', base['DatasyncWS'])
+    DatasyncWS.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/asgi/upgrades.py b/tailbone/views/asgi/upgrades.py
new file mode 100644
index 00000000..13458f23
--- /dev/null
+++ b/tailbone/views/asgi/upgrades.py
@@ -0,0 +1,233 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Upgrade Views for ASGI
+"""
+
+import asyncio
+import json
+import os
+from urllib.parse import parse_qs
+
+from tailbone.views.asgi import WebsocketView
+from tailbone.progress import get_basic_session
+
+
+class UpgradeExecutionProgressWS(WebsocketView):
+
+    # keep track of all "global" state for this socket
+    global_state = {
+        'upgrades': {},
+    }
+
+    new_messages = asyncio.Queue()
+
+    async def __call__(self, scope, receive, send):
+        app = self.get_rattail_app()
+
+        # is user allowed to see this?
+        if not await self.authorize(scope, receive, send, 'upgrades.execute'):
+            return
+
+        # keep track of client state
+        client_state = {
+            'uuid': app.make_uuid(),
+            'disconnected': False,
+            'scope': scope,
+            'receive': receive,
+            'send': send,
+        }
+
+        # parse upgrade uuid from query string
+        query = scope['query_string'].decode('utf_8')
+        query = parse_qs(query)
+        uuid = query['uuid'][0]
+
+        # first client to request progress for this upgrade, must
+        # start a task to manage the collect/transmit logic for
+        # progress data, on behalf of this and/or any future clients
+        started_task = None
+        if uuid not in self.global_state['upgrades']:
+
+            # this upgrade is new to us; establish state and add first client
+            upgrade_state = self.global_state['upgrades'][uuid] = {
+                'clients': {client_state['uuid']: client_state},
+            }
+
+            # start task for transmit of progress data to all clients
+            started_task = asyncio.create_task(self.manage_progress(uuid))
+
+        else:
+
+            # progress task is already running, just add new client
+            upgrade_state = self.global_state['upgrades'][uuid]
+            upgrade_state['clients'][client_state['uuid']] = client_state
+
+        async def wait_for_disconnect():
+            message = await receive()
+            if message['type'] == 'websocket.disconnect':
+                client_state['disconnected'] = True
+
+        # wait forever, until client disconnects
+        asyncio.create_task(wait_for_disconnect())
+        while not client_state['disconnected']:
+
+            # can stop if upgrade has completed
+            if uuid not in self.global_state['upgrades']:
+                break
+
+            await asyncio.sleep(0.1)
+
+        # remove client from global set, if upgrade still running
+        if client_state['disconnected']:
+            upgrade_state = self.global_state['upgrades'].get(uuid)
+            if upgrade_state:
+                del upgrade_state['clients'][client_state['uuid']]
+
+        # must continue to wait for other clients, if this client was
+        # the first to request progress
+        if started_task:
+            await started_task
+
+    async def manage_progress(self, uuid):
+        """
+        Task which handles collect / transmit of progress data, for
+        sake of all attached clients.
+        """
+        progress_session_id = 'upgrades.{}.execution_progress'.format(uuid)
+        progress_session = get_basic_session(self.rattail_config,
+                                             id=progress_session_id)
+
+        # start collecting status, textout messages
+        asyncio.create_task(self.collect_status(uuid, progress_session))
+        asyncio.create_task(self.collect_textout(uuid))
+
+        upgrade_state = self.global_state['upgrades'][uuid]
+        clients = upgrade_state['clients']
+        while clients:
+
+            msg = await self.new_messages.get()
+
+            # send message to all clients
+            for client in clients.values():
+                await client['send']({
+                    'type': 'websocket.send',
+                    'subtype': 'upgrades.execute_progress',
+                    'text': json.dumps(msg)})
+
+            await asyncio.sleep(0.1)
+
+        # no more clients, no more reason to track this upgrade
+        del self.global_state['upgrades'][uuid]
+
+    async def collect_status(self, uuid, progress_session):
+
+        upgrade_state = self.global_state['upgrades'][uuid]
+        clients = upgrade_state['clients']
+        while True:
+
+            # load latest progress data
+            progress_session.load()
+
+            # when upgrade progress is complete...
+            if progress_session.get('complete'):
+
+                # maybe set success flash msg (for all clients)
+                msg = progress_session.get('success_msg')
+                if msg:
+                    for client in clients.values():
+                        user_session = self.get_user_session(client['scope'])
+                        user_session.flash(msg)
+                        user_session.persist()
+
+                # push "complete" message to queue
+                await self.new_messages.put({'complete': True})
+
+                # there will be no more status coming
+                break
+
+            await asyncio.sleep(0.1)
+
+    async def collect_textout(self, uuid):
+        path = self.rattail_config.upgrade_filepath(uuid, filename='stdout.log')
+
+        # wait until stdout file exists
+        while not os.path.exists(path):
+
+            # bail if upgrade is complete
+            if uuid not in self.global_state['upgrades']:
+                return
+
+            await asyncio.sleep(0.1)
+
+        offset = 0
+        while True:
+
+            # wait until we have something new to read
+            size = os.path.getsize(path) - offset
+            while not size:
+
+                # bail if upgrade is complete
+                if uuid not in self.global_state['upgrades']:
+                    return
+
+                # wait a whole second, then look again
+                # (the less frequent we look, the bigger the chunk)
+                await asyncio.sleep(1)
+                size = os.path.getsize(path) - offset
+
+            # bail if upgrade is complete
+            if uuid not in self.global_state['upgrades']:
+                return
+
+            # read the latest chunk and bookmark new offset
+            with open(path, 'rb') as f:
+                f.seek(offset)
+                chunk = f.read(size)
+                textout = chunk.decode('utf_8')
+            offset += size
+
+            # push new chunk onto message queue
+            textout = textout.replace('\n', '<br />')
+            await self.new_messages.put({'stdout': textout})
+
+            await asyncio.sleep(0.1)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+
+    @classmethod
+    def _defaults(cls, config):
+        config.add_tailbone_websocket('upgrades.execution_progress', cls)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    UpgradeExecutionProgressWS = kwargs.get('UpgradeExecutionProgressWS', base['UpgradeExecutionProgressWS'])
+    UpgradeExecutionProgressWS.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index 4765e8e8..eceab803 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Auth Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-from rattail.db.auth import authenticate_user, set_user_password
-
 import colander
 from deform import widget as dfwidget
 from pyramid.httpexceptions import HTTPForbidden
@@ -37,6 +33,7 @@ from tailbone import forms
 from tailbone.db import Session
 from tailbone.views import View
 from tailbone.auth import login_user, logout_user
+from tailbone.config import global_help_url
 
 
 class UserLogin(colander.MappingSchema):
@@ -47,25 +44,6 @@ class UserLogin(colander.MappingSchema):
                                    widget=dfwidget.PasswordWidget())
 
 
-@colander.deferred
-def current_password_correct(node, kw):
-    user = kw['user']
-    def validate(node, value):
-        if not authenticate_user(Session(), user.username, value):
-            raise colander.Invalid(node, "The password is incorrect")
-    return validate
-
-
-class ChangePassword(colander.MappingSchema):
-
-    current_password = colander.SchemaNode(colander.String(),
-                                           widget=dfwidget.PasswordWidget(),
-                                           validator=current_password_correct)
-
-    new_password = colander.SchemaNode(colander.String(),
-                                       widget=dfwidget.CheckedPasswordWidget())
-
-
 class AuthenticationView(View):
 
     def forbidden(self):
@@ -83,30 +61,27 @@ class AuthenticationView(View):
             # Store current URL in session, for smarter redirect after login.
             self.request.session['next_url'] = self.request.current_route_url()
             next_url = self.request.route_url('login')
-        self.request.session.flash(msg, allow_duplicate=False)
+        self.request.session.flash(msg, 'warning', allow_duplicate=False)
         return self.redirect(next_url)
 
-    def login(self, mobile=False):
+    def login(self, **kwargs):
         """
         The login view, responsible for displaying and handling the login form.
         """
-        home = 'mobile.home' if mobile else 'home'
-        referrer = self.request.get_referrer(default=self.request.route_url(home))
+        app = self.get_rattail_app()
+        referrer = self.request.get_referrer(default=self.request.route_url('home'))
 
         # redirect if already logged in
         if self.request.user:
             self.request.session.flash("{} is already logged in".format(self.request.user), 'error')
             return self.redirect(referrer)
 
-        use_buefy = self.get_use_buefy()
-        form = forms.Form(schema=UserLogin(), request=self.request,
-                          use_buefy=use_buefy)
+        form = forms.Form(schema=UserLogin(), request=self.request)
         form.save_label = "Login"
-        form.auto_disable_save = False
-        form.auto_disable = False # TODO: deprecate / remove this
         form.show_reset = True
         form.show_cancel = False
-        if form.validate(newstyle=True):
+        form.button_icon_submit = 'user'
+        if form.validate():
             user = self.authenticate_user(form.validated['username'],
                                           form.validated['password'])
             if user:
@@ -119,24 +94,28 @@ class AuthenticationView(View):
             else:
                 self.request.session.flash("Invalid username or password", 'error')
 
-        image_url = self.rattail_config.get(
-            'tailbone', 'main_image_url',
-            default=self.request.static_url('tailbone:static/img/home_logo.png'))
+        # nb. hacky..but necessary, to add the refs, for autofocus
+        # (also add key handler, so ENTER acts like TAB)
+        dform = form.make_deform_form()
+        dform['username'].widget.attributes = {
+            'ref': 'username',
+            'autocomplete': 'off',
+        }
+        dform['password'].widget.attributes = {'ref': 'password'}
 
         return {
             'form': form,
             'referrer': referrer,
-            'image_url': image_url,
-            'use_buefy': use_buefy,
+            'index_title': app.get_node_title(),
+            'help_url': global_help_url(self.rattail_config),
         }
 
     def authenticate_user(self, username, password):
-        return authenticate_user(Session(), username, password)
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        return auth.authenticate_user(Session(), username, password)
 
-    def mobile_login(self):
-        return self.login(mobile=True)
-
-    def logout(self, mobile=False):
+    def logout(self, **kwargs):
         """
         View responsible for logging out the current user.
 
@@ -148,17 +127,12 @@ class AuthenticationView(View):
 
         # redirect to home page after login, if so configured
         if self.rattail_config.getbool('tailbone', 'home_after_logout', default=False):
-            home = 'mobile.home' if mobile else 'home'
-            return self.redirect(self.request.route_url(home), headers=headers)
+            return self.redirect(self.request.route_url('home'), headers=headers)
 
         # otherwise redirect to referrer, with 'login' page as fallback
-        login = 'mobile.login' if mobile else 'login'
-        referrer = self.request.get_referrer(default=self.request.route_url(login))
+        referrer = self.request.get_referrer(default=self.request.route_url('login'))
         return self.redirect(referrer, headers=headers)
 
-    def mobile_logout(self):
-        return self.logout(mobile=True)
-
     def noop(self):
         """
         View to serve as "no-op" / ping action to reset current user's session timer
@@ -172,15 +146,40 @@ class AuthenticationView(View):
         if not self.request.user:
             return self.redirect(self.request.route_url('home'))
 
-        use_buefy = self.get_use_buefy()
-        schema = ChangePassword().bind(user=self.request.user)
-        form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy)
-        if form.validate(newstyle=True):
-            set_user_password(self.request.user, form.validated['new_password'])
+        if ((self.request.user.prevent_password_change
+             or self.user_is_protected(self.request.user))
+            and not self.request.is_root):
+
+            self.request.session.flash("Cannot change password for user: {}".format(
+                self.request.user))
+            return self.redirect(self.request.get_referrer())
+
+        def check_user_password(node, value):
+            auth = self.app.get_auth_handler()
+            user = self.request.user
+            if not auth.check_user_password(user, value):
+                node.raise_invalid("The password is incorrect")
+
+        schema = colander.Schema()
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='current_password',
+                                       widget=dfwidget.PasswordWidget(),
+                                       validator=check_user_password))
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='new_password',
+                                       widget=dfwidget.CheckedPasswordWidget()))
+
+        form = forms.Form(schema=schema, request=self.request)
+        if form.validate():
+            auth = self.app.get_auth_handler()
+            auth.set_user_password(self.request.user, form.validated['new_password'])
             self.request.session.flash("Your password has been changed.")
             return self.redirect(self.request.get_referrer())
 
-        return {'form': form, 'use_buefy': use_buefy}
+        return {'index_title': str(self.request.user),
+                'form': form}
 
     def become_root(self):
         """
@@ -207,7 +206,6 @@ class AuthenticationView(View):
     @classmethod
     def defaults(cls, config):
         rattail_config = config.registry.settings.get('rattail_config')
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
 
         # forbidden
         config.add_forbidden_view(cls, attr='forbidden')
@@ -215,16 +213,10 @@ class AuthenticationView(View):
         # login
         config.add_route('login', '/login')
         config.add_view(cls, attr='login', route_name='login', renderer='/login.mako')
-        if legacy_mobile:
-            config.add_route('mobile.login', '/mobile/login')
-            config.add_view(cls, attr='mobile_login', route_name='mobile.login', renderer='/mobile/login.mako')
 
         # logout
         config.add_route('logout', '/logout')
         config.add_view(cls, attr='logout', route_name='logout')
-        if legacy_mobile:
-            config.add_route('mobile.logout', '/mobile/logout')
-            config.add_view(cls, attr='mobile_logout', route_name='mobile.logout')
 
         # no-op
         config.add_route('noop', '/noop')
@@ -235,11 +227,21 @@ class AuthenticationView(View):
         config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako')
 
         # become/stop root
+        # TODO: these should require POST but i won't bother until
+        # after butterball becomes default theme..or probably should
+        # just refactor the falafel theme accordingly..?
         config.add_route('become_root', '/root/yes')
         config.add_view(cls, attr='become_root', route_name='become_root')
         config.add_route('stop_root', '/root/no')
         config.add_view(cls, attr='stop_root', route_name='stop_root')
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView'])
     AuthenticationView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/autocomplete.py b/tailbone/views/autocomplete.py
deleted file mode 100644
index 96bbd36b..00000000
--- a/tailbone/views/autocomplete.py
+++ /dev/null
@@ -1,80 +0,0 @@
-# -*- coding: utf-8 -*-
-################################################################################
-#
-#  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
-#
-#  This file is part of Rattail.
-#
-#  Rattail is free software: you can redistribute it and/or modify it under the
-#  terms of the GNU General Public License as published by the Free Software
-#  Foundation, either version 3 of the License, or (at your option) any later
-#  version.
-#
-#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
-#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-#  details.
-#
-#  You should have received a copy of the GNU General Public License along with
-#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
-#
-################################################################################
-"""
-Autocomplete View
-"""
-
-from __future__ import unicode_literals, absolute_import
-
-from tailbone.views.core import View
-from tailbone.db import Session
-
-
-class AutocompleteView(View):
-    """
-    Base class for generic autocomplete views.
-    """
-
-    def prepare_term(self, term):
-        """
-        If necessary, massage the incoming search term for use with the query.
-        """
-        return term
-
-    def filter_query(self, q):
-        return q
-
-    def make_query(self, term):
-        q = Session.query(self.mapped_class)
-        q = self.filter_query(q)
-        q = q.filter(getattr(self.mapped_class, self.fieldname).ilike('%%%s%%' % term))
-        q = q.order_by(getattr(self.mapped_class, self.fieldname))
-        return q
-
-    def query(self, term):
-        return self.make_query(term)
-
-    def display(self, instance):
-        return getattr(instance, self.fieldname)
-
-    def value(self, instance):
-        """
-        Determine the data value for a query result instance.
-        """
-        return instance.uuid
-
-    def get_data(self, term):
-        return self.query(term).all()
-
-    def __call__(self):
-        """
-        View implementation.
-        """
-        term = self.request.params.get(u'term') or u''
-        term = term.strip()
-        if term:
-            term = self.prepare_term(term)
-        if not term:
-            return []
-        results = self.get_data(term)
-        return [{'label': self.display(x), 'value': self.value(x)} for x in results]
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index b5d0915b..c162b579 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Base views for maintaining "new-style" batches.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import sys
 import json
@@ -34,38 +32,30 @@ import logging
 import socket
 import subprocess
 import tempfile
-from six import StringIO
+import warnings
 
 import json
-import six
+import markdown
 import sqlalchemy as sa
 from sqlalchemy import orm
 
-from rattail.db import model, Session as RattailSession
-from rattail.db.util import short_session
 from rattail.threads import Thread
-from rattail.util import load_object, prettify, simple_error
-from rattail.progress import SocketProgress
+from rattail.util import simple_error
 
 import colander
-import deform
-from pyramid.renderers import render_to_response
-from pyramid.response import FileResponse
+from deform import widget as dfwidget
 from webhelpers2.html import HTML, tags
 
+from wuttaweb.util import render_csrf_token
+
 from tailbone import forms, grids
 from tailbone.db import Session
 from tailbone.views import MasterView
-from tailbone.util import csrf_token
 
 
 log = logging.getLogger(__name__)
 
 
-class EverythingComplete(Exception):
-    pass
-
-
 class BatchMasterView(MasterView):
     """
     Base class for all "batch master" views.
@@ -74,6 +64,8 @@ class BatchMasterView(MasterView):
     batch_handler_class = None
     has_rows = True
     rows_deletable = True
+    rows_deletable_if_executed = False
+    rows_bulk_deletable = True
     rows_downloadable_csv = True
     rows_downloadable_xlsx = True
     refreshable = True
@@ -82,10 +74,11 @@ class BatchMasterView(MasterView):
     executable = True
     results_refreshable = False
     results_executable = False
-    supports_mobile = True
-    mobile_filterable = True
-    mobile_rows_viewable = True
     has_worksheet = False
+    has_worksheet_file = False
+    delete_requires_progress = True
+
+    input_file_template_config_section = 'rattail.batch'
 
     grid_columns = [
         'id',
@@ -103,10 +96,11 @@ class BatchMasterView(MasterView):
         'id',
         'description',
         'notes',
-        'created',
-        'created_by',
+        'params',
         'rowcount',
         'status_code',
+        'created',
+        'created_by',
         'executed',
         'executed_by',
     ]
@@ -118,8 +112,10 @@ class BatchMasterView(MasterView):
     }
 
     def __init__(self, request):
-        super(BatchMasterView, self).__init__(request)
-        self.handler = self.get_handler()
+        super().__init__(request)
+        self.batch_handler = self.get_handler()
+        # TODO: deprecate / remove this (?)
+        self.handler = self.batch_handler
 
     @classmethod
     def get_handler_factory(cls, rattail_config):
@@ -140,12 +136,13 @@ class BatchMasterView(MasterView):
         ``batch_key`` attribute of the main batch model class.
         """
         # first try to figure out if config defines a factory class
+        app = rattail_config.get_app()
         model_class = cls.get_model_class()
         batch_key = model_class.batch_key
-        spec = rattail_config.get('rattail.batch', '{}.handler'.format(batch_key),
-                                  default=cls.default_handler_spec)
-        if spec: # yep, so use that
-            return load_object(spec)
+        handler = app.get_batch_handler(batch_key,
+                                        default=cls.default_handler_spec)
+        if handler:
+            return handler.__class__
 
         # fall back to whatever class was defined statically
         return cls.batch_handler_class
@@ -159,36 +156,112 @@ class BatchMasterView(MasterView):
         factory = self.get_handler_factory(self.rattail_config)
         return factory(self.rattail_config)
 
+    @property
+    def input_file_template_config_prefix(self):
+        return '{}.input_file_template'.format(self.batch_handler.batch_key)
+
     def download_path(self, batch, filename):
         return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename)
 
     def template_kwargs_view(self, **kwargs):
-        use_buefy = self.get_use_buefy()
+        kwargs = super().template_kwargs_view(**kwargs)
         batch = kwargs['instance']
         kwargs['batch'] = batch
         kwargs['handler'] = self.handler
+
+        if self.has_worksheet_file and self.allow_worksheet(batch) and self.has_perm('worksheet'):
+            kwargs['upload_worksheet_form'] = self.make_upload_worksheet_form(batch)
+
         kwargs['execute_title'] = self.get_execute_title(batch)
         kwargs['execute_enabled'] = self.instance_executable(batch)
-        if kwargs['mobile']:
-            if self.mobile_rows_creatable:
-                kwargs.setdefault('add_item_title', "Add Item")
-            if self.mobile_rows_quickable:
-                kwargs.setdefault('quick_entry_placeholder', "Enter {}".format(
-                    self.rattail_config.product_key_title()))
         if kwargs['execute_enabled']:
             url = self.get_action_url('execute', batch)
             kwargs['execute_form'] = self.make_execute_form(batch, action_url=url)
+            description = (self.handler.describe_execution(batch)
+                           or "TODO: handler does not provide a description for this batch")
+            kwargs['execution_described'] = markdown.markdown(
+                description, extensions=['fenced_code', 'codehilite'])
         else:
             kwargs['why_not_execute'] = self.handler.why_not_execute(batch)
-        kwargs['status_breakdown'] = self.make_status_breakdown(batch)
-        if use_buefy:
-            data = [{'title': title, 'count': count}
-                    for title, count in kwargs['status_breakdown']]
-            Grid = self.get_grid_factory()
-            kwargs['status_breakdown_grid'] = Grid('batch_row_status_breakdown',
-                                                   data, ['title', 'count'])
+
+        breakdown = self.make_status_breakdown(batch)
+
+        factory = self.get_grid_factory()
+        g = factory(self.request,
+                    key='batch_row_status_breakdown',
+                    data=[],
+                    columns=['title', 'count'])
+        g.set_click_handler('title', "autoFilterStatus(props.row)")
+        kwargs['status_breakdown_data'] = breakdown
+        kwargs['status_breakdown_grid'] = HTML.literal(
+            g.render_table_element(data_prop='statusBreakdownData',
+                                   empty_labels=True))
+
         return kwargs
 
+    def make_upload_worksheet_form(self, batch):
+        action_url = self.get_action_url('upload_worksheet', batch)
+        form = forms.Form(schema=UploadWorksheet(),
+                          request=self.request,
+                          action_url=action_url,
+                          component='upload-worksheet-form')
+        form.set_type('worksheet_file', 'file')
+        # TODO: must set these to avoid some default code
+        form.auto_disable = False
+        form.auto_disable_save = False
+        return form
+
+    def download_worksheet(self):
+        batch = self.get_instance()
+        path = self.handler.write_worksheet(batch)
+        root, ext = os.path.splitext(path)
+        # we present a more descriptive filename for download
+        filename = '{}.worksheet.{}{}'.format(batch.batch_key, batch.id_str, ext)
+        return self.file_response(path, filename=filename)
+
+    def upload_worksheet(self):
+        batch = self.get_instance()
+        form = self.make_upload_worksheet_form(batch)
+        if self.validate_form(form):
+            uploads = self.normalize_uploads(form)
+            path = uploads['worksheet_file']['temp_path']
+            return self.handler_action(batch, 'update_from_worksheet', path=path)
+        self.request.session.flash("Upload form did not validate!", 'error')
+        return self.redirect(self.get_action_url('view', batch))
+
+    def update_from_worksheet_thread(self, batch_uuid, user_uuid, progress, path=None):
+        """
+        Thread target for updating a batch from worksheet.
+        """
+        session = self.make_isolated_session()
+        batch = session.get(self.model_class, batch_uuid)
+        try:
+            self.handler.update_from_worksheet(batch, path, progress=progress)
+
+        except Exception as error:
+            session.rollback()
+            log.exception("upload/update failed for '{}' batch: {}".format(self.batch_key, batch))
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = "Upload processing failed: {}".format(
+                    simple_error(error))
+                progress.session.save()
+
+        else:
+            session.commit()
+            success_msg = "Batch has been updated: {}".format(batch)
+            success_url = self.get_action_url('view', batch)
+            session.close()
+
+            if progress:
+                progress.session.load()
+                progress.session['complete'] = True
+                progress.session['success_msg'] = success_msg
+                progress.session['success_url'] = success_url
+                progress.session.save()
+
     def make_status_breakdown(self, batch, rows=None, status_enum=None):
         """
         Returns a simple list of 2-tuples, each of which has the status display
@@ -208,16 +281,14 @@ class BatchMasterView(MasterView):
                         'count': 0,
                     }
                 breakdown[row.status_code]['count'] += 1
-        breakdown = [
-            (status['title'], status['count'])
-            for code, status in six.iteritems(breakdown)]
-        return breakdown
+        return list(breakdown.values())
 
     def allow_worksheet(self, batch):
         return not batch.executed and not batch.complete
 
     def configure_grid(self, g):
-        super(BatchMasterView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         # created_by
         CreatedBy = orm.aliased(model.User)
@@ -265,26 +336,21 @@ class BatchMasterView(MasterView):
             return "{} {}".format(batch.id_str, batch.description)
         return batch.id_str
 
-    def get_mobile_data(self, session=None):
-        return super(BatchMasterView, self).get_mobile_data(session=session)\
-                                           .order_by(self.model_class.id.desc())
-
-    def make_mobile_filters(self):
-        """
-        Returns a set of filters for the mobile grid.
-        """
-        filters = grids.filters.GridFilterSet()
-        filters['status'] = MobileBatchStatusFilter(self.model_class, 'status', default_value='pending')
-        return filters
-
     def configure_form(self, f):
-        super(BatchMasterView, self).configure_form(f)
+        super().configure_form(f)
 
         # id
         f.set_readonly('id')
         f.set_renderer('id', self.render_id_str)
         f.set_label('id', "Batch ID")
 
+        # params
+        if self.creating:
+            f.remove('params')
+        else:
+            f.set_readonly('params')
+            f.set_renderer('params', self.render_params)
+
         # created
         f.set_readonly('created')
         f.set_readonly('created_by')
@@ -318,7 +384,7 @@ class BatchMasterView(MasterView):
         f.set_label('executed_by', "Executed by")
 
         # notes
-        f.set_type('notes', 'text')
+        f.set_type('notes', 'text_wrapped')
 
         # if self.creating and self.request.user:
         #     batch = fs.model
@@ -341,70 +407,57 @@ class BatchMasterView(MasterView):
                 f.remove_fields('executed',
                                 'executed_by')
 
-    def make_status_renderer(self, enum):
-        def render_status(batch, field):
-            value = batch.status_code
-            if value is None:
-                return ""
-            status_code_text = enum.get(value, six.text_type(value))
-            if batch.status_text:
-                return HTML.tag('span', title=batch.status_text, c=status_code_text)
-            return status_code_text
-        return render_status
+    def render_params(self, batch, field):
+        params = self.get_visible_params(batch)
+        if not params:
+            return
+
+        return params
+
+    def get_visible_params(self, batch):
+        return dict(batch.params or {})
 
     def render_complete(self, batch, field):
-        permission_prefix = self.get_permission_prefix()
-        use_buefy = self.get_use_buefy()
         text = "Yes" if batch.complete else "No"
 
-        if batch.executed or not self.request.has_perm('{}.edit'.format(permission_prefix)):
+        if batch.executed or not self.has_perm('edit'):
             return text
 
         if batch.complete:
-            label = "Mark as NOT Complete"
+            label = "Mark Incomplete"
             value = 'false'
         else:
-            label = "Mark as Complete"
+            label = "Mark Complete"
             value = 'true'
 
-        kwargs = {}
-        if not use_buefy:
-            kwargs['class_'] = 'autodisable'
-        begin_form = tags.form(self.get_action_url('toggle_complete', batch), **kwargs)
+        url = self.get_action_url('toggle_complete', batch)
+        kwargs = {'@submit': 'togglingBatchComplete = true'}
+        begin_form = tags.form(url, **kwargs)
 
-        if use_buefy:
-            submit = HTML.tag('once-button',
-                              type='is-primary',
-                              native_type='submit',
-                              text=label)
-        else:
-            submit = tags.submit('submit', label)
+        label = HTML.literal(
+            '{{{{ togglingBatchComplete ? "Working, please wait..." : "{}" }}}}'.format(label))
+        submit = self.make_button(label, is_primary=True,
+                                  native_type='submit',
+                                  **{':disabled': 'togglingBatchComplete'})
 
         form = [
             begin_form,
-            csrf_token(self.request),
+            render_csrf_token(self.request),
             tags.hidden('complete', value=value),
             submit,
             tags.end_form(),
         ]
 
-        if use_buefy:
-            text = HTML.tag('div', class_='control', c=text)
-            form = HTML.tag('div', class_='control', c=form)
-            content = [
-                HTML.tag('nav', class_='level',
-                         c=[HTML.tag('div', class_='level-left', c=[
-                             text,
-                             HTML.literal(' &nbsp; &nbsp; '),
-                             form,
-                         ])]),
-            ]
-
-        else:
-            content = [
-                text,
-                HTML.literal(' &nbsp; '),
-            ] + form
+        text = HTML.tag('div', class_='control', c=text)
+        form = HTML.tag('div', class_='control', c=form)
+        content = [
+            HTML.tag('nav', class_='level',
+                     c=[HTML.tag('div', class_='level-left', c=[
+                         text,
+                         HTML.literal(' &nbsp; &nbsp; '),
+                         form,
+                     ])]),
+        ]
 
         return HTML.tag('div', c=content)
 
@@ -412,32 +465,10 @@ class BatchMasterView(MasterView):
         user = getattr(batch, field)
         if not user:
             return ""
-        title = six.text_type(user)
+        title = str(user)
         url = self.request.route_url('users.view', uuid=user.uuid)
         return tags.link_to(title, url)
 
-    def configure_mobile_form(self, f):
-        super(BatchMasterView, self).configure_mobile_form(f)
-        batch = f.model_instance
-
-        if self.creating:
-            f.remove_fields('id',
-                            'rowcount',
-                            'created',
-                            'created_by',
-                            'cognized',
-                            'cognized_by',
-                            'executed',
-                            'executed_by',
-                            'purge')
-
-        else: # not creating
-            if not batch.executed:
-                f.remove_fields('executed',
-                                'executed_by')
-                if not batch.complete:
-                    f.remove_field('complete')
-
     def save_create_form(self, form):
         uploads = self.normalize_uploads(form)
         self.before_create(form)
@@ -456,7 +487,7 @@ class BatchMasterView(MasterView):
 
             # TODO: this needs work yet surely...why is this an issue?
             # treat 'filename' field specially, for some reason it can be a filedict?
-            if 'filename' in kwargs and not isinstance(kwargs['filename'], six.string_types):
+            if 'filename' in kwargs and not isinstance(kwargs['filename'], str):
                 kwargs['filename'] = '' # null not allowed
 
             # TODO: is this still necessary with colander?
@@ -470,33 +501,22 @@ class BatchMasterView(MasterView):
         return batch
 
     def process_uploads(self, batch, form, uploads):
-        for key, upload in six.iteritems(uploads):
+
+        def process(upload, key):
             self.handler.set_input_file(batch, upload['temp_path'], attr=key)
             os.remove(upload['temp_path'])
             os.rmdir(upload['tempdir'])
 
-    def save_mobile_create_form(self, form):
-        self.before_create(form)
-        session = self.Session()
-        with session.no_autoflush:
+        for key, upload in uploads.items():
+            if isinstance(upload, dict):
+                process(upload, key)
+            else:
+                uploads = upload
+                for upload in uploads:
+                    if isinstance(upload, dict):
+                        process(upload, key)
 
-            # transfer form data to batch instance
-            batch = self.objectify(form, self.form_deserialized)
-
-            # current user is batch creator
-            batch.created_by = self.request.user
-
-            # TODO: is this still necessary with colander?
-            # destroy initial batch and re-make using handler
-            kwargs = self.get_batch_kwargs(batch)
-            if batch in session:
-                session.expunge(batch)
-            batch = self.handler.make_batch(session, **kwargs)
-
-        session.flush()
-        return batch
-
-    def get_batch_kwargs(self, batch, mobile=False):
+    def get_batch_kwargs(self, batch, **kwargs):
         """
         Return a kwargs dict for use with ``self.handler.make_batch()``, using
         the given batch as a template.
@@ -527,13 +547,13 @@ class BatchMasterView(MasterView):
         """
         return True
 
-    def redirect_after_create(self, batch, mobile=False):
+    def redirect_after_create(self, batch, **kwargs):
         if self.handler.should_populate(batch):
-            return self.redirect(self.get_action_url('prefill', batch, mobile=mobile))
+            return self.redirect(self.get_action_url('prefill', batch))
         elif self.refresh_after_create:
-            return self.redirect(self.get_action_url('refresh', batch, mobile=mobile))
+            return self.redirect(self.get_action_url('refresh', batch))
         else:
-            return self.redirect(self.get_action_url('view', batch, mobile=mobile))
+            return self.redirect(self.get_action_url('view', batch))
 
     def template_kwargs_edit(self, **kwargs):
         batch = kwargs['instance']
@@ -546,7 +566,7 @@ class BatchMasterView(MasterView):
             self.request.session.flash("Request ignored, since batch has already been executed")
         else:
             form = forms.Form(schema=ToggleComplete(), request=self.request)
-            if form.validate(newstyle=True):
+            if form.validate():
                 if form.validated['complete']:
                     self.mark_batch_complete(batch)
                 else:
@@ -559,16 +579,6 @@ class BatchMasterView(MasterView):
     def mark_batch_incomplete(self, batch):
         self.handler.mark_incomplete(batch)
 
-    def mobile_mark_complete(self):
-        batch = self.get_instance()
-        self.mark_batch_complete(batch)
-        return self.redirect(self.get_index_url(mobile=True))
-
-    def mobile_mark_pending(self):
-        batch = self.get_instance()
-        self.mark_batch_incomplete(batch)
-        return self.redirect(self.get_action_url('view', batch, mobile=True))
-
     def rows_creatable_for(self, batch):
         """
         Only allow creating new rows on a batch if it hasn't yet been executed
@@ -593,7 +603,7 @@ class BatchMasterView(MasterView):
         return True
 
     def configure_row_grid(self, g):
-        super(BatchMasterView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_sort_defaults('sequence')
         g.set_link('sequence')
@@ -612,11 +622,16 @@ class BatchMasterView(MasterView):
     def get_row_status_enum(self):
         return self.model_row_class.STATUS
 
+    def render_upc_pretty(self, row, field):
+        upc = getattr(row, field)
+        if upc:
+            return upc.pretty()
+
     def render_row_status(self, row, column):
         code = row.status_code
         if code is None:
             return ""
-        text = self.get_row_status_enum().get(code, six.text_type(code))
+        text = self.get_row_status_enum().get(code, str(code))
         if row.status_text:
             return HTML.tag('span', title=row.status_text, c=text)
         return text
@@ -629,17 +644,7 @@ class BatchMasterView(MasterView):
         if batch.executed:
             self.request.session.flash("You cannot add new rows to a batch which has been executed")
             return self.redirect(self.get_action_url('view', batch))
-        return super(BatchMasterView, self).create_row()
-
-    def mobile_create_row(self):
-        """
-        Only allow creating a new row if the batch hasn't yet been executed.
-        """
-        batch = self.get_instance()
-        if batch.executed:
-            self.request.session.flash("You cannot add new rows to a batch which has been executed")
-            return self.redirect(self.get_action_url('view', batch, mobile=True))
-        return super(BatchMasterView, self).mobile_create_row()
+        return super().create_row()
 
     def save_create_row_form(self, form):
         batch = self.get_instance()
@@ -652,11 +657,15 @@ class BatchMasterView(MasterView):
         self.handler.refresh_row(row)
 
     def configure_row_form(self, f):
-        super(BatchMasterView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # sequence
         f.set_readonly('sequence')
 
+        # upc (default rendering, just in case there is such a field
+        # on our row model)
+        f.set_renderer('upc', self.render_upc_pretty)
+
         # status_code
         if self.model_row_class:
             f.set_enum('status_code', self.model_row_class.STATUS)
@@ -667,31 +676,17 @@ class BatchMasterView(MasterView):
         # status text
         f.set_readonly('status_text')
 
-    def configure_mobile_row_form(self, f):
-        super(BatchMasterView, self).configure_mobile_row_form(f)
-
-        # sequence
-        f.set_readonly('sequence')
-
-        # status_code
-        if self.model_row_class:
-            f.set_enum('status_code', self.model_row_class.STATUS)
-        f.set_renderer('status_code', self.render_row_status)
-        f.set_readonly('status_code')
-        f.set_label('status_code', "Status")
-
     def make_default_row_grid_tools(self, batch):
         if self.rows_creatable and not batch.executed and not batch.complete:
             permission_prefix = self.get_permission_prefix()
             if self.request.has_perm('{}.create_row'.format(permission_prefix)):
-                link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
-                                    self.get_action_url('create_row', batch))
-                return HTML.tag('p', c=[link])
+                url = self.get_action_url('create_row', batch)
+                return self.make_button("New Row", url=url,
+                                        is_primary=True,
+                                        icon_left='plus')
 
     def make_batch_row_grid_tools(self, batch):
-        if self.rows_bulk_deletable and not batch.executed and self.request.has_perm('{}.delete_rows'.format(self.get_permission_prefix())):
-            url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid)
-            return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)])
+        pass
 
     def make_row_grid_kwargs(self, **kwargs):
         """
@@ -701,39 +696,34 @@ class BatchMasterView(MasterView):
         batch = self.get_instance()
 
         # TODO: most of this logic is copied from MasterView, should refactor/merge somehow...
-        if 'main_actions' not in kwargs:
+        if 'actions' not in kwargs:
             actions = []
-            use_buefy = self.get_use_buefy()
 
             # view action
             if self.rows_viewable:
                 view = lambda r, i: self.get_row_action_url('view', r)
-                icon = 'eye' if use_buefy else 'zoomin'
-                actions.append(self.make_action('view', icon=icon, url=view))
+                actions.append(self.make_action('view', icon='eye', url=view))
 
-            # edit and delete are NOT allowed after execution, or if batch is "complete"
-            if not batch.executed and not batch.complete:
+            # edit and delete are NOT allowed if batch is "complete"
+            if not batch.complete:
 
                 # edit action
-                if self.rows_editable and self.has_perm('edit_row'):
-                    icon = 'edit' if use_buefy else 'pencil'
-                    actions.append(self.make_action('edit', icon=icon, url=self.row_edit_action_url))
+                if self.rows_editable and not batch.executed and self.has_perm('edit_row'):
+                    actions.append(self.make_action('edit', icon='edit',
+                                                    url=self.row_edit_action_url))
 
                 # delete action
                 if self.rows_deletable and self.has_perm('delete_row'):
                     actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url))
                     kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump)
 
-            kwargs['main_actions'] = actions
+            kwargs['actions'] = actions
 
-        return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs)
+        return super().make_row_grid_kwargs(**kwargs)
 
     def make_row_grid_tools(self, batch):
         return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '')
 
-    def sort_mobile_row_data(self, query):
-        return query.order_by(self.model_row_class.sequence)
-
     def redirect_after_edit(self, batch):
         """
         If refresh flag is set, do that; otherwise go (back) to view/edit page.
@@ -746,15 +736,57 @@ class BatchMasterView(MasterView):
         """
         Delete all data (files etc.) for the batch.
         """
-        self.handler.do_delete(batch)
-        super(BatchMasterView, self).delete_instance(batch)
+        app = self.get_rattail_app()
+        session = app.get_session(batch)
+        self.batch_handler.do_delete(batch)
+        session.flush()
 
-    def get_fallback_templates(self, template, mobile=False):
-        if mobile:
-            return [
-                '/mobile/batch/{}.mako'.format(template),
-                '/mobile/master/{}.mako'.format(template),
-            ]
+    def delete_instance_with_progress(self, batch):
+        """
+        Delete all data (files etc.) for the batch.
+        """
+        return self.handler_action(batch, 'delete')
+
+    def delete_thread(self, key, user_uuid, progress, **kwargs):
+        """
+        Thread target for deleting a batch with progress indicator.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+        # nb. must make new session, separate from main thread
+        session = app.make_session()
+        batch = self.get_instance_for_key(key, session)
+        batch_str = str(batch)
+
+        try:
+            # try to delete batch
+            self.handler.do_delete(batch, progress=progress, **kwargs)
+
+        except Exception as error:
+            # error; log that and rollback
+            log.exception("delete failed for batch: %s", batch)
+            session.rollback()
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = "Batch deletion failed: {}".format(
+                    simple_error(error))
+                progress.session.save()
+
+        else:
+            # no error; finish up
+            session.commit()
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['complete'] = True
+                progress.session['success_url'] = self.get_index_url()
+                progress.session['success_msg'] = "Batch has been deleted: {}".format(
+                    batch_str)
+                progress.session.save()
+
+    def get_fallback_templates(self, template, **kwargs):
         return [
             '/batch/{}.mako'.format(template),
             '/master/{}.mako'.format(template),
@@ -801,29 +833,42 @@ class BatchMasterView(MasterView):
         defaults = {}
         route_prefix = self.get_route_prefix()
 
+        schema = None
         if self.has_execution_options(batch):
             if batch is None:
                 batch = self.model_class
             schema = self.make_execute_schema(batch)
-            for field in schema:
+            if schema:
+                for field in schema:
 
-                # if field does not yet have a default, maybe provide one from session storage
-                if field.default is colander.null:
-                    key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name)
-                    if key in self.request.session:
-                        defaults[field.name] = self.request.session[key]
+                    # if field does not yet have a default, maybe provide one from session storage
+                    if field.default is colander.null:
+                        key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name)
+                        if key in self.request.session:
+                            defaults[field.name] = self.request.session[key]
 
-                # make sure field label is preserved
-                if field.title:
-                    labels = kwargs.setdefault('labels', {})
-                    labels[field.name] = field.title
+                    # make sure field label is preserved
+                    if field.title:
+                        labels = kwargs.setdefault('labels', {})
+                        labels[field.name] = field.title
 
-        else:
+                    # auto-convert select widgets for theme
+                    if isinstance(field.widget, forms.widgets.PlainSelectWidget):
+                        warnings.warn("PlainSelectWidget is deprecated; "
+                                      "please use deform.widget.SelectWidget instead",
+                                      DeprecationWarning, stacklevel=2)
+                        field.widget = dfwidget.SelectWidget(values=field.widget.values)
+
+        if not schema:
             schema = colander.Schema()
 
-        kwargs['use_buefy'] = self.get_use_buefy()
-        kwargs['component'] = 'execute-form'
-        return forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs)
+        kwargs['vue_tagname'] = 'execute-form'
+        form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs)
+        self.configure_execute_form(form)
+        return form
+
+    def configure_execute_form(self, form):
+        pass
 
     def get_execute_title(self, batch):
         if hasattr(self.handler, 'get_execute_title'):
@@ -863,7 +908,7 @@ class BatchMasterView(MasterView):
 
             # launch thread to invoke handler action
             thread = Thread(target=self.action_subprocess_thread,
-                            args=(batch.uuid, port, username, batch_action, progress),
+                            args=((batch.uuid,), port, username, batch_action, progress),
                             kwargs=kwargs)
             thread.start()
 
@@ -872,7 +917,7 @@ class BatchMasterView(MasterView):
 
             # launch thread to populate batch; that will update session progress directly
             target = getattr(self, '{}_thread'.format(batch_action))
-            thread = Thread(target=target, args=(batch.uuid, user_uuid, progress), kwargs=kwargs)
+            thread = Thread(target=target, args=((batch.uuid,), user_uuid, progress), kwargs=kwargs)
             thread.start()
 
         return self.render_progress(progress, {
@@ -881,78 +926,6 @@ class BatchMasterView(MasterView):
             'cancel_msg': "{} of batch was canceled.".format(batch_action.capitalize()),
         })
 
-    def progress_thread(self, sock, success_url, progress):
-        """
-        This method is meant to be used as a thread target.  Its job is to read
-        progress data from ``connection`` and update the session progress
-        accordingly.  When a final "process complete" indication is read, the
-        socket will be closed and the thread will end.
-        """
-        while True:
-            try:
-                self.process_progress(sock, progress)
-            except EverythingComplete:
-                break
-
-        # close server socket
-        sock.close()
-
-        # finalize session progress
-        progress.session.load()
-        progress.session['complete'] = True
-        if callable(success_url):
-            success_url = success_url()
-        progress.session['success_url'] = success_url
-        progress.session.save()
-
-    def process_progress(self, sock, progress):
-        """
-        This method will accept a client connection on the given socket, and
-        then update the given progress object according to data written by the
-        client.
-        """
-        connection, client_address = sock.accept()
-        active_progress = None
-
-        # TODO: make this configurable?
-        suffix = "\n\n.".encode('utf_8')
-        data = b''
-
-        # listen for progress info, update session progress as needed
-        while True:
-
-            # accumulate data bytestring until we see the suffix
-            byte = connection.recv(1)
-            data += byte
-            if data.endswith(suffix):
-
-                # strip suffix, interpret data as JSON
-                data = data[:-len(suffix)]
-                if six.PY3:
-                    data = data.decode('utf_8')
-                data = json.loads(data)
-
-                if data.get('everything_complete'):
-                    if active_progress:
-                        active_progress.finish()
-                    raise EverythingComplete
-
-                elif data.get('process_complete'):
-                    active_progress.finish()
-                    active_progress = None
-                    break
-
-                elif 'value' in data:
-                    if not active_progress:
-                        active_progress = progress(data['message'], data['maximum'])
-                    active_progress.update(data['value'])
-
-                # reset data buffer
-                data = b''
-
-        # close client connection
-        connection.close()
-
     def launch_subprocess(self, port=None, username=None,
                           command='rattail', command_args=None,
                           subcommand=None, subcommand_args=None):
@@ -961,7 +934,7 @@ class BatchMasterView(MasterView):
         prefix = self.rattail_config.get('rattail', 'command_prefix',
                                          default=sys.prefix)
         cmd = [os.path.join(prefix, 'bin/{}'.format(command))]
-        for path in self.rattail_config.files_read:
+        for path in self.rattail_config.prioritized_files:
             cmd.extend(['--config', path])
         if username:
             cmd.extend(['--runas', username])
@@ -977,9 +950,18 @@ class BatchMasterView(MasterView):
 
         # run command in subprocess
         log.debug("launching command in subprocess: %s", cmd)
-        subprocess.check_call(cmd)
+        try:
+            # nb. we do not capture stderr, but on failure the stdout
+            # will contain a simple error string
+            subprocess.check_output(cmd)
+        except subprocess.CalledProcessError as error:
+            log.warning("command failed with exit code %s!  output was:",
+                        error.returncode)
+            output = error.output.decode('utf_8')
+            log.warning(output)
+            raise Exception(output)
 
-    def action_subprocess_thread(self, batch_uuid, port, username, handler_action, progress, **kwargs):
+    def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs):
         """
         This method is sort of an alternative thread target for batch actions,
         to be used in the event versioning is enabled for the main process but
@@ -987,7 +969,13 @@ class BatchMasterView(MasterView):
         launch a separate process with versioning disabled in order to act on
         the batch.
         """
+        batch_uuid = key[0]
+
         # figure out the (sub)command args we'll be passing
+        if handler_action == 'auto_receive':
+            subcommand = 'auto-receive'
+        else:
+            subcommand = f'{handler_action}-batch'
         subargs = [
             '--batch-type',
             self.handler.batch_key,
@@ -1006,18 +994,19 @@ class BatchMasterView(MasterView):
                                    command_args=[
                                        '--no-versioning',
                                    ],
-                                   subcommand='{}-batch'.format(handler_action),
+                                   subcommand=subcommand,
                                    subcommand_args=subargs)
         except Exception as error:
             log.warning("%s of '%s' batch failed: %s", handler_action, self.handler.batch_key, batch_uuid, exc_info=True)
 
-            # TODO: write error info to socket
-
-            # if progress:
-            #     progress.session.load()
-            #     progress.session['error'] = True
-            #     progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error)
-            #     progress.session.save()
+            # TODO: write minimal error info to socket
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = (
+                    "{} of '{}' batch failed: {} (see logs for more info)").format(
+                        handler_action, self.handler.batch_key, error)
+                progress.session.save()
 
             return
 
@@ -1030,17 +1019,17 @@ class BatchMasterView(MasterView):
         data = json.dumps({
             'everything_complete': True,
         })
-        if six.PY3:
-            data = data.encode('utf_8')
+        data = data.encode('utf_8')
         cxn.send(data)
         cxn.send(suffix)
         cxn.close()
 
     def catchup_versions(self, port, batch_uuid, username, *models):
-        with short_session() as s:
-            batch = s.query(self.model_class).get(batch_uuid)
+        app = self.get_rattail_app()
+        with app.short_session() as s:
+            batch = s.get(self.model_class, batch_uuid)
             batch_id = batch.id_str
-            description = six.text_type(batch)
+            description = str(batch)
 
         self.launch_subprocess(
             port=port, username=username,
@@ -1063,21 +1052,24 @@ class BatchMasterView(MasterView):
         """
         Thread target for populating batch data with progress indicator.
         """
+        app = self.get_rattail_app()
+        model = self.model
         # mustn't use tailbone web session here
-        session = RattailSession()
-        batch = session.query(self.model_class).get(batch_uuid)
-        user = session.query(model.User).get(user_uuid)
+        session = app.make_session()
+        batch = session.get(self.model_class, batch_uuid)
+        user = session.get(model.User, user_uuid)
         try:
             self.handler.do_populate(batch, user, progress=progress)
+            session.flush()
         except Exception as error:
             session.rollback()
-            log.warning("batch population failed: %s", batch, exc_info=True)
+            log.warning("population failed for batch %s: %s", batch.uuid, batch,
+                        exc_info=True)
             session.close()
             if progress:
                 progress.session.load()
                 progress.session['error'] = True
-                progress.session['error_msg'] = "Batch population failed: {}".format(
-                    simple_error(error))
+                progress.session['error_msg'] = simple_error(error)
                 progress.session.save()
             return
 
@@ -1121,11 +1113,14 @@ class BatchMasterView(MasterView):
         # Refresh data for the batch, with progress.  Note that we must use the
         # rattail session here; can't use tailbone because it has web request
         # transaction binding etc.
-        session = RattailSession()
-        batch = session.query(self.model_class).get(batch_uuid)
-        cognizer = session.query(model.User).get(user_uuid) if user_uuid else None
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
+        batch = session.get(self.model_class, batch_uuid)
+        cognizer = session.get(model.User, user_uuid) if user_uuid else None
         try:
             self.refresh_data(session, batch, cognizer, progress=progress)
+            session.flush()
         except Exception as error:
             session.rollback()
             log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True)
@@ -1173,9 +1168,11 @@ class BatchMasterView(MasterView):
         """
         Thread target for refreshing multiple batches with progress indicator.
         """
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         batches = batches.with_session(session).all()
-        user = session.query(model.User).get(user_uuid)
+        user = session.get(model.User, user_uuid)
         try:
             self.handler.refresh_many(batches, user=user, progress=progress)
 
@@ -1231,18 +1228,34 @@ class BatchMasterView(MasterView):
         """
         Batch rows are editable only until batch is complete or executed.
         """
+        if not (self.rows_editable or self.rows_editable_but_not_directly):
+            return False
+
         batch = self.get_parent(row)
-        return self.rows_editable and not batch.executed and not batch.complete
+        if batch.complete or batch.executed:
+            return False
+
+        return True
 
     def row_deletable(self, row):
         """
         Batch rows are deletable only until batch is complete or executed.
         """
-        if self.rows_deletable:
-            batch = self.get_parent(row)
-            if not batch.executed and not batch.complete:
-                return True
-        return False
+        if not self.rows_deletable:
+            return False
+
+        batch = self.get_parent(row)
+
+        if batch.complete:
+            return False
+
+        if batch.executed:
+            if not self.rows_deletable_if_executed:
+                return False
+            if not self.has_perm('delete_row_if_executed'):
+                return False
+
+        return True
 
     def template_kwargs_view_row(self, **kwargs):
         kwargs['batch_model_title'] = kwargs['parent_model_title']
@@ -1260,22 +1273,19 @@ class BatchMasterView(MasterView):
         """
         self.handler.do_remove_row(row)
 
-    def bulk_delete_rows(self):
-        """
-        "Delete" all rows matching the current row grid view query.  This sets
-        the ``removed`` flag on the rows but does not truly delete them.
-        """
+    def delete_row_objects(self, rows):
+        deleted = super().delete_row_objects(rows)
         batch = self.get_instance()
-        query = self.get_effective_row_data(sort=False)
 
-        # TODO: this should surely be handled by the handler...
+        # decrement rowcount for batch
         if batch.rowcount is not None:
-            batch.rowcount -= query.count()
-        query.update({'removed': True}, synchronize_session=False)
+            batch.rowcount -= deleted
+
+        # refresh batch status
         self.Session.refresh(batch)
         self.handler.refresh_batch_status(batch)
 
-        return self.redirect(self.get_action_url('view', batch))
+        return deleted
 
     def execute(self):
         """
@@ -1285,7 +1295,7 @@ class BatchMasterView(MasterView):
         batch = self.get_instance()
         self.executing = True
         form = self.make_execute_form(batch)
-        if form.validate(newstyle=True):
+        if form.validate():
             kwargs = dict(form.validated)
 
             # cache options to use as defaults next time
@@ -1297,62 +1307,21 @@ class BatchMasterView(MasterView):
         self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error')
         return self.redirect(self.get_action_url('view', batch))
 
-    def mobile_execute(self):
-        """
-        Mobile view which can prompt user for execution options if applicable,
-        and/or execute a batch.  For now this is done in a "blocking" fashion,
-        i.e. no progress bar.
-        """
-        batch = self.get_instance()
-        model_title = self.get_model_title()
-        instance_title = self.get_instance_title(batch)
-        view_url = self.get_action_url('view', batch, mobile=True)
-        self.executing = True
-        form = self.make_execute_form(batch)
-        if form.validate(newstyle=True):
-            kwargs = dict(form.validated)
-
-            # cache options to use as defaults next time
-            for key, value in form.validated.items():
-                self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value
-
-            try:
-                result = self.handler.execute(batch, user=self.request.user, **kwargs)
-            except Exception as err:
-                log.exception("failed to execute %s %s", model_title, batch.id_str)
-                self.request.session.flash(self.execute_error_message(err), 'error')
-            else:
-                if result:
-                    batch.executed = datetime.datetime.utcnow()
-                    batch.executed_by = self.request.user
-                    self.request.session.flash("{} was executed: {}".format(model_title, instance_title))
-                else:
-                    log.error("not sure why, but failed to execute %s %s: %s", model_title, batch.id_str, batch)
-                    self.request.session.flash("Failed to execute {}: {}".format(model_title, err), 'error')
-            return self.redirect(view_url)
-
-        form.mobile = True
-        form.submit_label = "Execute"
-        form.cancel_url = view_url
-        return self.render_to_response('execute', {
-            'form': form,
-            'instance_title': instance_title,
-            'instance_url': view_url,
-        }, mobile=True)
-
     def execute_error_message(self, error):
         return "Batch execution failed: {}".format(simple_error(error))
 
-    def execute_thread(self, batch_uuid, user_uuid, progress, **kwargs):
+    def execute_thread(self, key, user_uuid, progress, **kwargs):
         """
         Thread target for executing a batch with progress indicator.
         """
         # Execute the batch, with progress.  Note that we must use the rattail
         # session here; can't use tailbone because it has web request
         # transaction binding etc.
-        session = RattailSession()
-        batch = session.query(self.model_class).get(batch_uuid)
-        user = session.query(model.User).get(user_uuid)
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
+        batch = self.get_instance_for_key(key, session)
+        user = session.get(model.User, user_uuid)
         try:
             result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs)
 
@@ -1369,11 +1338,11 @@ class BatchMasterView(MasterView):
 
         # If no error, check result flag (false means user canceled).
         else:
+            success_msg = None
             if result:
                 session.commit()
-                # TODO: this doesn't always work...?
-                self.request.session.flash("{} has been executed: {}".format(
-                    self.get_model_title(), batch.id_str))
+                success_msg = "{} has been executed: {}".format(
+                    self.get_model_title(), batch.id_str)
             else:
                 session.rollback()
 
@@ -1385,6 +1354,8 @@ class BatchMasterView(MasterView):
                 progress.session.load()
                 progress.session['complete'] = True
                 progress.session['success_url'] = success_url
+                if success_msg:
+                    progress.session['success_msg'] = success_msg
                 progress.session.save()
 
     def get_execute_success_url(self, batch, result, **kwargs):
@@ -1397,7 +1368,7 @@ class BatchMasterView(MasterView):
         indicator page.
         """
         form = self.make_execute_form()
-        if form.validate(newstyle=True):
+        if form.validate():
             kwargs = dict(form.validated)
 
             # cache options to use as defaults next time
@@ -1423,9 +1394,11 @@ class BatchMasterView(MasterView):
         """
         Thread target for executing multiple batches with progress indicator.
         """
-        session = RattailSession()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         batches = batches.with_session(session).all()
-        user = session.query(model.User).get(user_uuid)
+        user = session.get(model.User, user_uuid)
         try:
             result = self.handler.execute_many(batches, user=user, progress=progress, **kwargs)
 
@@ -1463,7 +1436,7 @@ class BatchMasterView(MasterView):
         return self.get_index_url()
 
     def get_row_csv_fields(self):
-        fields = super(BatchMasterView, self).get_row_csv_fields()
+        fields = super().get_row_csv_fields()
         fields = [field for field in fields
                   if field not in ('uuid', 'batch_uuid', 'removed')]
         return fields
@@ -1493,10 +1466,10 @@ class BatchMasterView(MasterView):
         model_key = cls.get_model_key()
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
         permission_prefix = cls.get_permission_prefix()
         model_title = cls.get_model_title()
         model_title_plural = cls.get_model_title_plural()
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
 
         # TODO: currently must do this here (in addition to `_defaults()` or
         # else the perm group label will not display correctly...
@@ -1508,9 +1481,10 @@ class BatchMasterView(MasterView):
                         permission='{}.create'.format(permission_prefix))
 
         # worksheet
-        if cls.has_worksheet:
+        if cls.has_worksheet or cls.has_worksheet_file:
             config.add_tailbone_permission(permission_prefix, '{}.worksheet'.format(permission_prefix),
                                            "Edit {} data as worksheet".format(model_title))
+        if cls.has_worksheet:
             config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key))
             config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix),
                             permission='{}.worksheet'.format(permission_prefix))
@@ -1519,6 +1493,20 @@ class BatchMasterView(MasterView):
             config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix),
                             renderer='json', permission='{}.worksheet'.format(permission_prefix))
 
+        # worksheet file
+        if cls.has_worksheet_file:
+
+            # download worksheet
+            config.add_route('{}.download_worksheet'.format(route_prefix), '{}/download-worksheet'.format(instance_url_prefix))
+            config.add_view(cls, attr='download_worksheet', route_name='{}.download_worksheet'.format(route_prefix),
+                            permission='{}.worksheet'.format(permission_prefix))
+
+            # upload worksheet
+            config.add_route('{}.upload_worksheet'.format(route_prefix), '{}/upload-worksheet'.format(instance_url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='upload_worksheet', route_name='{}.upload_worksheet'.format(route_prefix),
+                            permission='{}.worksheet'.format(permission_prefix))
+
         # refresh batch data
         if cls.refreshable:
             config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix))
@@ -1527,31 +1515,17 @@ class BatchMasterView(MasterView):
             config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix),
                                            "Refresh data for {}".format(model_title))
 
-        # bulk delete rows
-        if cls.rows_bulk_deletable:
-            config.add_route('{}.delete_rows'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix))
-            config.add_view(cls, attr='bulk_delete_rows', route_name='{}.delete_rows'.format(route_prefix),
-                            permission='{}.delete_rows'.format(permission_prefix))
-            config.add_tailbone_permission(permission_prefix, '{}.delete_rows'.format(permission_prefix),
-                                           "Bulk-delete data rows from {}".format(model_title))
+        # delete row if executed
+        if cls.rows_deletable_if_executed:
+            config.add_tailbone_permission(permission_prefix,
+                                           f'{permission_prefix}.delete_row_if_executed',
+                                           "Delete rows after batch is executed")
 
         # toggle complete
         config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key))
         config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix),
                         permission='{}.edit'.format(permission_prefix))
 
-        # mobile mark complete
-        if legacy_mobile:
-            config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key))
-            config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix),
-                            permission='{}.edit'.format(permission_prefix))
-
-        # mobile mark pending
-        if legacy_mobile:
-            config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key))
-            config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix),
-                            permission='{}.edit'.format(permission_prefix))
-
         # refresh multiple batches (results)
         if cls.results_refreshable:
             config.add_route('{}.refresh_results'.format(route_prefix), '{}/refresh-results'.format(url_prefix),
@@ -1591,7 +1565,7 @@ class FileBatchMasterView(BatchMasterView):
         return uploads
 
     def configure_form(self, f):
-        super(FileBatchMasterView, self).configure_form(f)
+        super().configure_form(f)
         batch = f.model_instance
 
         # filename
@@ -1605,36 +1579,12 @@ class FileBatchMasterView(BatchMasterView):
             f.set_renderer('filename', self.render_downloadable_file)
 
 
+class UploadWorksheet(colander.Schema):
+
+    # this node is actually "replaced" when form is configured
+    worksheet_file = colander.SchemaNode(colander.String())
+
+
 class ToggleComplete(colander.MappingSchema):
 
     complete = colander.SchemaNode(colander.Boolean())
-
-
-class MobileBatchStatusFilter(grids.filters.MobileFilter):
-
-    value_choices = ['pending', 'complete', 'executed', 'all']
-
-    def __init__(self, model_class, key, **kwargs):
-        self.model_class = model_class
-        super(MobileBatchStatusFilter, self).__init__(key, **kwargs)
-
-    def filter_equal(self, query, value):
-
-        if value == 'pending':
-            return query.filter(self.model_class.executed == None)\
-                        .filter(sa.or_(
-                            self.model_class.complete == None,
-                            self.model_class.complete == False))
-
-        if value == 'complete':
-            return query.filter(self.model_class.executed == None)\
-                        .filter(self.model_class.complete == True)
-
-        if value == 'executed':
-            return query.filter(self.model_class.executed != None)
-
-        return query
-
-    def iter_choices(self):
-        for value in self.value_choices:
-            yield value, prettify(value)
diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py
new file mode 100644
index 00000000..60561e96
--- /dev/null
+++ b/tailbone/views/batch/delproduct.py
@@ -0,0 +1,128 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2021 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Views for "delete product" batches
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from rattail.db import model
+
+from tailbone.views.batch import BatchMasterView
+
+
+class DeleteProductBatchView(BatchMasterView):
+    """
+    Master view for delete product batches.
+    """
+    model_class = model.DeleteProductBatch
+    model_row_class = model.DeleteProductBatchRow
+    default_handler_spec = 'rattail.batch.delproduct:DeleteProductBatchHandler'
+    route_prefix = 'batch.delproduct'
+    url_prefix = '/batches/delproduct'
+    template_prefix = '/batch/delproduct'
+    creatable = False
+    bulk_deletable = True
+    rows_bulk_deletable = True
+
+    form_fields = [
+        'id',
+        'description',
+        'notes',
+        'inactivity_months',
+        'created',
+        'created_by',
+        'rowcount',
+        'status_code',
+        'executed',
+        'executed_by',
+    ]
+
+    row_grid_columns = [
+        'sequence',
+        'upc',
+        'brand_name',
+        'description',
+        'size',
+        'pack_size',
+        'department_name',
+        'subdepartment_name',
+        'present_in_scale',
+        'date_created',
+        'status_code',
+    ]
+
+    row_form_fields = [
+        'sequence',
+        'product',
+        'upc',
+        'brand_name',
+        'description',
+        'size',
+        'pack_size',
+        'department_number',
+        'department_name',
+        'subdepartment_number',
+        'subdepartment_name',
+        'present_in_scale',
+        'date_created',
+        'status_code',
+        'status_text',
+    ]
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super(DeleteProductBatchView, self).template_kwargs_view(**kwargs)
+        batch = kwargs['batch']
+
+        kwargs['rows_with_inventory'] = [row for row in batch.active_rows()
+                                         if row.status_code == row.STATUS_HAS_INVENTORY]
+
+        return kwargs
+
+    def row_grid_extra_class(self, row, i):
+        if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
+            return 'warning'
+        if row.status_code in (row.STATUS_DELETE_NOT_ALLOWED,
+                               row.STATUS_HAS_INVENTORY,
+                               row.STATUS_PENDING_CUSTOMER_ORDERS):
+            return 'notice'
+
+    def configure_row_grid(self, g):
+        super(DeleteProductBatchView, self).configure_row_grid(g)
+
+        # pack_size
+        g.set_type('pack_size', 'quantity')
+
+    def configure_row_form(self, f):
+        super(DeleteProductBatchView, self).configure_row_form(f)
+        row = f.model_instance
+
+        # upc
+        f.set_renderer('upc', self.render_upc)
+
+        # pack_size
+        f.set_type('pack_size', 'quantity')
+
+
+def includeme(config):
+    DeleteProductBatchView.defaults(config)
diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py
new file mode 100644
index 00000000..486d8774
--- /dev/null
+++ b/tailbone/views/batch/handheld.py
@@ -0,0 +1,209 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Views for handheld batches
+"""
+
+from collections import OrderedDict
+
+from rattail.db.model import HandheldBatch, HandheldBatchRow
+
+import colander
+from deform import widget as dfwidget
+from webhelpers2.html import tags
+
+from tailbone.views.batch import FileBatchMasterView
+
+
+ACTION_OPTIONS = OrderedDict([
+    ('make_label_batch', "Make a new Label Batch"),
+    ('make_inventory_batch', "Make a new Inventory Batch"),
+])
+
+
+class ExecutionOptions(colander.Schema):
+
+    action = colander.SchemaNode(
+        colander.String(),
+        validator=colander.OneOf(ACTION_OPTIONS),
+        widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items())))
+
+
+class HandheldBatchView(FileBatchMasterView):
+    """
+    Master view for handheld batches.
+    """
+    model_class = HandheldBatch
+    default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler'
+    model_title_plural = "Handheld Batches"
+    route_prefix = 'batch.handheld'
+    url_prefix = '/batch/handheld'
+    execution_options_schema = ExecutionOptions
+    editable = False
+
+    model_row_class = HandheldBatchRow
+    rows_creatable = False
+    rows_editable = True
+
+    grid_columns = [
+        'id',
+        'device_type',
+        'device_name',
+        'created',
+        'created_by',
+        'rowcount',
+        'status_code',
+        'executed',
+    ]
+
+    form_fields = [
+        'id',
+        'device_type',
+        'device_name',
+        'filename',
+        'created',
+        'created_by',
+        'rowcount',
+        'status_code',
+        'executed',
+        'executed_by',
+    ]
+
+    row_labels = {
+        'upc': "UPC",
+    }
+
+    row_grid_columns = [
+        'sequence',
+        'upc',
+        'brand_name',
+        'description',
+        'size',
+        'cases',
+        'units',
+        'status_code',
+    ]
+
+    row_form_fields = [
+        'sequence',
+        'upc',
+        'brand_name',
+        'description',
+        'size',
+        'status_code',
+        'cases',
+        'units',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+        device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(),
+                                          key=lambda item: item[1]))
+        g.set_enum('device_type', device_types)
+
+    def grid_extra_class(self, batch, i):
+        if batch.status_code is not None and batch.status_code != batch.STATUS_OK:
+            return 'notice'
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        batch = f.model_instance
+
+        # device_type
+        device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(),
+                                          key=lambda item: item[1]))
+        f.set_enum('device_type', device_types)
+        f.widgets['device_type'].values.insert(0, ('', "(none)"))
+
+        if self.creating:
+            f.set_fields([
+                'filename',
+                'device_type',
+                'device_name',
+            ])
+
+        if self.viewing:
+            if batch.inventory_batch:
+                f.append('inventory_batch')
+                f.set_renderer('inventory_batch', self.render_inventory_batch)
+
+    def render_inventory_batch(self, handheld_batch, field):
+        batch = handheld_batch.inventory_batch
+        if not batch:
+            return ""
+        text = batch.id_str
+        url = self.request.route_url('batch.inventory.view', uuid=batch.uuid)
+        return tags.link_to(text, url)
+
+    def get_batch_kwargs(self, batch):
+        kwargs = super().get_batch_kwargs(batch)
+        kwargs['device_type'] = batch.device_type
+        kwargs['device_name'] = batch.device_name
+        return kwargs
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+        g.set_type('cases', 'quantity')
+        g.set_type('units', 'quantity')
+        g.set_label('brand_name', "Brand")
+
+    def row_grid_extra_class(self, row, i):
+        if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
+            return 'warning'
+
+    def configure_row_form(self, f):
+        super().configure_row_form(f)
+
+        # readonly fields
+        f.set_readonly('upc')
+        f.set_readonly('brand_name')
+        f.set_readonly('description')
+        f.set_readonly('size')
+
+        # upc
+        f.set_renderer('upc', self.render_upc)
+
+    def get_execute_success_url(self, batch, result, **kwargs):
+        if kwargs['action'] == 'make_inventory_batch':
+            return self.request.route_url('batch.inventory.view', uuid=result.uuid)
+        elif kwargs['action'] == 'make_label_batch':
+            return self.request.route_url('labels.batch.view', uuid=result.uuid)
+        return super().get_execute_success_url(batch)
+
+    def get_execute_results_success_url(self, result, **kwargs):
+        if result is True:
+            # no batches were actually executed
+            return self.get_index_url()
+        batch = result
+        return self.get_execute_success_url(batch, result, **kwargs)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    HandheldBatchView = kwargs.get('HandheldBatchView', base['HandheldBatchView'])
+    HandheldBatchView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py
index 001de0ff..ea4e1c74 100644
--- a/tailbone/views/batch/importer.py
+++ b/tailbone/views/batch/importer.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,11 +24,9 @@
 Views for importer batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import sqlalchemy as sa
 
-from rattail.db import model
+from rattail.db.model import ImporterBatch
 
 import colander
 
@@ -39,9 +37,8 @@ class ImporterBatchView(BatchMasterView):
     """
     Master view for importer batches.
     """
-    model_class = model.ImporterBatch
+    model_class = ImporterBatch
     default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler'
-    model_title_plural = "Import / Export Batches"
     route_prefix = 'batch.importer'
     url_prefix = '/batches/importer'
     template_prefix = '/batch/importer'
@@ -94,7 +91,7 @@ class ImporterBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(ImporterBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # readonly fields
         f.set_readonly('import_handler_spec')
@@ -113,21 +110,21 @@ class ImporterBatchView(BatchMasterView):
             self.make_row_table(batch.row_table)
             kwargs['rows'] = self.Session.query(self.current_row_table).all()
         kwargs.setdefault('status_enum', self.enum.IMPORTER_BATCH_ROW_STATUS)
-        breakdown = super(ImporterBatchView, self).make_status_breakdown(
-            batch, **kwargs)
+        breakdown = super().make_status_breakdown(batch, **kwargs)
         return breakdown
 
     def delete_instance(self, batch):
         self.make_row_table(batch.row_table)
         if self.current_row_table is not None:
             self.current_row_table.drop()
-        super(ImporterBatchView, self).delete_instance(batch)
+        super().delete_instance(batch)
 
     def make_row_table(self, name):
         if not hasattr(self, 'current_row_table'):
-            metadata = sa.MetaData(schema='batch', bind=self.Session.bind)
+            metadata = sa.MetaData(schema='batch')
             try:
-                self.current_row_table = sa.Table(name, metadata, autoload=True)
+                self.current_row_table = sa.Table(name, metadata,
+                                                  autoload_with=self.Session.bind)
             except sa.exc.NoSuchTableError:
                 self.current_row_table = None
 
@@ -139,8 +136,7 @@ class ImporterBatchView(BatchMasterView):
         return self.enum.IMPORTER_BATCH_ROW_STATUS
 
     def configure_row_grid(self, g):
-        super(ImporterBatchView, self).configure_row_grid(g)
-        use_buefy = self.get_use_buefy()
+        super().configure_row_grid(g)
 
         def make_filter(field, **kwargs):
             column = getattr(self.current_row_table.c, field)
@@ -149,13 +145,8 @@ class ImporterBatchView(BatchMasterView):
         make_filter('object_key')
         make_filter('object_str')
 
-        # for some reason we have to do this differently for Buefy?
-        kwargs = {}
-        if not use_buefy:
-            kwargs['value_enum'] = self.enum.IMPORTER_BATCH_ROW_STATUS
-        make_filter('status_code', label="Status", **kwargs)
-        if use_buefy:
-            g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS)
+        make_filter('status_code', label="Status")
+        g.filters['status_code'].set_choices(self.enum.IMPORTER_BATCH_ROW_STATUS)
 
         def make_sorter(field):
             column = getattr(self.current_row_table.c, field)
@@ -197,7 +188,7 @@ class ImporterBatchView(BatchMasterView):
 
     def get_parent(self, row):
         uuid = self.current_row_table.name
-        return self.Session.query(model.ImporterBatch).get(uuid)
+        return self.Session.get(ImporterBatch, uuid)
 
     def get_row_instance_title(self, row):
         if row.object_str:
@@ -249,7 +240,7 @@ class ImporterBatchView(BatchMasterView):
 
         kwargs.setdefault('schema', colander.Schema())
         kwargs.setdefault('cancel_url', None)
-        return super(ImporterBatchView, self).make_row_form(instance=row, **kwargs)
+        return super().make_row_form(instance=row, **kwargs)
 
     def configure_row_form(self, f):
         """
@@ -284,9 +275,33 @@ class ImporterBatchView(BatchMasterView):
         query = self.get_effective_row_data(sort=False)
         batch.rowcount -= query.count()
         delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query]))
-        delete_query.execute()
+        self.Session.bind.execute(delete_query)
         return self.redirect(self.get_action_url('view', batch))
 
+    def get_row_xlsx_fields(self):
+        return [
+            'sequence',
+            'object_key',
+            'object_str',
+            'status',
+            'status_code',
+            'status_text',
+        ]
+
+    def get_row_xlsx_row(self, row, fields):
+        xlrow = super().get_row_xlsx_row(row, fields)
+
+        xlrow['status'] = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code]
+
+        return xlrow
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ImporterBatchView = kwargs.get('ImporterBatchView', base['ImporterBatchView'])
+    ImporterBatchView.defaults(config)
+
 
 def includeme(config):
-    ImporterBatchView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py
index 26123707..e9f72ceb 100644
--- a/tailbone/views/batch/inventory.py
+++ b/tailbone/views/batch/inventory.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,20 +24,16 @@
 Views for inventory batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import re
 import decimal
 import logging
-
-import six
+from collections import OrderedDict
 
 from rattail import pod
 from rattail.db import model
 from rattail.db.util import make_full_description
-from rattail.time import localtime
 from rattail.gpc import GPC
-from rattail.util import pretty_quantity, OrderedDict
+from rattail.util import pretty_quantity
 
 import colander
 from deform import widget as dfwidget
@@ -63,8 +59,6 @@ class InventoryBatchView(BatchMasterView):
     index_title = "Inventory"
     rows_creatable = True
     bulk_deletable = True
-    mobile_creatable = True
-    mobile_rows_creatable = True
 
     # set to True for the UI to "prefer" case amounts, as opposed to unit
     prefer_cases = False
@@ -101,15 +95,6 @@ class InventoryBatchView(BatchMasterView):
         'executed_by',
     ]
 
-    mobile_form_fields = [
-        'mode',
-        'reason_code',
-        'rowcount',
-        'complete',
-        'executed',
-        'executed_by',
-    ]
-
     model_row_class = model.InventoryBatchRow
     rows_editable = True
 
@@ -160,13 +145,6 @@ class InventoryBatchView(BatchMasterView):
         # total_cost
         g.set_type('total_cost', 'currency')
 
-    def render_mobile_listitem(self, batch, i):
-        return "({}) {} rows - {}, {}".format(
-            batch.id_str,
-            "?" if batch.rowcount is None else batch.rowcount,
-            batch.created_by,
-            localtime(self.request.rattail_config, batch.created, from_utc=True).strftime('%Y-%m-%d'))
-
     def mutable_batch(self, batch):
         return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL
 
@@ -237,7 +215,7 @@ class InventoryBatchView(BatchMasterView):
         return super(InventoryBatchView, self).save_edit_row_form(form)
 
     def delete_row(self):
-        row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid'])
+        row = self.Session.get(model.InventoryBatchRow, self.request.matchdict['row_uuid'])
         if not row:
             raise self.notfound()
         batch = row.batch
@@ -250,43 +228,49 @@ class InventoryBatchView(BatchMasterView):
         Desktop workflow view for adding items to inventory batch.
         """
         batch = self.get_instance()
-        if batch.executed:
+        if batch.executed or batch.complete:
             return self.redirect(self.get_action_url('view', batch))
 
         schema = DesktopForm().bind(session=self.Session())
         form = forms.Form(schema=schema, request=self.request)
-        if form.validate(newstyle=True):
+        if self.request.method == 'POST':
+            if form.validate():
 
-            product = self.Session.query(model.Product).get(form.validated['product'])
+                product = self.Session.get(model.Product, form.validated['product'])
 
-            row = None
-            if self.should_aggregate_products(batch):
-                row = self.find_row_for_product(batch, product)
-                if row:
+                row = None
+                if self.should_aggregate_products(batch):
+                    row = self.find_row_for_product(batch, product)
+                    if row:
+                        row.cases = form.validated['cases']
+                        row.units = form.validated['units']
+                        self.handler.refresh_row(row)
+
+                if not row:
+                    row = model.InventoryBatchRow()
+                    row.product = product
+                    row.upc = form.validated['upc']
+                    row.brand_name = form.validated['brand_name']
+                    row.description = form.validated['description']
+                    row.size = form.validated['size']
+                    row.case_quantity = form.validated['case_quantity']
                     row.cases = form.validated['cases']
                     row.units = form.validated['units']
-                    self.handler.refresh_row(row)
+                    self.handler.capture_current_units(row)
+                    self.handler.add_row(batch, row)
 
-            if not row:
-                row = model.InventoryBatchRow()
-                row.product = product
-                row.upc = form.validated['upc']
-                row.brand_name = form.validated['brand_name']
-                row.description = form.validated['description']
-                row.size = form.validated['size']
-                row.case_quantity = form.validated['case_quantity']
-                row.cases = form.validated['cases']
-                row.units = form.validated['units']
-                self.handler.capture_current_units(row)
-                self.handler.add_row(batch, row)
+                description = make_full_description(form.validated['brand_name'],
+                                                    form.validated['description'],
+                                                    form.validated['size'])
+                self.request.session.flash("{} cases, {} units: {} {}".format(
+                    form.validated['cases'] or 0, form.validated['units'] or 0,
+                    form.validated['upc'].pretty(), description))
+                return self.redirect(self.request.current_route_url())
 
-            description = make_full_description(form.validated['brand_name'],
-                                                form.validated['description'],
-                                                form.validated['size'])
-            self.request.session.flash("{} cases, {} units: {} {}".format(
-                form.validated['cases'] or 0, form.validated['units'] or 0,
-                form.validated['upc'].pretty(), description))
-            return self.redirect(self.request.current_route_url())
+            else:
+                dform = form.make_deform_form()
+                msg = "Form did not validate: {}".format(str(dform.error))
+                self.request.session.flash(msg, 'error')
 
         title = self.get_instance_title(batch)
         return self.render_to_response('desktop_form', {
@@ -357,7 +341,7 @@ class InventoryBatchView(BatchMasterView):
             upc = re.sub(r'\D', '', entry.strip())
             if upc:
                 upc = GPC(upc)
-                result['upc'] = six.text_type(upc)
+                result['upc'] = str(upc)
                 result['upc_pretty'] = upc.pretty()
                 result['image_url'] = pod.get_image_url(self.rattail_config, upc)
 
@@ -376,20 +360,26 @@ class InventoryBatchView(BatchMasterView):
 
     # TODO: deprecate / remove (?)
     def find_product(self, entry):
-        lookup_by_code = self.rattail_config.getbool(
-            'tailbone', 'inventory.lookup_by_code', default=False)
+        lookup_fields = [
+            'uuid',
+            '_product_key_',
+        ]
 
-        return self.handler.locate_product_for_entry(
-            self.Session(), entry, lookup_by_code=lookup_by_code)
+        if self.rattail_config.getbool('tailbone', 'inventory.lookup_by_code',
+                                       default=False):
+            lookup_fields.append('alt_code')
+
+        return self.handler.locate_product_for_entry(self.Session(), entry,
+                                                     lookup_fields=lookup_fields)
 
     def product_info(self, product):
         data = {}
         if product and (not product.deleted or self.request.has_perm('products.view_deleted')):
             data['uuid'] = product.uuid
-            data['upc'] = six.text_type(product.upc)
+            data['upc'] = str(product.upc)
             data['upc_pretty'] = product.upc.pretty()
             data['full_description'] = product.full_description
-            data['brand_name'] = six.text_type(product.brand or '')
+            data['brand_name'] = str(product.brand or '')
             data['description'] = product.description
             data['size'] = product.size
             data['case_quantity'] = 1 # default
@@ -397,56 +387,6 @@ class InventoryBatchView(BatchMasterView):
             data['image_url'] = pod.get_image_url(self.rattail_config, product.upc)
         return data
 
-    def configure_mobile_form(self, f):
-        super(InventoryBatchView, self).configure_mobile_form(f)
-        batch = f.model_instance
-
-        # mode
-        modes = self.get_available_modes()
-        f.set_enum('mode', modes)
-        mode_values = [(k, v) for k, v in sorted(modes.items())]
-        f.set_widget('mode', forms.widgets.PlainSelectWidget(values=mode_values))
-
-        # complete
-        if self.creating or batch.executed or not batch.complete:
-            f.remove_field('complete')
-
-        # rowcount
-        if self.viewing and not batch.executed and not batch.complete:
-            f.remove_field('rowcount')
-
-    # TODO: this view can create new rows, with only a GET query.  that should
-    # probably be changed to require POST; for now we just require the "create
-    # batch row" perm and call it good..
-    def mobile_row_from_upc(self):
-        """
-        Locate and/or create a row within the batch, according to the given
-        product UPC, then redirect to the row view page.
-        """
-        batch = self.get_instance()
-        row = None
-        raw_entry = self.request.GET.get('upc', '')
-        entry = raw_entry.strip()
-        entry = re.sub(r'\D', '', entry)
-        if entry:
-
-            if len(entry) <= 14:
-                row = self.add_row_for_upc(batch, entry, warn_if_present=True)
-                if not row:
-                    self.request.session.flash("Product not found: {}".format(entry), 'error')
-                    return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-            else:
-                self.request.session.flash("UPC has too many digits ({}): {}".format(len(entry), entry), 'error')
-                return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-        else:
-            self.request.session.flash("Product not found: {}".format(raw_entry), 'error')
-            return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-        self.Session.flush()
-        return self.redirect(self.mobile_row_route_url('view', uuid=row.batch_uuid, row_uuid=row.uuid))
-
     def add_row_for_upc(self, batch, entry, warn_if_present=False):
         """
         Add a row to the batch for the given UPC, if applicable.
@@ -467,76 +407,13 @@ class InventoryBatchView(BatchMasterView):
         kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc)
         return kwargs
 
-    def get_batch_kwargs(self, batch, mobile=False):
-        kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, mobile=False)
+    def get_batch_kwargs(self, batch, **kwargs):
+        kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, **kwargs)
         kwargs['mode'] = batch.mode
         kwargs['complete'] = False
         kwargs['reason_code'] = batch.reason_code
         return kwargs
 
-    def get_mobile_row_data(self, batch):
-        # we want newest on top, for inventory batch rows
-        return self.get_row_data(batch)\
-                   .order_by(self.model_row_class.sequence.desc())
-
-    # TODO: ugh, the hackiness.  needs a refactor fo sho
-    def mobile_view_row(self):
-        """
-        Mobile view for inventory batch rows.  Note that this also handles
-        updating a row...ugh.
-        """
-        self.viewing = True
-        row = self.get_row_instance()
-        batch = self.get_parent(row)
-        form = self.make_mobile_row_form(row)
-
-        allow_cases = self.allow_cases(batch)
-        unit_uom = 'LB' if row.product and row.product.weighed else 'EA'
-        if row.cases and allow_cases:
-            uom = 'CS'
-        elif row.units:
-            uom = unit_uom
-        elif row.case_quantity and allow_cases and self.prefer_cases:
-            uom = 'CS'
-        else:
-            uom = unit_uom
-
-        context = {
-            'row': row,
-            'batch': batch,
-            'instance': row,
-            'instance_title': self.get_row_instance_title(row),
-            'parent_model_title': self.get_model_title(),
-            'parent_title': self.get_instance_title(batch),
-            'parent_url': self.get_action_url('view', batch, mobile=True),
-            'product_image_url': pod.get_image_url(self.rattail_config, row.upc),
-            'form': form,
-            'allow_cases': allow_cases,
-            'unit_uom': unit_uom,
-            'uom': uom,
-        }
-
-        if self.request.has_perm('{}.edit_row'.format(self.get_permission_prefix())):
-            schema = InventoryForm().bind(session=self.Session())
-            update_form = forms.Form(schema=schema, request=self.request)
-            if update_form.validate(newstyle=True):
-                row = self.Session.query(model.InventoryBatchRow).get(update_form.validated['row'])
-                cases = update_form.validated['cases']
-                units = update_form.validated['units']
-                if cases is not colander.null:
-                    row.cases = cases
-                    row.units = None
-                elif units is not colander.null:
-                    row.cases = None
-                    row.units = units
-                else:
-                    raise NotImplementedError
-                self.handler.refresh_row(row)
-                route_prefix = self.get_route_prefix()
-                return self.redirect(self.request.route_url('mobile.{}.view'.format(route_prefix), uuid=batch.uuid))
-
-        return self.render_to_response('view_row', context, mobile=True)
-
     def get_row_instance_title(self, row):
         if row.upc:
             return row.upc.pretty()
@@ -569,12 +446,6 @@ class InventoryBatchView(BatchMasterView):
         if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
             return 'warning'
 
-    def render_mobile_row_listitem(self, row, i):
-        description = row.product.full_description if row.product else row.description
-        unit_uom = 'LB' if row.product and row.product.weighed else 'EA'
-        qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom)
-        return "({}) {} - {}".format(row.upc.pretty(), description, qty)
-
     def configure_row_form(self, f):
         super(InventoryBatchView, self).configure_row_form(f)
         row = f.model_instance
@@ -609,16 +480,6 @@ class InventoryBatchView(BatchMasterView):
             if not self.allow_cases(row.batch):
                 f.set_readonly('cases')
 
-    def render_upc(self, row, field):
-        upc = row.upc
-        if not upc:
-            return ""
-        text = upc.pretty()
-        if row.product_uuid:
-            url = self.request.route_url('products.view', uuid=row.product_uuid)
-            return tags.link_to(text, url)
-        return text
-
     @classmethod
     def defaults(cls, config):
         cls._batch_defaults(config)
@@ -633,7 +494,6 @@ class InventoryBatchView(BatchMasterView):
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
         permission_prefix = cls.get_permission_prefix()
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
 
         # we need batch handler to determine available permissions
         factory = cls.get_handler_factory(rattail_config)
@@ -654,38 +514,6 @@ class InventoryBatchView(BatchMasterView):
         config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix),
                         renderer='json', permission='{}.create_row'.format(permission_prefix))
 
-        # mobile - make new row from UPC
-        if legacy_mobile:
-            config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key))
-            config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix),
-                            permission='{}.create_row'.format(permission_prefix))
-
-
-# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
-# session is not provided by the view at runtime (i.e. when it was instead
-# being provided by the type instance, which was created upon app startup).
-@colander.deferred
-def valid_inventory_batch_row(node, kw):
-    session = kw['session']
-    def validate(node, value):
-        row = session.query(model.InventoryBatchRow).get(value)
-        if not row:
-            raise colander.Invalid(node, "Batch row not found")
-        if row.batch.executed:
-            raise colander.Invalid(node, "Batch has already been executed")
-        return row.uuid
-    return validate
-
-
-class InventoryForm(colander.MappingSchema):
-
-    row = colander.SchemaNode(colander.String(),
-                              validator=valid_inventory_batch_row)
-
-    cases = colander.SchemaNode(colander.Decimal(), missing=colander.null)
-
-    units = colander.SchemaNode(colander.Decimal(), missing=colander.null)
-
 
 # TODO: this is a stopgap measure to fix an obvious bug, which exists when the
 # session is not provided by the view at runtime (i.e. when it was instead
@@ -694,7 +522,7 @@ class InventoryForm(colander.MappingSchema):
 def valid_product(node, kw):
     session = kw['session']
     def validate(node, value):
-        product = session.query(model.Product).get(value)
+        product = session.get(model.Product, value)
         if not product:
             raise colander.Invalid(node, "Product not found")
         return product.uuid
diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py
index 8aeab62b..7291b05e 100644
--- a/tailbone/views/batch/labels.py
+++ b/tailbone/views/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for label batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from deform import widget as dfwidget
@@ -48,7 +44,6 @@ class LabelBatchView(BatchMasterView):
     route_prefix = 'labels.batch'
     url_prefix = '/labels/batches'
     template_prefix = '/batch/labels'
-    creatable = False
     bulk_deletable = True
     rows_editable = True
     rows_bulk_deletable = True
@@ -85,6 +80,11 @@ class LabelBatchView(BatchMasterView):
         'upc': "UPC",
         'vendor_id': "Vendor ID",
         'label_profile': "Label Type",
+        'sale_start': "Sale Starts",
+        'sale_stop': "Sale Ends",
+        'tpr_price': "TPR Price",
+        'tpr_starts': "TPR Starts",
+        'tpr_ends': "TPR Ends",
     }
 
     row_form_fields = [
@@ -102,6 +102,12 @@ class LabelBatchView(BatchMasterView):
         'sale_price',
         'sale_start',
         'sale_stop',
+        'tpr_price',
+        'tpr_starts',
+        'tpr_ends',
+        'current_price',
+        'current_starts',
+        'current_ends',
         'vendor_id',
         'vendor_name',
         'vendor_item_code',
@@ -113,15 +119,18 @@ class LabelBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(LabelBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # handheld_batches
-        f.set_readonly('handheld_batches')
-        f.set_renderer('handheld_batches', self.render_handheld_batches)
-        if self.viewing or self.deleting:
+        if self.creating:
+            f.remove('handheld_batches')
+        else:
             batch = self.get_instance()
             if not batch._handhelds:
                 f.remove_field('handheld_batches')
+            else:
+                f.set_readonly('handheld_batches')
+                f.set_renderer('handheld_batches', self.render_handheld_batches)
 
         # label profile
         if self.creating or self.editing:
@@ -129,7 +138,7 @@ class LabelBatchView(BatchMasterView):
                 f.replace('label_profile', 'label_profile_uuid')
                 # TODO: should restrict somehow? just allow override?
                 profiles = self.Session.query(model.LabelProfile)
-                values = [(p.uuid, six.text_type(p))
+                values = [(p.uuid, str(p))
                           for p in profiles]
                 require_profile = False
                 if not require_profile:
@@ -138,15 +147,15 @@ class LabelBatchView(BatchMasterView):
                 f.set_label('label_profile_uuid', "Label Profile")
 
     def render_handheld_batches(self, label_batch, field):
-        items = ''
+        items = []
         for handheld in label_batch._handhelds:
             text = handheld.handheld.id_str
             url = self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid)
-            items += HTML.tag('li', c=tags.link_to(text, url))
+            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
         return HTML.tag('ul', c=items)
 
     def configure_row_grid(self, g):
-        super(LabelBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         # short labels
         g.set_label('brand_name', "Brand")
@@ -158,7 +167,7 @@ class LabelBatchView(BatchMasterView):
             return 'warning'
 
     def configure_row_form(self, f):
-        super(LabelBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('sequence')
@@ -206,7 +215,7 @@ class LabelBatchView(BatchMasterView):
             profiles = self.Session.query(model.LabelProfile)\
                                    .filter(model.LabelProfile.visible == True)\
                                    .order_by(model.LabelProfile.ordinal)
-            profile_values = [(p.uuid, six.text_type(p))
+            profile_values = [(p.uuid, str(p))
                               for p in profiles]
             f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values))
 
diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py
index b56d008e..bd46ad52 100644
--- a/tailbone/views/batch/newproduct.py
+++ b/tailbone/views/batch/newproduct.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,10 @@
 Views for new product batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
+from deform import widget as dfwidget
+
 from tailbone.views.batch import BatchMasterView
 
 
@@ -46,11 +46,20 @@ class NewProductBatchView(BatchMasterView):
     rows_editable = True
     rows_bulk_deletable = True
 
+    configurable = True
+    has_input_file_templates = True
+
+    labels = {
+        'type2_lookup': "Type-2 UPC Lookups",
+    }
+
     form_fields = [
         'id',
         'input_filename',
         'description',
         'notes',
+        'type2_lookup',
+        'params',
         'created',
         'created_by',
         'rowcount',
@@ -64,14 +73,14 @@ class NewProductBatchView(BatchMasterView):
 
     row_grid_columns = [
         'sequence',
-        'upc',
+        '_product_key_',
         'brand_name',
         'description',
         'size',
         'vendor',
         'vendor_item_code',
-        'department',
-        'subdepartment',
+        'department_name',
+        'subdepartment_name',
         'regular_price',
         'status_code',
     ]
@@ -79,17 +88,25 @@ class NewProductBatchView(BatchMasterView):
     row_form_fields = [
         'sequence',
         'product',
-        'upc',
+        '_product_key_',
         'brand_name',
         'description',
         'size',
+        'unit_size',
+        'unit_of_measure_entry',
         'vendor_id',
         'vendor',
         'vendor_item_code',
         'department_number',
+        'department_name',
         'department',
         'subdepartment_number',
+        'subdepartment_name',
         'subdepartment',
+        'weighed',
+        'tax1',
+        'tax2',
+        'tax3',
         'case_size',
         'case_cost',
         'unit_cost',
@@ -104,12 +121,21 @@ class NewProductBatchView(BatchMasterView):
         'family',
         'report_code',
         'report',
+        'ecommerce_available',
         'status_code',
         'status_text',
     ]
 
+    def get_input_file_templates(self):
+        return [
+            {'key': 'default',
+             'label': "Default",
+             'default_url': self.request.static_url(
+                 'tailbone:static/files/newproduct_template.xlsx')},
+        ]
+
     def configure_form(self, f):
-        super(NewProductBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # input_filename
         if self.creating:
@@ -118,6 +144,34 @@ class NewProductBatchView(BatchMasterView):
             f.set_readonly('input_filename')
             f.set_renderer('input_filename', self.render_downloadable_file)
 
+        # type2_lookup
+        if self.creating:
+            values = [
+                ('', "(use default behavior)"),
+                ('always', "Always try Type-2 lookup, when applicable"),
+                ('never', "Never try Type-2 lookup"),
+            ]
+            f.set_widget('type2_lookup', dfwidget.SelectWidget(values=values))
+            f.set_default('type2_lookup', '')
+        else:
+            f.remove('type2_lookup')
+
+    def save_create_form(self, form):
+        batch = super().save_create_form(form)
+
+        if 'type2_lookup' in form:
+            type2_lookup = form.validated['type2_lookup']
+            if type2_lookup == 'always':
+                type2_lookup = True
+            elif type2_lookup == 'never':
+                type2_lookup = False
+            else:
+                type2_lookup = None
+            if type2_lookup is not None:
+                batch.set_param('type2_lookup', type2_lookup)
+
+        return batch
+
     def configure_row_grid(self, g):
         super(NewProductBatchView, self).configure_row_grid(g)
 
@@ -127,6 +181,10 @@ class NewProductBatchView(BatchMasterView):
         g.set_type('pack_price', 'currency')
         g.set_type('suggested_price', 'currency')
 
+        g.set_link('brand_name')
+        g.set_link('description')
+        g.set_link('size')
+
     def row_grid_extra_class(self, row, i):
         if row.status_code in (row.STATUS_MISSING_KEY,
                                row.STATUS_PRODUCT_EXISTS,
@@ -136,11 +194,12 @@ class NewProductBatchView(BatchMasterView):
             return 'warning'
         if row.status_code in (row.STATUS_CATEGORY_NOT_FOUND,
                                row.STATUS_FAMILY_NOT_FOUND,
-                               row.STATUS_REPORTCODE_NOT_FOUND):
+                               row.STATUS_REPORTCODE_NOT_FOUND,
+                               row.STATUS_CANNOT_CALCULATE_PRICE):
             return 'notice'
 
     def configure_row_form(self, f):
-        super(NewProductBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         f.set_readonly('product')
         f.set_readonly('vendor')
@@ -152,11 +211,19 @@ class NewProductBatchView(BatchMasterView):
 
         f.set_type('upc', 'gpc')
 
+        f.set_renderer('product', self.render_product)
         f.set_renderer('vendor', self.render_vendor)
         f.set_renderer('department', self.render_department)
         f.set_renderer('subdepartment', self.render_subdepartment)
         f.set_renderer('report', self.render_report)
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    NewProductBatchView = kwargs.get('NewProductBatchView', base['NewProductBatchView'])
     NewProductBatchView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py
new file mode 100644
index 00000000..b6fef6c8
--- /dev/null
+++ b/tailbone/views/batch/pos.py
@@ -0,0 +1,312 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Views for POS batches
+"""
+
+from rattail.db.model import POSBatch, POSBatchRow
+
+from webhelpers2.html import HTML
+
+from tailbone.views.batch import BatchMasterView
+
+
+class POSBatchView(BatchMasterView):
+    """
+    Master view for POS batches
+    """
+    model_class = POSBatch
+    model_row_class = POSBatchRow
+    default_handler_spec = 'rattail.batch.pos:POSBatchHandler'
+    route_prefix = 'batch.pos'
+    url_prefix = '/batch/pos'
+    creatable = False
+    editable = False
+    cloneable = True
+    refreshable = False
+    rows_deletable = False
+    rows_bulk_deletable = False
+
+    labels = {
+        'terminal_id': "Terminal ID",
+        'fs_tender_total': "FS Tender Total",
+    }
+
+    grid_columns = [
+        'id',
+        'created',
+        'terminal_id',
+        'cashier',
+        'customer',
+        'rowcount',
+        'sales_total',
+        'void',
+        'status_code',
+        'executed',
+    ]
+
+    form_fields = [
+        'id',
+        'terminal_id',
+        'cashier',
+        'customer',
+        'customer_is_member',
+        'customer_is_employee',
+        'params',
+        'rowcount',
+        'sales_total',
+        'taxes',
+        'tender_total',
+        'fs_tender_total',
+        'balance',
+        'void',
+        'training_mode',
+        'status_code',
+        'created',
+        'created_by',
+        'executed',
+        'executed_by',
+    ]
+
+    row_grid_columns = [
+        'sequence',
+        'row_type',
+        'item_entry',
+        'description',
+        'reg_price',
+        'txn_price',
+        'quantity',
+        'sales_total',
+        'tender_total',
+        'tax_code',
+        'user',
+    ]
+
+    row_form_fields = [
+        'sequence',
+        'row_type',
+        'item_entry',
+        'product',
+        'description',
+        'department_number',
+        'department_name',
+        'reg_price',
+        'cur_price',
+        'cur_price_type',
+        'cur_price_start',
+        'cur_price_end',
+        'txn_price',
+        'txn_price_adjusted',
+        'quantity',
+        'sales_total',
+        'tax_code',
+        'tender_total',
+        'tender',
+        'void',
+        'status_code',
+        'timestamp',
+        'user',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+        model = self.model
+
+        # terminal_id
+        g.set_label('terminal_id', "Terminal")
+        if 'terminal_id' in g.filters:
+            g.filters['terminal_id'].label = self.labels.get('terminal_id', "Terminal ID")
+
+        # cashier
+        def join_cashier(q):
+            return q.outerjoin(model.Employee,
+                               model.Employee.uuid == model.POSBatch.cashier_uuid)\
+                    .outerjoin(model.Person,
+                               model.Person.uuid == model.Employee.person_uuid)
+        g.set_joiner('cashier', join_cashier)
+        g.set_sorter('cashier', model.Person.display_name)
+
+        # customer
+        g.set_link('customer')
+        g.set_joiner('customer', lambda q: q.outerjoin(model.Customer))
+        g.set_sorter('customer', model.Customer.name)
+
+        g.set_link('created')
+        g.set_link('created_by')
+
+        g.set_type('sales_total', 'currency')
+        g.set_type('tender_total', 'currency')
+        g.set_type('fs_tender_total', 'currency')
+
+        # executed
+        # nb. default view should show "all recent" batches regardless
+        # of execution (i think..)
+        if 'executed' in g.filters:
+            g.filters['executed'].default_active = False
+
+    def grid_extra_class(self, batch, i):
+        if batch.void:
+            return 'warning'
+        if (batch.training_mode
+            or batch.status_code == batch.STATUS_SUSPENDED):
+            return 'notice'
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        app = self.get_rattail_app()
+
+        # cashier
+        f.set_renderer('cashier', self.render_employee)
+
+        # customer
+        f.set_renderer('customer', self.render_customer)
+
+        f.set_type('sales_total', 'currency')
+        f.set_type('tender_total', 'currency')
+        f.set_type('fs_tender_total', 'currency')
+
+        if self.viewing:
+            f.set_renderer('taxes', self.render_taxes)
+
+        f.set_renderer('balance', lambda batch, field: app.render_currency(batch.get_balance()))
+
+    def render_taxes(self, batch, field):
+        route_prefix = self.get_route_prefix()
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.taxes',
+            data=[],
+            columns=[
+                'code',
+                'description',
+                'rate',
+                'total',
+            ],
+        )
+
+        return HTML.literal(
+            g.render_table_element(data_prop='taxesData'))
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
+        batch = kwargs['instance']
+
+        taxes = []
+        for btax in batch.taxes.values():
+            data = {
+                'uuid': btax.uuid,
+                'code': btax.tax_code,
+                'description': btax.tax.description,
+                'rate': app.render_percent(btax.tax_rate),
+                'total': app.render_currency(btax.tax_total),
+            }
+            taxes.append(data)
+        taxes.sort(key=lambda t: t['code'])
+        kwargs['taxes_data'] = taxes
+
+        kwargs['execute_enabled'] = False
+        kwargs['why_not_execute'] = "POS batch must be executed at POS"
+
+        return kwargs
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        g.set_enum('row_type', self.enum.POS_ROW_TYPE)
+
+        g.set_type('quantity', 'quantity')
+        g.set_type('reg_price', 'currency')
+        g.set_type('txn_price', 'currency')
+        g.set_type('sales_total', 'currency')
+        g.set_type('tender_total', 'currency')
+
+        g.set_link('product')
+        g.set_link('description')
+
+    def row_grid_extra_class(self, row, i):
+        if row.void:
+            return 'warning'
+
+    def configure_row_form(self, f):
+        super().configure_row_form(f)
+
+        f.set_enum('row_type', self.enum.POS_ROW_TYPE)
+
+        f.set_renderer('product', self.render_product)
+        f.set_renderer('tender', self.render_tender)
+
+        f.set_type('quantity', 'quantity')
+        f.set_type('reg_price', 'currency')
+        f.set_type('txn_price', 'currency')
+        f.set_type('sales_total', 'currency')
+        f.set_type('tender_total', 'currency')
+
+        f.set_renderer('user', self.render_user)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._batch_defaults(config)
+        cls._defaults(config)
+        cls._pos_batch_defaults(config)
+
+    @classmethod
+    def _pos_batch_defaults(cls, config):
+        rattail_config = config.registry.settings.get('rattail_config')
+
+        if rattail_config.getbool('tailbone', 'expose_pos_permissions',
+                                  default=False):
+
+            config.add_tailbone_permission_group('pos', "POS", overwrite=False)
+
+            config.add_tailbone_permission('pos', 'pos.test_error',
+                                           "Force error to test error handling")
+            config.add_tailbone_permission('pos', 'pos.ring_sales',
+                                           "Make transactions (ring up sales)")
+            config.add_tailbone_permission('pos', 'pos.override_price',
+                                           "Override price for any item")
+            config.add_tailbone_permission('pos', 'pos.del_customer',
+                                           "Remove customer from current transaction")
+            # config.add_tailbone_permission('pos', 'pos.resume',
+            #                                "Resume previously-suspended transaction")
+            config.add_tailbone_permission('pos', 'pos.toggle_training',
+                                           "Start/end training mode")
+            config.add_tailbone_permission('pos', 'pos.suspend',
+                                           "Suspend current transaction")
+            config.add_tailbone_permission('pos', 'pos.swap_customer',
+                                           "Swap customer for current transaction")
+            config.add_tailbone_permission('pos', 'pos.void_txn',
+                                           "Void current transaction")
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    POSBatchView = kwargs.get('POSBatchView', base['POSBatchView'])
+    POSBatchView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py
index 88063d00..5b5d013b 100644
--- a/tailbone/views/batch/pricing.py
+++ b/tailbone/views/batch/pricing.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for pricing batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 from rattail.time import localtime
 
@@ -52,6 +48,7 @@ class PricingBatchView(BatchMasterView):
     bulk_deletable = True
     rows_editable = True
     rows_bulk_deletable = True
+    configurable = True
 
     labels = {
         'min_diff_threshold': "Min $ Diff",
@@ -62,11 +59,12 @@ class PricingBatchView(BatchMasterView):
     grid_columns = [
         'id',
         'description',
+        'start_date',
         'created',
         'created_by',
         'rowcount',
         # 'status_code',
-        # 'complete',
+        'complete',
         'executed',
         'executed_by',
     ]
@@ -75,6 +73,7 @@ class PricingBatchView(BatchMasterView):
         'id',
         'input_filename',
         'description',
+        'start_date',
         'min_diff_threshold',
         'min_diff_percent',
         'calculate_for_manual',
@@ -84,6 +83,7 @@ class PricingBatchView(BatchMasterView):
         'created_by',
         'rowcount',
         'shelved',
+        'complete',
         'executed',
         'executed_by',
     ]
@@ -147,8 +147,24 @@ class PricingBatchView(BatchMasterView):
         'status_text',
     ]
 
+    def allow_future_pricing(self):
+        return self.batch_handler.allow_future()
+
     def configure_form(self, f):
-        super(PricingBatchView, self).configure_form(f)
+        super().configure_form(f)
+        app = self.get_rattail_app()
+        batch = f.model_instance
+
+        if self.creating or self.editing:
+            if self.allow_future_pricing():
+                f.set_type('start_date', 'date_jquery')
+                f.set_helptext('start_date', "Only set this for a \"FUTURE\" batch.")
+            else:
+                f.remove('start_date')
+        else: # viewing or deleting
+            if not self.allow_future_pricing():
+                if not batch.start_date:
+                    f.remove('start_date')
 
         f.set_type('min_diff_threshold', 'currency')
 
@@ -171,8 +187,9 @@ class PricingBatchView(BatchMasterView):
             if self.request.POST.get('auto_generate_from_srp_breach') == 'true':
                 f.set_required('input_filename', False)
 
-    def get_batch_kwargs(self, batch, mobile=False):
-        kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
+    def get_batch_kwargs(self, batch, **kwargs):
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
+        kwargs['start_date'] = batch.start_date
         kwargs['min_diff_threshold'] = batch.min_diff_threshold
         kwargs['min_diff_percent'] = batch.min_diff_percent
         kwargs['calculate_for_manual'] = batch.calculate_for_manual
@@ -192,13 +209,15 @@ class PricingBatchView(BatchMasterView):
         return kwargs
 
     def configure_row_grid(self, g):
-        super(PricingBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_joiner('vendor_id', lambda q: q.outerjoin(model.Vendor))
         g.set_sorter('vendor_id', model.Vendor.id)
         g.set_filter('vendor_id', model.Vendor.id)
         g.set_renderer('vendor_id', self.render_vendor_id)
 
+        g.set_renderer('subdepartment_number', self.render_subdepartment_number)
+
         g.set_type('old_price', 'currency')
         g.set_type('new_price', 'currency')
         g.set_type('price_diff', 'currency')
@@ -208,15 +227,23 @@ class PricingBatchView(BatchMasterView):
         g.set_renderer('true_margin', self.render_true_margin)
 
     def render_vendor_id(self, row, field):
-        vendor_id = row.vendor.id if row.vendor else None
-        if not vendor_id:
-            return ""
-        return vendor_id
+        vendor = row.vendor
+        if not vendor:
+            return
+        text = vendor.id or "(no id)"
+        return HTML.tag('span', c=text, title=vendor.name)
+
+    def render_subdepartment_number(self, row, field):
+        if row.subdepartment_number:
+            if row.subdepartment_name:
+                return HTML.tag('span', title=row.subdepartment_name,
+                                c=str(row.subdepartment_number))
+            return row.subdepartment_number
 
     def render_true_margin(self, row, field):
         margin = row.true_margin
         if margin:
-            margin = six.text_type(margin)
+            margin = str(margin)
         else:
             margin = HTML.literal('&nbsp;')
         if row.old_true_margin is not None:
@@ -264,7 +291,7 @@ class PricingBatchView(BatchMasterView):
         return HTML.tag('span', title=title, c=text)
 
     def configure_row_form(self, f):
-        super(PricingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('product')
@@ -297,7 +324,7 @@ class PricingBatchView(BatchMasterView):
         return tags.link_to(text, url)
 
     def get_row_csv_fields(self):
-        fields = super(PricingBatchView, self).get_row_csv_fields()
+        fields = super().get_row_csv_fields()
 
         if 'vendor_uuid' in fields:
             i = fields.index('vendor_uuid')
@@ -313,7 +340,7 @@ class PricingBatchView(BatchMasterView):
 
     # TODO: this is the same as xlsx row! should merge/share somehow?
     def get_row_csv_row(self, row, fields):
-        csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields)
+        csvrow = super().get_row_csv_row(row, fields)
 
         vendor = row.vendor
         if 'vendor_id' in fields:
@@ -327,7 +354,7 @@ class PricingBatchView(BatchMasterView):
 
     # TODO: this is the same as csv row! should merge/share somehow?
     def get_row_xlsx_row(self, row, fields):
-        xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields)
+        xlrow = super().get_row_xlsx_row(row, fields)
 
         vendor = row.vendor
         if 'vendor_id' in fields:
@@ -339,6 +366,22 @@ class PricingBatchView(BatchMasterView):
 
         return xlrow
 
+    def configure_get_simple_settings(self):
+        return [
+
+            # options
+            {'section': 'rattail.batch',
+             'option': 'pricing.allow_future',
+             'type': bool},
+        ]
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PricingBatchView = kwargs.get('PricingBatchView', base['PricingBatchView'])
+    PricingBatchView.defaults(config)
+
 
 def includeme(config):
-    PricingBatchView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py
index 50b18953..590c3ff0 100644
--- a/tailbone/views/batch/product.py
+++ b/tailbone/views/batch/product.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,14 @@
 Views for generic product batches
 """
 
-from __future__ import unicode_literals, absolute_import
+from collections import OrderedDict
 
-from rattail.db import model
-from rattail.util import OrderedDict
+from rattail.db.model import ProductBatch, ProductBatchRow
 
 import colander
+from deform import widget as dfwidget
 from webhelpers2.html import HTML
 
-from tailbone import forms
 from tailbone.views.batch import BatchMasterView
 
 
@@ -47,15 +46,15 @@ class ExecutionOptions(colander.Schema):
     action = colander.SchemaNode(
         colander.String(),
         validator=colander.OneOf(ACTION_OPTIONS),
-        widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items()))
+        widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items())))
 
 
 class ProductBatchView(BatchMasterView):
     """
     Master view for product batches.
     """
-    model_class = model.ProductBatch
-    model_row_class = model.ProductBatchRow
+    model_class = ProductBatch
+    model_row_class = ProductBatchRow
     default_handler_spec = 'rattail.batch.product:ProductBatchHandler'
     route_prefix = 'batch.product'
     url_prefix = '/batches/product'
@@ -130,7 +129,7 @@ class ProductBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(ProductBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # input_filename
         if self.creating:
@@ -140,7 +139,8 @@ class ProductBatchView(BatchMasterView):
             f.set_renderer('input_filename', self.render_downloadable_file)
 
     def configure_row_grid(self, g):
-        super(ProductBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
+        model = self.model
 
         g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor))
         g.set_sorter('vendor', model.Vendor.name)
@@ -166,7 +166,7 @@ class ProductBatchView(BatchMasterView):
             return 'warning'
 
     def configure_row_form(self, f):
-        super(ProductBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         f.set_type('upc', 'gpc')
 
@@ -205,10 +205,10 @@ class ProductBatchView(BatchMasterView):
             return self.request.route_url('labels.batch.view', uuid=result.uuid)
         elif kwargs['action'] == 'make_pricing_batch':
             return self.request.route_url('batch.pricing.view', uuid=result.uuid)
-        return super(ProductBatchView, self).get_execute_success_url(batch)
+        return super().get_execute_success_url(batch)
 
     def get_row_csv_fields(self):
-        fields = super(ProductBatchView, self).get_row_csv_fields()
+        fields = super().get_row_csv_fields()
 
         if 'vendor_uuid' in fields:
             i = fields.index('vendor_uuid')
@@ -274,12 +274,12 @@ class ProductBatchView(BatchMasterView):
             data['report_name'] = (report.name or '') if report else ''
 
     def get_row_csv_row(self, row, fields):
-        csvrow = super(ProductBatchView, self).get_row_csv_row(row, fields)
+        csvrow = super().get_row_csv_row(row, fields)
         self.supplement_row_data(row, fields, csvrow)
         return csvrow
 
     def get_row_xlsx_row(self, row, fields):
-        xlrow = super(ProductBatchView, self).get_row_xlsx_row(row, fields)
+        xlrow = super().get_row_xlsx_row(row, fields)
         self.supplement_row_data(row, fields, xlrow)
         return xlrow
 
diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py
index f4692e5d..ec8da979 100644
--- a/tailbone/views/batch/vendorcatalog.py
+++ b/tailbone/views/batch/vendorcatalog.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,56 +24,65 @@
 Views for maintaining vendor catalogs
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import logging
 
-import six
-
-from rattail.db import model, api
-from rattail.vendors.catalogs import iter_catalog_parsers
+from rattail.db import model
 
 import colander
 from deform import widget as dfwidget
 from webhelpers2.html import tags
 
 from tailbone import forms
-from tailbone.db import Session
 from tailbone.views.batch import FileBatchMasterView
+from tailbone.diffs import Diff
+from tailbone.db import Session
 
 
 log = logging.getLogger(__name__)
 
 
-class VendorCatalogsView(FileBatchMasterView):
+class VendorCatalogView(FileBatchMasterView):
     """
     Master view for vendor catalog batches.
     """
-    model_class = model.VendorCatalog
-    model_row_class = model.VendorCatalogRow
+    model_class = model.VendorCatalogBatch
+    model_row_class = model.VendorCatalogBatchRow
     default_handler_spec = 'rattail.batch.vendorcatalog:VendorCatalogHandler'
+    route_prefix = 'vendorcatalogs'
     url_prefix = '/vendors/catalogs'
     template_prefix = '/batch/vendorcatalog'
-    editable = False
+    bulk_deletable = True
+    results_executable = True
     rows_bulk_deletable = True
+    has_input_file_templates = True
+    configurable = True
+
+    labels = {
+        'vendor_id': "Vendor ID",
+        'parser_key': "Parser",
+    }
 
     grid_columns = [
         'id',
         'vendor',
-        'effective',
+        'description',
         'filename',
         'rowcount',
         'created',
-        'created_by',
         'executed',
     ]
 
     form_fields = [
         'id',
-        'vendor',
         'filename',
+        'parser_key',
+        'vendor',
         'future',
         'effective',
+        'cache_products',
+        'params',
+        'description',
+        'notes',
         'created',
         'created_by',
         'rowcount',
@@ -105,16 +114,7 @@ class VendorCatalogsView(FileBatchMasterView):
         'brand_name',
         'description',
         'size',
-        'old_vendor_code',
-        'vendor_code',
-        'old_case_size',
-        'case_size',
-        'old_case_cost',
-        'case_cost',
-        'case_cost_diff',
-        'old_unit_cost',
-        'unit_cost',
-        'unit_cost_diff',
+        'is_preferred_vendor',
         'suggested_retail',
         'starts',
         'ends',
@@ -122,95 +122,200 @@ class VendorCatalogsView(FileBatchMasterView):
         'discount_ends',
         'discount_amount',
         'discount_percent',
+        'case_cost_diff',
+        'unit_cost_diff',
         'status_code',
         'status_text',
     ]
 
+    def get_input_file_templates(self):
+        return [
+            {'key': 'default',
+             'label': "Default",
+             'default_url': self.request.static_url(
+                 'tailbone:static/files/vendor_catalog_template.xlsx')},
+        ]
+
     def get_parsers(self):
         if not hasattr(self, 'parsers'):
-            self.parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display)
+            app = self.get_rattail_app()
+            vendor_handler = app.get_vendor_handler()
+            self.parsers = vendor_handler.get_supported_catalog_parsers()
         return self.parsers
 
     def configure_grid(self, g):
-        super(VendorCatalogsView, self).configure_grid(g)
-        g.joiners['vendor'] = lambda q: q.join(model.Vendor)
-        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
-                                            default_active=True, default_verb='contains')
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+        super(VendorCatalogView, self).configure_grid(g)
+        model = self.model
+
+        # nb. this batch has vendor_id and vendor_name fields, but in
+        # practice they aren't used much and normally just the vendor
+        # proper is set.  so we remove simple filters and add the
+        # custom one for (referenced) vendor name
+        g.remove_filter('vendor_id')
+        g.remove_filter('vendor_name')
+        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
+        g.set_filter('vendor', model.Vendor.name,
+                     default_active=True,
+                     default_verb='contains')
+        # and this is the same thing we are showing as grid column
+        g.set_sorter('vendor', model.Vendor.name)
+
+        # preferred filters
+        g.set_filters_sequence([
+            'id',
+            'vendor',
+            'description',
+            'executed',
+            'created',
+            'filename',
+            'future',
+            'effective',
+            'notes',
+        ])
 
         g.set_link('vendor')
         g.set_link('filename')
 
-    def get_instance_title(self, batch):
-        return six.text_type(batch.vendor)
-
     def configure_form(self, f):
-        super(VendorCatalogsView, self).configure_form(f)
-
-        # vendor
-        f.set_renderer('vendor', self.render_vendor)
-        if self.creating:
-            f.replace('vendor', 'vendor_uuid')
-            f.set_node('vendor_uuid', colander.String())
-            vendor_display = ""
-            if self.request.method == 'POST':
-                if self.request.POST.get('vendor_uuid'):
-                    vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid'])
-                    if vendor:
-                        vendor_display = six.text_type(vendor)
-            vendors_url = self.request.route_url('vendors.autocomplete')
-            f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget(
-                field_display=vendor_display, service_url=vendors_url))
-            f.set_label('vendor_uuid', "Vendor")
-        else:
-            f.set_readonly('vendor')
+        super(VendorCatalogView, self).configure_form(f)
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
 
         # filename
         f.set_label('filename', "Catalog File")
 
+        # parser_key
         if self.creating:
+            if 'parser_key' not in f:
+                f.insert_after('filename', 'parser_key')
+            parsers = self.get_parsers()
+            values = [(p.key, p.display) for p in parsers]
+            if len(values) == 1:
+                f.set_default('parser_key', parsers[0].key)
+            f.set_widget('parser_key', dfwidget.SelectWidget(values=values))
+        else:
+            f.set_readonly('parser_key')
+            f.set_renderer('parser_key', self.render_parser_key)
 
-            f.set_fields([
-                'filename',
-                'parser_key',
-                'vendor_uuid',
-                'future',
-            ])
+        # vendor
+        f.set_renderer('vendor', self.render_vendor)
+        if self.creating and 'vendor' in f:
+            f.replace('vendor', 'vendor_uuid')
+            f.set_label('vendor_uuid', "Vendor")
+            f.set_required('vendor_uuid')
+            f.set_validator('vendor_uuid', self.valid_vendor_uuid)
 
-            parser_values = [(p.key, p.display) for p in self.get_parsers()]
-            parser_values.insert(0, ('', "(please choose)"))
-            f.set_widget('parser_key', dfwidget.SelectWidget(values=parser_values))
-            f.set_label('parser_key', "File Type")
+            # should we use dropdown or autocomplete?  note that if
+            # autocomplete is to be used, we also must make sure we
+            # have an autocomplete url registered
+            use_dropdown = vendor_handler.choice_uses_dropdown()
+            if not use_dropdown:
+                try:
+                    vendors_url = self.request.route_url('vendors.autocomplete')
+                except KeyError:
+                    use_dropdown = True
 
-        # effective
-        if not self.creating:
-            f.set_readonly('effective')
+            if use_dropdown:
+                vendors = self.Session.query(model.Vendor)\
+                                      .order_by(model.Vendor.id)
+                vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id,
+                                                                vendor.name))
+                                 for vendor in vendors]
+                f.set_widget('vendor_uuid',
+                             dfwidget.SelectWidget(values=vendor_values))
+            else:
+                vendor_display = ""
+                if self.request.method == 'POST':
+                    if self.request.POST.get('vendor_uuid'):
+                        vendor = self.Session.get(model.Vendor,
+                                                  self.request.POST['vendor_uuid'])
+                        if vendor:
+                            vendor_display = str(vendor)
+                f.set_widget('vendor_uuid',
+                             forms.widgets.JQueryAutocompleteWidget(
+                                 field_display=vendor_display,
+                                 service_url=vendors_url,
+                                 ref='vendorAutocomplete',
+                                 assigned_label='vendorName',
+                                 input_callback='vendorChanged',
+                                 new_label_callback='vendorLabelChanging'))
+        else:
+            f.set_readonly('vendor')
 
-    def render_vendor(self, batch, field):
-        vendor = batch.vendor
-        if not vendor:
-            return ""
-        text = "({}) {}".format(vendor.id, vendor.name)
-        url = self.request.route_url('vendors.view', uuid=vendor.uuid)
-        return tags.link_to(text, url)
+        if self.batch_handler.allow_future():
+
+            # effective
+            f.set_type('effective', 'date_jquery')
+
+        else: # future not allowed
+            f.remove('future',
+                     'effective')
+
+        if self.creating:
+            f.set_node('cache_products', colander.Boolean())
+            f.set_type('cache_products', 'boolean')
+            f.set_helptext('cache_products',
+                           "If set, will pre-cache all products for quicker "
+                           "lookups when loading the catalog.")
+        else:
+            f.remove('cache_products')
+
+    def render_parser_key(self, batch, field):
+        key = getattr(batch, field)
+        if not key:
+            return
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+        parser = vendor_handler.get_catalog_parser(key)
+        return parser.display
+
+    def template_kwargs_create(self, **kwargs):
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+        parsers = self.get_parsers()
+        parsers_data = {}
+        for parser in parsers:
+            pdata = {'key': parser.key,
+                     'vendor_key': parser.vendor_key}
+            if parser.vendor_key:
+                vendor = vendor_handler.get_vendor(self.Session(),
+                                                   parser.vendor_key)
+                if vendor:
+                    pdata['vendor_uuid'] = vendor.uuid
+                    pdata['vendor_name'] = vendor.name
+            parsers_data[parser.key] = pdata
+        kwargs['parsers'] = parsers
+        kwargs['parsers_data'] = parsers_data
+        return kwargs
 
     def get_batch_kwargs(self, batch):
-        kwargs = super(VendorCatalogsView, self).get_batch_kwargs(batch)
+        kwargs = super(VendorCatalogView, self).get_batch_kwargs(batch)
         kwargs['parser_key'] = batch.parser_key
         if batch.vendor:
             kwargs['vendor'] = batch.vendor
         elif batch.vendor_uuid:
             kwargs['vendor_uuid'] = batch.vendor_uuid
-        kwargs['future'] = batch.future
+        if batch.vendor_id:
+            kwargs['vendor_id'] = batch.vendor_id
+        if batch.vendor_name:
+            kwargs['vendor_name'] = batch.vendor_name
+        if self.batch_handler.allow_future():
+            kwargs['future'] = batch.future
+            kwargs['effective'] = batch.effective
         return kwargs
 
+    def save_create_form(self, form):
+        batch = super(VendorCatalogView, self).save_create_form(form)
+        batch.set_param('cache_products', form.validated['cache_products'])
+        return batch
+
     def configure_row_grid(self, g):
-        super(VendorCatalogsView, self).configure_row_grid(g)
+        super(VendorCatalogView, self).configure_row_grid(g)
         batch = self.get_instance()
 
         # starts
         if not batch.future:
-            g.hide_column('starts')
+            g.remove('starts')
 
         g.set_type('old_unit_cost', 'currency')
         g.set_type('unit_cost', 'currency')
@@ -227,6 +332,11 @@ class VendorCatalogsView(FileBatchMasterView):
         g.set_label('unit_cost', "New Cost")
         g.set_label('unit_cost_diff', "Diff. $")
 
+        g.set_link('upc')
+        g.set_link('brand')
+        g.set_link('description')
+        g.set_link('vendor_code')
+
     def row_grid_extra_class(self, row, i):
         if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
             return 'warning'
@@ -234,31 +344,102 @@ class VendorCatalogsView(FileBatchMasterView):
                                row.STATUS_UPDATE_COST, # TODO: deprecate/remove this one
                                row.STATUS_CHANGE_VENDOR_ITEM_CODE,
                                row.STATUS_CHANGE_CASE_SIZE,
-                               row.STATUS_CHANGE_COST):
+                               row.STATUS_CHANGE_COST,
+                               row.STATUS_CHANGE_PRODUCT):
             return 'notice'
 
     def configure_row_form(self, f):
-        super(VendorCatalogsView, self).configure_row_form(f)
+        super(VendorCatalogView, self).configure_row_form(f)
         f.set_renderer('product', self.render_product)
+        f.set_type('upc', 'gpc')
         f.set_type('discount_percent', 'percent')
+        f.set_type('suggested_retail', 'currency')
+
+    def template_kwargs_view_row(self, **kwargs):
+        row = kwargs['instance']
+        batch = row.batch
+
+        fields = [
+            'vendor_code',
+            'case_size',
+            'case_cost',
+            'unit_cost',
+        ]
+        old_data = dict([(field, getattr(row, 'old_{}'.format(field)))
+                         for field in fields])
+        new_data = dict([(field, getattr(row, field))
+                         for field in fields])
+        kwargs['catalog_entry_diff'] = Diff(old_data, new_data, fields=fields,
+                                            monospace=True)
 
-    def template_kwargs_create(self, **kwargs):
-        parsers = self.get_parsers()
-        for parser in parsers:
-            if parser.vendor_key:
-                vendor = api.get_vendor(Session(), parser.vendor_key)
-                if vendor:
-                    parser.vendormap_value = "{{uuid: '{}', name: '{}'}}".format(
-                        vendor.uuid, vendor.name.replace("'", "\\'"))
-                else:
-                    log.warning("vendor '{}' not found for parser: {}".format(
-                        parser.vendor_key, parser.key))
-                    parser.vendormap_value = 'null'
-            else:
-                parser.vendormap_value = 'null'
-        kwargs['parsers'] = parsers
         return kwargs
 
+    def configure_get_simple_settings(self):
+        settings = super(VendorCatalogView, self).configure_get_simple_settings() or []
+        settings.extend([
+
+            # key field
+            {'section': 'rattail.batch',
+             'option': 'vendor_catalog.allow_future',
+             'type': bool},
+
+        ])
+        return settings
+
+    def configure_get_context(self):
+        context = super(VendorCatalogView, self).configure_get_context()
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+
+        Parsers = vendor_handler.get_all_catalog_parsers()
+        Supported = vendor_handler.get_supported_catalog_parsers()
+        context['catalog_parsers'] = Parsers
+        context['catalog_parsers_data'] = dict([(Parser.key, Parser in Supported)
+                                                for Parser in Parsers])
+
+        return context
+
+    def configure_gather_settings(self, data):
+        settings = super(VendorCatalogView, self).configure_gather_settings(data)
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+
+        supported = []
+        for Parser in vendor_handler.get_all_catalog_parsers():
+            name = 'catalog_parser_{}'.format(Parser.key)
+            if data.get(name) == 'true':
+                supported.append(Parser.key)
+        settings.append({'name': 'rattail.vendors.supported_catalog_parsers',
+                         'value': ', '.join(supported)})
+
+        return settings
+
+    def configure_remove_settings(self):
+        super(VendorCatalogView, self).configure_remove_settings()
+        app = self.get_rattail_app()
+
+        names = [
+            'rattail.vendors.supported_catalog_parsers',
+            'tailbone.batch.vendorcatalog.supported_parsers', # deprecated
+        ]
+
+        # nb. using thread-local session here; we do not use
+        # self.Session b/c it may not point to Rattail
+        session = Session()
+        for name in names:
+            app.delete_setting(session, name)
+
+
+# TODO: deprecate / remove this
+VendorCatalogsView = VendorCatalogView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    VendorCatalogView = kwargs.get('VendorCatalogView', base['VendorCatalogView'])
+    VendorCatalogView.defaults(config)
+
 
 def includeme(config):
-    VendorCatalogsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py
index a6777504..4815d1f4 100644
--- a/tailbone/views/batch/vendorinvoice.py
+++ b/tailbone/views/batch/vendorinvoice.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,11 +24,7 @@
 Views for maintaining vendor invoices
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
-from rattail.db import model, api
+from rattail.db import model
 from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
 
 # import formalchemy
@@ -38,13 +34,14 @@ from deform import widget as dfwidget
 from tailbone.views.batch import FileBatchMasterView
 
 
-class VendorInvoicesView(FileBatchMasterView):
+class VendorInvoiceView(FileBatchMasterView):
     """
     Master view for vendor invoice batches.
     """
-    model_class = model.VendorInvoice
-    model_row_class = model.VendorInvoiceRow
+    model_class = model.VendorInvoiceBatch
+    model_row_class = model.VendorInvoiceBatchRow
     default_handler_spec = 'rattail.batch.vendorinvoice:VendorInvoiceHandler'
+    route_prefix = 'vendorinvoices'
     url_prefix = '/vendors/invoices'
 
     grid_columns = [
@@ -88,10 +85,10 @@ class VendorInvoicesView(FileBatchMasterView):
     ]
 
     def get_instance_title(self, batch):
-        return six.text_type(batch.vendor)
+        return str(batch.vendor)
 
     def configure_grid(self, g):
-        super(VendorInvoicesView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # vendor
         g.set_joiner('vendor', lambda q: q.join(model.Vendor))
@@ -117,7 +114,7 @@ class VendorInvoicesView(FileBatchMasterView):
         g.set_link('executed', False)
 
     def configure_form(self, f):
-        super(VendorInvoicesView, self).configure_form(f)
+        super().configure_form(f)
 
         # vendor
         if self.creating:
@@ -166,13 +163,15 @@ class VendorInvoicesView(FileBatchMasterView):
     #         raise formalchemy.ValidationError(unicode(error))
 
     def get_batch_kwargs(self, batch):
-        kwargs = super(VendorInvoicesView, self).get_batch_kwargs(batch)
+        kwargs = super().get_batch_kwargs(batch)
         kwargs['parser_key'] = batch.parser_key
         return kwargs
 
     def init_batch(self, batch):
-        parser = require_invoice_parser(batch.parser_key)
-        vendor = api.get_vendor(self.Session(), parser.vendor_key)
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+        parser = require_invoice_parser(self.rattail_config, batch.parser_key)
+        vendor = vendor_handler.get_vendor(self.Session(), parser.vendor_key)
         if not vendor:
             self.request.session.flash("No vendor setting found in database for key: {}".format(parser.vendor_key))
             return False
@@ -180,7 +179,7 @@ class VendorInvoicesView(FileBatchMasterView):
         return True
 
     def configure_row_grid(self, g):
-        super(VendorInvoicesView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_label('upc', "UPC")
         g.set_label('brand_name', "Brand")
         g.set_label('shipped_cases', "Cases")
@@ -197,6 +196,9 @@ class VendorInvoicesView(FileBatchMasterView):
                                row.STATUS_UNIT_COST_DIFFERS):
             return 'notice'
 
+# TODO: deprecate / remove this
+VendorInvoicesView = VendorInvoiceView
+
 
 def includeme(config):
-    VendorInvoicesView.defaults(config)
+    VendorInvoiceView.defaults(config)
diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py
index ac995aca..7afcc567 100644
--- a/tailbone/views/bouncer.py
+++ b/tailbone/views/bouncer.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,24 +24,18 @@
 Views for Email Bounces
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import datetime
 
-import six
-
 from rattail.db import model
-from rattail.bouncer import get_handler
 from rattail.bouncer.config import get_profile_keys
 
-from pyramid.response import FileResponse
 from webhelpers2.html import HTML, tags
 
 from tailbone.views import MasterView
 
 
-class EmailBouncesView(MasterView):
+class EmailBounceView(MasterView):
     """
     Master view for email bounces.
     """
@@ -50,6 +44,7 @@ class EmailBouncesView(MasterView):
     url_prefix = '/email-bounces'
     creatable = False
     editable = False
+    downloadable = True
 
     labels = {
         'config_key': "Source",
@@ -66,24 +61,29 @@ class EmailBouncesView(MasterView):
     ]
 
     def __init__(self, request):
-        super(EmailBouncesView, self).__init__(request)
+        super().__init__(request)
         self.handler_options = sorted(get_profile_keys(self.rattail_config))
 
     def get_handler(self, bounce):
-        return get_handler(self.rattail_config, bounce.config_key)
+        app = self.get_rattail_app()
+        return app.get_bounce_handler(bounce.config_key)
 
     def configure_grid(self, g):
-        super(EmailBouncesView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         g.filters['config_key'].set_choices(self.handler_options)
         g.filters['config_key'].default_active = True
         g.filters['config_key'].default_verb = 'equal'
 
-        g.joiners['processed_by'] = lambda q: q.outerjoin(model.User)
         g.filters['processed'].default_active = True
         g.filters['processed'].default_verb = 'is_null'
-        g.filters['processed_by'] = g.make_filter('processed_by', model.User.username)
-        g.sorters['processed_by'] = g.make_sorter(model.User.username)
+
+        # processed_by
+        g.set_joiner('processed_by', lambda q: q.outerjoin(model.User))
+        g.set_sorter('processed_by', model.User.username)
+        g.set_filter('processed_by', model.User.username)
+
         g.set_sort_defaults('bounced', 'desc')
 
         g.set_label('bounce_recipient_address', "Bounced To")
@@ -93,7 +93,7 @@ class EmailBouncesView(MasterView):
         g.set_link('intended_recipient_address')
 
     def configure_form(self, f):
-        super(EmailBouncesView, self).configure_form(f)
+        super().configure_form(f)
         bounce = f.model_instance
         f.set_renderer('message', self.render_message_file)
         f.set_renderer('links', self.render_links)
@@ -142,11 +142,17 @@ class EmailBouncesView(MasterView):
         path = handler.msgpath(bounce)
         if os.path.exists(path):
             with open(path, 'rb') as f:
-                kwargs['message'] = f.read()
+                # TODO: how to determine encoding? (is utf_8 guaranteed?)
+                kwargs['message'] = f.read().decode('utf_8')
         else:
             kwargs['message'] = "(file not found)"
         return kwargs
 
+    def download_path(self, bounce, filename):
+        handler = self.get_handler(bounce)
+        return handler.msgpath(bounce)
+
+    # TODO: should require POST here
     def process(self):
         """
         View for marking a bounce as processed.
@@ -155,8 +161,9 @@ class EmailBouncesView(MasterView):
         bounce.processed = datetime.datetime.utcnow()
         bounce.processed_by = self.request.user
         self.request.session.flash("Email bounce has been marked processed.")
-        return self.redirect(self.request.route_url('emailbounces'))
+        return self.redirect(self.get_action_url('view', bounce))
 
+    # TODO: should require POST here
     def unprocess(self):
         """
         View for marking a bounce as *unprocessed*.
@@ -165,22 +172,15 @@ class EmailBouncesView(MasterView):
         bounce.processed = None
         bounce.processed_by = None
         self.request.session.flash("Email bounce has been marked UN-processed.")
-        return self.redirect(self.request.route_url('emailbounces'))
-
-    def download(self):
-        """
-        View for downloading the message file associated with a bounce.
-        """
-        bounce = self.get_instance()
-        handler = self.get_handler(bounce)
-        path = handler.msgpath(bounce)
-        response = FileResponse(path, request=self.request)
-        response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path))
-        response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"'
-        return response
+        return self.redirect(self.get_action_url('view', bounce))
 
     @classmethod
     def defaults(cls, config):
+        cls._bounce_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _bounce_defaults(cls, config):
 
         config.add_tailbone_permission_group('emailbounces', "Email Bounces", overwrite=False)
 
@@ -198,15 +198,13 @@ class EmailBouncesView(MasterView):
         config.add_tailbone_permission('emailbounces', 'emailbounces.unprocess',
                                        "Mark Email Bounce as UN-processed")
 
-        # download raw email
-        config.add_route('emailbounces.download', '/email-bounces/{uuid}/download')
-        config.add_view(cls, attr='download', route_name='emailbounces.download',
-                        permission='emailbounces.download')
-        config.add_tailbone_permission('emailbounces', 'emailbounces.download',
-                                       "Download raw message of Email Bounce")
 
-        cls._defaults(config)
+def defaults(config, **kwargs):
+    base = globals()
+
+    EmailBounceView = kwargs.get('EmailBounceView', base['EmailBounceView'])
+    EmailBounceView.defaults(config)
 
 
 def includeme(config):
-    EmailBouncesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py
index b66c3f42..109c80a7 100644
--- a/tailbone/views/brands.py
+++ b/tailbone/views/brands.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -28,16 +28,18 @@ from __future__ import unicode_literals, absolute_import
 
 from rattail.db import model
 
-from tailbone.views import MasterView, AutocompleteView
+from tailbone.views import MasterView
 
 
-class BrandsView(MasterView):
+class BrandView(MasterView):
     """
     Master view for the Brand class.
     """
     model_class = model.Brand
     has_versions = True
     bulk_deletable = True
+    results_downloadable = True
+    supports_autocomplete = True
 
     mergeable = True
     merge_additive_fields = [
@@ -58,8 +60,25 @@ class BrandsView(MasterView):
         'confirmed',
     ]
 
+    has_rows = True
+    model_row_class = model.Product
+
+    row_labels = {
+        'upc': "UPC",
+    }
+
+    row_grid_columns = [
+        'upc',
+        'description',
+        'size',
+        'department',
+        'vendor',
+        'regular_price',
+        'current_price',
+    ]
+
     def configure_grid(self, g):
-        super(BrandsView, self).configure_grid(g)
+        super(BrandView, self).configure_grid(g)
 
         # name
         g.filters['name'].default_active = True
@@ -70,6 +89,32 @@ class BrandsView(MasterView):
         # confirmed
         g.set_type('confirmed', 'boolean')
 
+    def get_row_data(self, brand):
+        return self.Session.query(model.Product)\
+                           .filter(model.Product.brand == brand)
+
+    def get_parent(self, product):
+        return product.brand
+
+    def configure_row_grid(self, g):
+        super(BrandView, self).configure_row_grid(g)
+
+        app = self.get_rattail_app()
+        self.handler = app.get_products_handler()
+        g.set_renderer('regular_price', self.render_price)
+        g.set_renderer('current_price', self.render_price)
+
+        g.set_sort_defaults('upc')
+
+    def render_price(self, product, field):
+        if not product.not_for_sale:
+            price = product[field]
+            if price:
+                return self.handler.render_price(price)
+
+    def row_view_action_url(self, product, i):
+        return self.request.route_url('products.view', uuid=product.uuid)
+
     def get_merge_data(self, brand):
         product_count = self.Session.query(model.Product)\
                                     .filter(model.Product.brand == brand)\
@@ -91,17 +136,12 @@ class BrandsView(MasterView):
         self.Session.delete(removing)
 
 
-class BrandsAutocomplete(AutocompleteView):
+def defaults(config, **kwargs):
+    base = globals()
 
-    mapped_class = model.Brand
-    fieldname = 'name'
+    BrandView = kwargs.get('BrandView', base['BrandView'])
+    BrandView.defaults(config)
 
 
 def includeme(config):
-
-    # autocomplete
-    config.add_route('brands.autocomplete', '/brands/autocomplete')
-    config.add_view(BrandsAutocomplete, route_name='brands.autocomplete',
-                    renderer='json', permission='brands.list')
-
-    BrandsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py
index 649ecfeb..941257b8 100644
--- a/tailbone/views/categories.py
+++ b/tailbone/views/categories.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -32,7 +32,7 @@ from tailbone import forms
 from tailbone.views import MasterView
 
 
-class CategoriesView(MasterView):
+class CategoryView(MasterView):
     """
     Master view for the Category class.
     """
@@ -40,7 +40,7 @@ class CategoriesView(MasterView):
     model_title_plural = "Categories"
     route_prefix = 'categories'
     has_versions = True
-    results_downloadable_xlsx = True
+    results_downloadable = True
 
     grid_columns = [
         'code',
@@ -55,7 +55,7 @@ class CategoriesView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(CategoriesView, self).configure_grid(g)
+        super(CategoryView, self).configure_grid(g)
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
 
@@ -68,7 +68,7 @@ class CategoriesView(MasterView):
         g.set_link('name')
 
     def get_xlsx_fields(self):
-        fields = super(CategoriesView, self).get_xlsx_fields()
+        fields = super(CategoryView, self).get_xlsx_fields()
         fields.extend([
             'department_number',
             'department_name',
@@ -76,7 +76,7 @@ class CategoriesView(MasterView):
         return fields
 
     def get_xlsx_row(self, category, fields):
-        row = super(CategoriesView, self).get_xlsx_row(category, fields)
+        row = super(CategoryView, self).get_xlsx_row(category, fields)
         dept = category.department
         if dept:
             row['department_number'] = dept.number
@@ -87,7 +87,7 @@ class CategoriesView(MasterView):
         return row
 
     def configure_form(self, f):
-        super(CategoriesView, self).configure_form(f)
+        super(CategoryView, self).configure_form(f)
 
         # department
         if self.creating or self.editing:
@@ -106,6 +106,16 @@ class CategoriesView(MasterView):
         return [(dept.uuid, "{} {}".format(dept.number, dept.name))
                 for dept in departments]
 
+# TODO: deprecate / remove this
+CategoriesView = CategoryView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    CategoryView = kwargs.get('CategoryView', base['CategoryView'])
+    CategoryView.defaults(config)
+
 
 def includeme(config):
-    CategoriesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 39e938b6..f4d98c05 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,21 +24,14 @@
 Various common views
 """
 
-from __future__ import unicode_literals, absolute_import
+import os
+import warnings
+from collections import OrderedDict
 
-import six
-
-import rattail
-from rattail.db import model
 from rattail.batch import consume_batch_id
-from rattail.mail import send_email
-from rattail.util import OrderedDict
+from rattail.util import get_pkg_version, simple_error
 from rattail.files import resource_path
 
-from pyramid import httpexceptions
-from pyramid.response import Response
-
-import tailbone
 from tailbone import forms
 from tailbone.forms.common import Feedback
 from tailbone.db import Session
@@ -51,33 +44,68 @@ class CommonView(View):
     """
     Base class for common views; override as needed.
     """
-    project_title = "Tailbone"
-    project_version = tailbone.__version__
     robots_txt_path = resource_path('tailbone.static:robots.txt')
 
-    def home(self, mobile=False):
+    def home(self, **kwargs):
         """
         Home page view.
         """
-        if not mobile and not self.request.user:
-            if self.rattail_config.getbool('tailbone', 'login_is_home', default=True):
-                raise self.redirect(self.request.route_url('login'))
+        app = self.get_rattail_app()
 
-        image_url = self.rattail_config.get(
-            'tailbone', 'main_image_url',
-            default=self.request.static_url('tailbone:static/img/home_logo.png'))
+        # maybe auto-redirect anons to login
+        if not self.request.user:
+            redirect = self.config.get_bool('wuttaweb.home_redirect_to_login')
+            if redirect is None:
+                redirect = self.config.get_bool('tailbone.login_is_home')
+                if redirect is not None:
+                    warnings.warn("tailbone.login_is_home setting is deprecated; "
+                                  "please set wuttaweb.home_redirect_to_login instead",
+                                  DeprecationWarning)
+                else:
+                    # TODO: this is opposite of upstream default, should change
+                    redirect = True
+            if redirect:
+                return self.redirect(self.request.route_url('login'))
+
+        image_url = self.config.get('wuttaweb.logo_url')
+        if not image_url:
+            image_url = self.config.get('tailbone.main_image_url')
+            if image_url:
+                warnings.warn("tailbone.main_image_url setting is deprecated; "
+                              "please set wuttaweb.logo_url instead",
+                              DeprecationWarning)
+            else:
+                image_url = self.request.static_url('tailbone:static/img/home_logo.png')
 
         context = {
             'image_url': image_url,
-            'use_buefy': self.get_use_buefy(),
+            'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
 
-        if self.expose_quickie_search:
+        if self.should_expose_quickie_search():
             context['quickie'] = self.get_quickie_context()
 
         return context
 
+    # nb. this is only invoked from home() view
+    def should_expose_quickie_search(self):
+        if self.expose_quickie_search:
+            return True
+        # TODO: for now we are assuming *people* search
+        app = self.get_rattail_app()
+        return app.get_people_handler().should_expose_quickie_search()
+
+    def get_quickie_perm(self):
+        return 'people.quickie'
+
+    def get_quickie_url(self):
+        return self.request.route_url('people.quickie')
+
+    def get_quickie_placeholder(self):
+        app = self.get_rattail_app()
+        return app.get_people_handler().get_quickie_search_placeholder()
+
     def robots_txt(self):
         """
         Returns a basic 'robots.txt' response
@@ -85,35 +113,39 @@ class CommonView(View):
         with open(self.robots_txt_path, 'rt') as f:
             content = f.read()
         response = self.request.response
-        if six.PY3:
-            response.text = content
-            response.content_type = 'text/plain'
-        else:
-            response.body = content
-            response.content_type = b'text/plain'
+        response.text = content
+        response.content_type = 'text/plain'
         return response
 
-    def mobile_home(self):
-        """
-        Home page view for mobile.
-        """
-        return self.home(mobile=True)
+    def get_project_title(self):
+        app = self.get_rattail_app()
+        return app.get_title()
+
+    def get_project_version(self):
+
+        # TODO: deprecate this
+        if hasattr(self, 'project_version'):
+            return self.project_version
+
+        app = self.get_rattail_app()
+        return app.get_version()
 
     def exception(self):
         """
         Generic exception view
         """
-        return {'project_title': self.project_title}
+        return {'project_title': self.get_project_title()}
 
     def about(self):
         """
         Generic view to show "about project" info page.
         """
+        app = self.get_rattail_app()
         return {
-            'project_title': self.project_title,
-            'project_version': self.project_version,
+            'project_title': self.get_project_title(),
+            'project_version': self.get_project_version(),
             'packages': self.get_packages(),
-            'use_buefy': self.get_use_buefy(),
+            'index_title': app.get_node_title(),
         }
 
     def get_packages(self):
@@ -122,8 +154,8 @@ class CommonView(View):
         'about' page.
         """
         return OrderedDict([
-            ('rattail', rattail.__version__),
-            ('Tailbone', tailbone.__version__),
+            ('rattail', get_pkg_version('rattail')),
+            ('Tailbone', get_pkg_version('Tailbone')),
         ])
 
     def change_theme(self):
@@ -138,9 +170,8 @@ class CommonView(View):
             except Exception as error:
                 msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error)
                 self.request.session.flash(msg, 'error')
-            else:
-                self.request.session.flash("App theme has been changed to: {}".format(theme))
-        return self.redirect(self.request.get_referrer())
+        referrer = self.request.params.get('referrer') or self.request.get_referrer()
+        return self.redirect(referrer)
 
     def change_db_engine(self):
         """
@@ -160,23 +191,20 @@ class CommonView(View):
         """
         Generic view to handle the user feedback form.
         """
+        app = self.get_rattail_app()
+        model = self.model
         schema = Feedback().bind(session=Session())
         form = forms.Form(schema=schema, request=self.request)
-        if form.validate(newstyle=True):
+        if form.validate():
             data = dict(form.validated)
             if data['user']:
-                data['user'] = Session.query(model.User).get(data['user'])
+                data['user'] = Session.get(model.User, data['user'])
                 data['user_url'] = self.request.route_url('users.view', uuid=data['user'].uuid)
             data['client_ip'] = self.request.client_addr
-            send_email(self.rattail_config, 'user_feedback', data=data)
+            app.send_email('user_feedback', data=data)
             return {'ok': True}
-        return {'error': "Form did not validate!"}
-
-    def mobile_feedback(self):
-        """
-        Generic view to handle the user feedback form on mobile.
-        """
-        return self.feedback()
+        dform = form.make_deform_form()
+        return {'error': str(dform.error)}
 
     def consume_batch_id(self):
         """
@@ -193,6 +221,75 @@ class CommonView(View):
         """
         raise Exception("Congratulations, you have triggered a bogus error.")
 
+    def poser_setup(self):
+        if not self.request.is_root:
+            raise self.forbidden()
+
+        app = self.get_rattail_app()
+        app_title = app.get_title()
+        poser_handler = app.get_poser_handler()
+        poser_dir = poser_handler.get_default_poser_dir()
+        poser_dir_exists = os.path.isdir(poser_dir)
+
+        if self.request.method == 'POST':
+
+            # maybe refresh poser dir
+            if self.request.POST.get('action') == 'refresh':
+                poser_handler.refresh_poser_dir()
+                self.request.session.flash("Poser folder has been refreshed.")
+
+            else: # otherwise make poser dir
+
+                if poser_dir_exists:
+                    self.request.session.flash("Poser folder already exists!", 'error')
+                else:
+                    try:
+                        path = poser_handler.make_poser_dir()
+                    except Exception as error:
+                        self.request.session.flash(simple_error(error), 'error')
+                    else:
+                        self.request.session.flash("Poser folder created at:  {}".format(path))
+                        self.request.session.flash("Please restart the web app!", 'warning')
+                        return self.redirect(self.request.route_url('home'))
+
+        try:
+            from poser import reports
+            reports_error = None
+        except Exception as error:
+            reports = None
+            reports_error = simple_error(error)
+
+        try:
+            from poser.web import views
+            views_error = None
+        except Exception as error:
+            views = None
+            views_error = simple_error(error)
+
+        try:
+            import poser
+            poser_error = None
+        except Exception as error:
+            poser = None
+            poser_error = simple_error(error)
+
+        return {
+            'app_title': app_title,
+            'index_title': app_title,
+            'poser_dir': poser_dir,
+            'poser_dir_exists': poser_dir_exists,
+            'poser_imported': {
+                'poser': poser,
+                'reports': reports,
+                'views': views,
+            },
+            'poser_import_errors': {
+                'poser': poser_error,
+                'reports': reports_error,
+                'views': views_error,
+            },
+        }
+
     @classmethod
     def defaults(cls, config):
         cls._defaults(config)
@@ -200,7 +297,6 @@ class CommonView(View):
     @classmethod
     def _defaults(cls, config):
         rattail_config = config.registry.settings.get('rattail_config')
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
 
         # auto-correct URLs which require trailing slash
         config.add_notfound_view(cls, attr='notfound', append_slash=True)
@@ -211,13 +307,17 @@ class CommonView(View):
 
         # permissions
         config.add_tailbone_permission_group('common', "(common)", overwrite=False)
+        config.add_tailbone_permission('common', 'common.edit_help',
+                                       "Edit help info for *any* page")
+
+        # API swagger
+        if rattail_config.getbool('tailbone', 'expose_api_swagger'):
+            config.add_tailbone_permission('common', 'common.api_swagger',
+                                           "Explore the API with Swagger tools")
 
         # home
         config.add_route('home', '/')
         config.add_view(cls, attr='home', route_name='home', renderer='/home.mako')
-        if legacy_mobile:
-            config.add_route('mobile.home', '/mobile/')
-            config.add_view(cls, attr='mobile_home', route_name='mobile.home', renderer='/mobile/home.mako')
 
         # robots.txt
         config.add_route('robots.txt', '/robots.txt')
@@ -226,15 +326,14 @@ class CommonView(View):
         # about
         config.add_route('about', '/about')
         config.add_view(cls, attr='about', route_name='about', renderer='/about.mako')
-        if legacy_mobile:
-            config.add_route('mobile.about', '/mobile/about')
-            config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako')
 
         # change db engine
         config.add_tailbone_permission('common', 'common.change_db_engine',
                                        "Change which Database Engine is active (for user)")
         config.add_route('change_db_engine', '/change-db-engine', request_method='POST')
-        config.add_view(cls, attr='change_db_engine', route_name='change_db_engine')
+        config.add_view(cls, attr='change_db_engine',
+                        route_name='change_db_engine',
+                        permission='common.change_db_engine')
 
         # change theme
         config.add_tailbone_permission('common', 'common.change_app_theme',
@@ -248,10 +347,6 @@ class CommonView(View):
         config.add_route('feedback', '/feedback', request_method='POST')
         config.add_view(cls, attr='feedback', route_name='feedback',
                         renderer='json', permission='common.feedback')
-        if legacy_mobile:
-            config.add_route('mobile.feedback', '/mobile/feedback', request_method='POST')
-            config.add_view(cls, attr='mobile_feedback', route_name='mobile.feedback',
-                            renderer='json', permission='common.feedback')
 
         # consume batch ID
         config.add_tailbone_permission('common', 'common.consume_batch_id',
@@ -263,6 +358,21 @@ class CommonView(View):
         config.add_route('bogus_error', '/bogus-error')
         config.add_view(cls, attr='bogus_error', route_name='bogus_error', permission='errors.bogus')
 
+        # make poser dir
+        config.add_route('poser_setup', '/poser-setup')
+        config.add_view(cls, attr='poser_setup',
+                        route_name='poser_setup',
+                        renderer='/poser/setup.mako',
+                        # nb. root only
+                        permission='admin')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    CommonView = kwargs.get('CommonView', base['CommonView'])
+    CommonView.defaults(config)
+
 
 def includeme(config):
-    CommonView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/core.py b/tailbone/views/core.py
index a3152a8b..88b2519f 100644
--- a/tailbone/views/core.py
+++ b/tailbone/views/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,16 +24,8 @@
 Base View Class
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 
-import six
-
-from rattail.db import model
-from rattail.core import Object
-from rattail.util import progress_loop
-
 from pyramid import httpexceptions
 from pyramid.renderers import render_to_response
 from pyramid.response import FileResponse
@@ -41,11 +33,10 @@ from pyramid.response import FileResponse
 from tailbone.db import Session
 from tailbone.auth import logout_user
 from tailbone.progress import SessionProgress
-from tailbone.util import should_use_buefy
-from tailbone.config import legacy_mobile_enabled
+from tailbone.config import protected_usernames
 
 
-class View(object):
+class View:
     """
     Base class for all class-based views.
     """
@@ -67,7 +58,10 @@ class View(object):
 
         config = self.rattail_config
         if config:
-            self.enum = config.get_enum()
+            self.config = config
+            self.app = self.config.get_app()
+            self.model = self.app.model
+            self.enum = self.app.enum
 
     @property
     def rattail_config(self):
@@ -76,6 +70,14 @@ class View(object):
         """
         return getattr(self.request, 'rattail_config', None)
 
+    def get_rattail_app(self):
+        """
+        Returns the  Rattail ``AppHandler`` instance, creating it if necessary.
+        """
+        if not hasattr(self, 'rattail_app'):
+            self.rattail_app = self.rattail_config.get_app()
+        return self.rattail_app
+
     def forbidden(self):
         """
         Convenience method, to raise a HTTP 403 Forbidden exception.
@@ -85,30 +87,30 @@ class View(object):
     def notfound(self):
         return httpexceptions.HTTPNotFound()
     
-    def get_use_buefy(self):
-        """
-        Returns a flag indicating whether or not the current theme supports
-        (and therefore should use) the Buefy JS library.
-        """
-        return should_use_buefy(self.request)
-
-    @classmethod
-    def legacy_mobile_enabled(cls, rattail_config):
-        """
-        Returns the boolean setting indicating whether the old / "legacy"
-        (jQuery) mobile app/site should be exposed.
-        """
-        return legacy_mobile_enabled(rattail_config)
-
     def late_login_user(self):
         """
         Returns the :class:`rattail:rattail.db.model.User` instance
         corresponding to the "late login" form data (if any), or ``None``.
         """
+        model = self.model
         if self.request.method == 'POST':
             uuid = self.request.POST.get('late-login-user')
             if uuid:
-                return Session.query(model.User).get(uuid)
+                return Session.get(model.User, uuid)
+
+    def user_is_protected(self, user):
+        """
+        This logic will consult the settings for a list of "protected"
+        usernames, which should require root privileges to edit.  If the given
+        ``user`` object is represented in this list, it is considered to be
+        protected and this method will return ``True``; otherwise it returns
+        ``False``.
+        """
+        if not hasattr(self, 'protected_usernames'):
+            self.protected_usernames = protected_usernames(self.rattail_config)
+        if self.protected_usernames and user.username in self.protected_usernames:
+            return True
+        return False
 
     def redirect(self, url, **kwargs):
         """
@@ -117,14 +119,15 @@ class View(object):
         return httpexceptions.HTTPFound(location=url, **kwargs)
 
     def progress_loop(self, func, items, factory, *args, **kwargs):
-        return progress_loop(func, items, factory, *args, **kwargs)
+        app = self.get_rattail_app()
+        return app.progress_loop(func, items, factory, *args, **kwargs)
 
-    def make_progress(self, key):
+    def make_progress(self, key, **kwargs):
         """
         Create and return a :class:`tailbone.progress.SessionProgress`
         instance, with the given key.
         """
-        return SessionProgress(self.request, key)
+        return SessionProgress(self.request, key, **kwargs)
 
     # TODO: this signature seems wonky
     def render_progress(self, progress, kwargs, template=None):
@@ -144,7 +147,7 @@ class View(object):
         return render_to_response('json', data,
                                   request=self.request)
 
-    def file_response(self, path):
+    def file_response(self, path, filename=None, attachment=True):
         """
         Returns a generic FileResponse from the given path
         """
@@ -152,14 +155,18 @@ class View(object):
             return self.notfound()
         response = FileResponse(path, request=self.request)
         response.content_length = os.path.getsize(path)
-        filename = os.path.basename(path)
-        if six.PY2:
-            filename = filename.encode('ascii', 'replace')
-        response.content_disposition = str('attachment; filename="{}"'.format(filename))
+        if attachment:
+            if not filename:
+                filename = os.path.basename(path)
+            response.content_disposition = str('attachment; filename="{}"'.format(filename))
         return response
 
+    def should_expose_quickie_search(self):
+        return self.expose_quickie_search
+
     def get_quickie_context(self):
-        return Object(
+        app = self.get_rattail_app()
+        return app.make_object(
             url=self.get_quickie_url(),
             perm=self.get_quickie_perm(),
             placeholder=self.get_quickie_placeholder())
diff --git a/tailbone/views/customergroups.py b/tailbone/views/customergroups.py
index 5c6892d5..98cea8e0 100644
--- a/tailbone/views/customergroups.py
+++ b/tailbone/views/customergroups.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -32,7 +32,7 @@ from tailbone.db import Session
 from tailbone.views import MasterView
 
 
-class CustomerGroupsView(MasterView):
+class CustomerGroupView(MasterView):
     """
     Master view for the CustomerGroup class.
     """
@@ -54,7 +54,7 @@ class CustomerGroupsView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(CustomerGroupsView, self).configure_grid(g)
+        super(CustomerGroupView, self).configure_grid(g)
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
         g.set_sort_defaults('name')
@@ -76,6 +76,16 @@ class CustomerGroupsView(MasterView):
 
         cls._defaults(config)
 
+# TODO: deprecate / remove this
+CustomerGroupsView = CustomerGroupView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    CustomerGroupView = kwargs.get('CustomerGroupView', base['CustomerGroupView'])
+    CustomerGroupView.defaults(config)
+
 
 def includeme(config):
-    CustomerGroupsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py
index a5cf963a..7e49ccef 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,11 +24,8 @@
 Customer Views
 """
 
-from __future__ import unicode_literals, absolute_import
+from collections import OrderedDict
 
-import re
-
-import six
 import sqlalchemy as sa
 from sqlalchemy import orm
 
@@ -38,27 +35,30 @@ from webhelpers2.html import HTML, tags
 
 from tailbone import grids
 from tailbone.db import Session
-from tailbone.views import MasterView, AutocompleteView
+from tailbone.views import MasterView
 
-from rattail.db import model
+from rattail.db.model import Customer, CustomerShopper, PendingCustomer
 
 
-class CustomersView(MasterView):
+class CustomerView(MasterView):
     """
     Master view for the Customer class.
     """
-    model_class = model.Customer
+    model_class = Customer
     is_contact = True
     has_versions = True
-    supports_mobile = True
+    results_downloadable = True
     people_detachable = True
     touchable = True
+    supports_autocomplete = True
+    configurable = True
 
     # whether to show "view full profile" helper for customer view
     show_profiles_helper = True
 
     labels = {
         'id': "ID",
+        'name': "Account Name",
         'default_phone': "Phone Number",
         'default_email': "Email Address",
         'default_address': "Physical Address",
@@ -67,17 +67,16 @@ class CustomersView(MasterView):
     }
 
     grid_columns = [
-        'id',
-        'number',
+        '_customer_key_',
         'name',
         'phone',
         'email',
     ]
 
     form_fields = [
-        'id',
-        'number',
+        '_customer_key_',
         'name',
+        'account_holder',
         'default_phone',
         'default_address',
         'address_street',
@@ -90,83 +89,168 @@ class CustomersView(MasterView):
         'wholesale',
         'active_in_pos',
         'active_in_pos_sticky',
+        'shoppers',
         'people',
         'groups',
         'members',
     ]
 
-    mobile_form_fields = [
-        'id',
-        'name',
-        'default_phone',
-        'default_email',
-        'default_address',
-        'email_preference',
-        'wholesale',
-        'active_in_pos',
-        'active_in_pos_sticky',
-        'people',
-        'groups',
+    mergeable = True
+
+    merge_coalesce_fields = [
+        'email_addresses',
+        'phone_numbers',
     ]
 
+    merge_fields = merge_coalesce_fields + [
+        'uuid',
+        'name',
+    ]
+
+    def should_expose_quickie_search(self):
+        if self.expose_quickie_search:
+            return True
+        app = self.get_rattail_app()
+        return app.get_people_handler().should_expose_quickie_search()
+
+    def get_quickie_perm(self):
+        return 'people.quickie'
+
+    def get_quickie_url(self):
+        return self.request.route_url('people.quickie')
+
+    def get_quickie_placeholder(self):
+        app = self.get_rattail_app()
+        return app.get_people_handler().get_quickie_search_placeholder()
+
+    def get_expose_active_in_pos(self):
+        if not hasattr(self, '_expose_active_in_pos'):
+            self._expose_active_in_pos = self.rattail_config.getbool(
+                'rattail', 'customers.active_in_pos',
+                default=False)
+        return self._expose_active_in_pos
+
+    # TODO: this is duplicated in people view module
+    def should_expose_shoppers(self):
+        return self.rattail_config.getbool('rattail',
+                                           'customers.expose_shoppers',
+                                           default=True)
+
+    # TODO: this is duplicated in people view module
+    def should_expose_people(self):
+        return self.rattail_config.getbool('rattail',
+                                           'customers.expose_people',
+                                           default=True)
+
+    def query(self, session):
+        query = super().query(session)
+        app = self.get_rattail_app()
+        model = self.model
+        query = query.outerjoin(model.Person,
+                                model.Person.uuid == model.Customer.account_holder_uuid)
+        return query
+
     def configure_grid(self, g):
-        super(CustomersView, self).configure_grid(g)
+        super().configure_grid(g)
+        app = self.get_rattail_app()
+        model = self.model
+        route_prefix = self.get_route_prefix()
+
+        # customer key
+        field = self.get_customer_key_field()
+        g.filters[field].default_active = True
+        g.filters[field].default_verb = 'equal'
+        g.set_sort_defaults(field)
+        g.set_link(field)
 
         # name
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
-        g.set_sort_defaults('name')
 
         # phone
+        g.set_label('phone', "Phone Number")
         g.set_joiner('phone', lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_(
             model.CustomerPhoneNumber.parent_uuid == model.Customer.uuid,
             model.CustomerPhoneNumber.preference == 1)))
-        g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.CustomerPhoneNumber.number, d)())
+        g.set_sorter('phone', model.CustomerPhoneNumber.number)
         g.set_filter('phone', model.CustomerPhoneNumber.number,
                      # label="Phone Number",
                      factory=grids.filters.AlchemyPhoneNumberFilter)
-        g.set_label('phone', "Phone Number")
 
         # email
+        g.set_label('email', "Email Address")
         g.set_joiner('email', lambda q: q.outerjoin(model.CustomerEmailAddress, sa.and_(
             model.CustomerEmailAddress.parent_uuid == model.Customer.uuid,
             model.CustomerEmailAddress.preference == 1)))
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.CustomerEmailAddress.address, d)())
+        g.set_sorter('email', model.CustomerEmailAddress.address)
         g.set_filter('email', model.CustomerEmailAddress.address)#, label="Email Address")
-        g.set_label('email', "Email Address")
 
         # email_preference
         g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE)
 
-        # person
-        g.set_joiner('person', lambda q:
-                     q.outerjoin(model.CustomerPerson,
-                                 sa.and_(
-                                     model.CustomerPerson.customer_uuid == model.Customer.uuid,
-                                     model.CustomerPerson.ordinal == 1))\
-                     .outerjoin(model.Person))
-        g.set_sorter('person', model.Person.display_name)
-        g.set_renderer('person', self.grid_render_person)
+        # account_holder_*_name
+        g.set_filter('account_holder_first_name', model.Person.first_name)
+        g.set_filter('account_holder_last_name', model.Person.last_name)
+
+        # person
+        g.set_renderer('person', self.grid_render_person)
+        g.set_sorter('person', model.Person.display_name)
+
+        # active_in_pos
+        if self.get_expose_active_in_pos():
+            g.filters['active_in_pos'].default_active = True
+            g.filters['active_in_pos'].default_verb = 'is_true'
+
+        if (self.request.has_perm('people.view_profile')
+            and self.should_link_straight_to_profile()):
+
+            # add View Raw action
+            url = lambda r, i: self.request.route_url(
+                f'{route_prefix}.view', **self.get_action_route_kwargs(r))
+            # nb. insert to slot 1, just after normal View action
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
 
-        g.set_link('id')
-        g.set_link('number')
         g.set_link('name')
         g.set_link('person')
         g.set_link('email')
 
-    def get_mobile_data(self, session=None):
-        # TODO: hacky!
-        return self.get_data(session=session).order_by(model.Customer.name)
+    def default_view_url(self):
+        if (self.request.has_perm('people.view_profile')
+            and self.should_link_straight_to_profile()):
+            app = self.get_rattail_app()
+
+            def url(customer, i):
+                person = app.get_person(customer)
+                if person:
+                    return self.request.route_url(
+                        'people.view_profile', uuid=person.uuid,
+                        _anchor='customer')
+                return self.get_action_url('view', customer)
+
+            return url
+
+        return super().default_view_url()
+
+    def should_link_straight_to_profile(self):
+        return self.rattail_config.getbool('rattail',
+                                           'customers.straight_to_profile',
+                                           default=False)
+
+    def grid_extra_class(self, customer, i):
+        if self.get_expose_active_in_pos():
+            if not customer.active_in_pos:
+                return 'warning'
 
     def get_instance(self):
         try:
-            instance = super(CustomersView, self).get_instance()
+            instance = super().get_instance()
         except HTTPNotFound:
             pass
         else:
             if instance:
                 return instance
 
+        model = self.model
         key = self.request.matchdict['uuid']
 
         # search by Customer.id
@@ -177,26 +261,35 @@ class CustomersView(MasterView):
             return instance
 
         # search by CustomerPerson.uuid
-        instance = self.Session.query(model.CustomerPerson).get(key)
+        instance = self.Session.get(model.CustomerPerson, key)
         if instance:
             return instance.customer
 
         # search by CustomerGroupAssignment.uuid
-        instance = self.Session.query(model.CustomerGroupAssignment).get(key)
+        instance = self.Session.get(model.CustomerGroupAssignment, key)
         if instance:
             return instance.customer
 
-        raise HTTPNotFound
+        raise self.notfound()
 
-    def configure_common_form(self, f):
-        super(CustomersView, self).configure_common_form(f)
+    def configure_form(self, f):
+        super().configure_form(f)
         customer = f.model_instance
         permission_prefix = self.get_permission_prefix()
 
+        # account_holder
+        if self.creating:
+            f.remove_field('account_holder')
+        else:
+            f.set_readonly('account_holder')
+            f.set_renderer('account_holder', self.render_person)
+
+        # default_email
         f.set_renderer('default_email', self.render_default_email)
         if not self.creating and customer.emails:
             f.set_default('default_email', customer.emails[0].address)
 
+        # default_phone
         f.set_renderer('default_phone', self.render_default_phone)
         if not self.creating and customer.phones:
             f.set_default('default_phone', customer.phones[0].number)
@@ -223,6 +316,7 @@ class CustomersView(MasterView):
             f.set_default('address_state', addr.state)
             f.set_default('address_zipcode', addr.zipcode)
 
+        # email_preference
         f.set_enum('email_preference', self.enum.EMAIL_PREFERENCE)
         preferences = list(self.enum.EMAIL_PREFERENCE.items())
         preferences.insert(0, ('', "(no preference)"))
@@ -235,14 +329,23 @@ class CustomersView(MasterView):
             f.set_readonly('person')
             f.set_renderer('person', self.form_render_person)
 
-        # people
-        if self.creating:
-            f.remove_field('people')
-        elif self.viewing and self.request.has_perm('{}.detach_person'.format(permission_prefix)):
-            f.set_renderer('people', self.render_people_removable)
+        # shoppers
+        if self.should_expose_shoppers():
+            if self.viewing:
+                f.set_renderer('shoppers', self.render_shoppers)
+            else:
+                f.remove('shoppers')
         else:
-            f.set_renderer('people', self.render_people)
-            f.set_readonly('people')
+            f.remove('shoppers')
+
+        # people
+        if self.should_expose_people():
+            if self.viewing:
+                f.set_renderer('people', self.render_people)
+            else:
+                f.remove('people')
+        else:
+            f.remove('people')
 
         # groups
         if self.creating:
@@ -251,10 +354,10 @@ class CustomersView(MasterView):
             f.set_renderer('groups', self.render_groups)
             f.set_readonly('groups')
 
-    def configure_form(self, f):
-        super(CustomersView, self).configure_form(f)
-        customer = f.model_instance
-        permission_prefix = self.get_permission_prefix()
+        # active_in_pos*
+        if not self.get_expose_active_in_pos():
+            f.remove('active_in_pos',
+                     'active_in_pos_sticky')
 
         # members
         if self.creating:
@@ -264,10 +367,76 @@ class CustomersView(MasterView):
             f.set_readonly('members')
 
     def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        customer = kwargs['instance']
+
+        kwargs['expose_shoppers'] = self.should_expose_shoppers()
+        if kwargs['expose_shoppers']:
+            shoppers = []
+            for shopper in customer.shoppers:
+                person = shopper.person
+                active = None
+                if shopper.active is not None:
+                    active = "Yes" if shopper.active else "No"
+                data = {
+                    'uuid': shopper.uuid,
+                    'shopper_number': shopper.shopper_number,
+                    'first_name': person.first_name,
+                    'last_name': person.last_name,
+                    'full_name': person.display_name,
+                    'phone': person.first_phone_number(),
+                    'email': person.first_email_address(),
+                    'active': active,
+                }
+                shoppers.append(data)
+            kwargs['shoppers_data'] = shoppers
+
+        kwargs['expose_people'] = self.should_expose_people()
+        if kwargs['expose_people']:
+            people = []
+            for person in customer.people:
+                data = {
+                    'uuid': person.uuid,
+                    'full_name': person.display_name,
+                    'first_name': person.first_name,
+                    'last_name': person.last_name,
+                    '_action_url_view': self.request.route_url('people.view',
+                                                               uuid=person.uuid),
+                }
+                if self.editable and self.request.has_perm('people.edit'):
+                    data['_action_url_edit'] = self.request.route_url(
+                        'people.edit',
+                        uuid=person.uuid)
+                if self.people_detachable and self.has_perm('detach_person'):
+                    data['_action_url_detach'] = self.request.route_url(
+                        'customers.detach_person',
+                        uuid=customer.uuid,
+                        person_uuid=person.uuid)
+                people.append(data)
+            kwargs['people_data'] = people
+
         kwargs['show_profiles_helper'] = self.show_profiles_helper
+        if kwargs['show_profiles_helper']:
+            people = OrderedDict()
+
+            if customer.account_holder:
+                person = customer.account_holder
+                people.setdefault(person.uuid, person)
+
+            for shopper in customer.shoppers:
+                if shopper.active:
+                    person = shopper.person
+                    people.setdefault(person.uuid, person)
+
+            for person in customer.people:
+                people.setdefault(person.uuid, person)
+
+            kwargs['show_profiles_people'] = list(people.values())
+
         return kwargs
 
     def unique_id(self, node, value):
+        model = self.model
         query = self.Session.query(model.Customer)\
                             .filter(model.Customer.id == value)
         if self.editing:
@@ -278,65 +447,82 @@ class CustomersView(MasterView):
 
     def render_default_address(self, customer, field):
         if customer.addresses:
-            return six.text_type(customer.addresses[0])
+            return str(customer.addresses[0])
 
     def grid_render_person(self, customer, field):
         person = getattr(customer, field)
         if not person:
             return ""
-        return six.text_type(person)
+        return str(person)
 
     def form_render_person(self, customer, field):
         person = getattr(customer, field)
         if not person:
             return ""
 
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
-    def render_people(self, customer, field):
-        people = customer.people
-        if not people:
-            return ""
-
-        items = []
-        for person in people:
-            text = six.text_type(person)
-            route = '{}people.view'.format('mobile.' if self.mobile else '')
-            url = self.request.route_url(route, uuid=person.uuid)
-            link = tags.link_to(text, url)
-            items.append(HTML.tag('li', c=[link]))
-        return HTML.tag('ul', c=items)
-
-    def render_people_removable(self, customer, field):
-        people = customer.people
-        if not people:
-            return ""
-
+    def render_shoppers(self, customer, field):
         route_prefix = self.get_route_prefix()
         permission_prefix = self.get_permission_prefix()
 
-        view_url = lambda p, i: self.request.route_url('people.view', uuid=p.uuid)
-        actions = [
-            grids.GridAction('view', icon='zoomin', url=view_url),
-        ]
-        if self.people_detachable and self.request.has_perm('{}.detach_person'.format(permission_prefix)):
-            url = lambda p, i: self.request.route_url('{}.detach_person'.format(route_prefix),
-                                                      uuid=customer.uuid, person_uuid=p.uuid)
-            actions.append(
-                grids.GridAction('detach', icon='trash', url=url))
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.people',
+            data=[],
+            columns=[
+                'shopper_number',
+                'first_name',
+                'last_name',
+                'phone',
+                'email',
+                'active',
+            ],
+            sortable=True,
+            sorters={'shopper_number': True,
+                     'first_name': True,
+                     'last_name': True,
+                     'phone': True,
+                     'email': True,
+                     'active': True},
+            labels={'shopper_number': "Shopper #"},
+        )
 
-        columns = ['first_name', 'last_name', 'display_name']
-        g = grids.Grid(
-            key='{}.people'.format(route_prefix),
-            data=customer.people,
-            columns=columns,
-            labels={'display_name': "Full Name"},
-            url=lambda p: self.request.route_url('people.view', uuid=p.uuid),
-            linked_columns=columns,
-            main_actions=actions)
-        return HTML.literal(g.render_grid())
+        return HTML.literal(
+            g.render_table_element(data_prop='shoppers'))
+
+    def render_people(self, customer, field):
+        route_prefix = self.get_route_prefix()
+        permission_prefix = self.get_permission_prefix()
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.people',
+            data=[],
+            columns=[
+                'full_name',
+                'first_name',
+                'last_name',
+            ],
+            sortable=True,
+            sorters={'full_name': True, 'first_name': True, 'last_name': True},
+        )
+
+        if self.request.has_perm('people.view'):
+            g.actions.append(self.make_action('view', icon='eye'))
+        if self.request.has_perm('people.edit'):
+            g.actions.append(self.make_action('edit', icon='edit'))
+        if self.people_detachable and self.has_perm('detach_person'):
+            g.actions.append(self.make_action('detach', icon='minus-circle',
+                                              link_class='has-text-warning',
+                                              click_handler="$emit('detach-person', props.row._action_url_detach)"))
+
+        return HTML.literal(
+            g.render_table_element(data_prop='peopleData'))
 
     def render_groups(self, customer, field):
         groups = customer.groups
@@ -355,23 +541,28 @@ class CustomersView(MasterView):
             return ""
         items = []
         for member in members:
-            text = six.text_type(member)
+            text = str(member)
             url = self.request.route_url('members.view', uuid=member.uuid)
             items.append(HTML.tag('li', tags.link_to(text, url)))
         return HTML.tag('ul', HTML.literal('').join(items))
 
     def get_version_child_classes(self):
-        return [
+        classes = super().get_version_child_classes()
+        model = self.model
+        classes.extend([
+            (model.CustomerGroupAssignment, 'customer_uuid'),
             (model.CustomerPhoneNumber, 'parent_uuid'),
             (model.CustomerEmailAddress, 'parent_uuid'),
             (model.CustomerMailingAddress, 'parent_uuid'),
             (model.CustomerPerson, 'customer_uuid'),
             (model.CustomerNote, 'parent_uuid'),
-        ]
+        ])
+        return classes
 
     def detach_person(self):
+        model = self.model
         customer = self.get_instance()
-        person = self.Session.query(model.Person).get(self.request.matchdict['person_uuid'])
+        person = self.Session.get(model.Person, self.request.matchdict['person_uuid'])
         if not person:
             return self.notfound()
 
@@ -383,6 +574,69 @@ class CustomersView(MasterView):
 
         return self.redirect(self.request.get_referrer())
 
+    def get_merge_data(self, customer):
+        return {
+            'uuid': customer.uuid,
+            'name': customer.name,
+            'email_addresses': [e.address for e in customer.emails],
+            'phone_numbers': [p.number for p in customer.phones],
+        }
+
+    def merge_objects(self, removing, keeping):
+        coalesce = self.get_merge_coalesce_fields()
+        if coalesce:
+
+            if 'email_addresses' in coalesce:
+                keeping_emails = [e.address for e in keeping.emails]
+                for email in removing.emails:
+                    if email.address not in keeping_emails:
+                        keeping.add_email(address=email.address,
+                                          type=email.type,
+                                          invalid=email.invalid)
+                        keeping_emails.append(email.address)
+
+            if 'phone_numbers' in coalesce:
+                keeping_phones = [e.number for e in keeping.phones]
+                for phone in removing.phones:
+                    if phone.number not in keeping_phones:
+                        keeping.add_phone(number=phone.number,
+                                          type=phone.type)
+                        keeping_phones.append(phone.number)
+
+        self.Session.delete(removing)
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # General
+            {'section': 'rattail',
+             'option': 'customers.key_field'},
+            {'section': 'rattail',
+             'option': 'customers.key_label'},
+            {'section': 'rattail',
+             'option': 'customers.choice_uses_dropdown',
+             'type': bool},
+            {'section': 'rattail',
+             'option': 'customers.straight_to_profile',
+             'type': bool},
+            {'section': 'rattail',
+             'option': 'customers.expose_shoppers',
+             'type': bool,
+             'default': True},
+            {'section': 'rattail',
+             'option': 'customers.expose_people',
+             'type': bool,
+             'default': True},
+            {'section': 'rattail',
+             'option': 'clientele.handler'},
+
+            # POS
+            {'section': 'rattail',
+             'option': 'customers.active_in_pos',
+             'type': bool},
+
+        ]
+
     @classmethod
     def defaults(cls, config):
         cls._defaults(config)
@@ -392,21 +646,227 @@ class CustomersView(MasterView):
     def _customer_defaults(cls, config):
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
         permission_prefix = cls.get_permission_prefix()
         model_key = cls.get_model_key()
         model_title = cls.get_model_title()
 
         # detach person
         if cls.people_detachable:
-            config.add_tailbone_permission(permission_prefix, '{}.detach_person'.format(permission_prefix),
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.detach_person'.format(permission_prefix),
                                            "Detach a Person from a {}".format(model_title))
-            config.add_route('{}.detach_person'.format(route_prefix), '{}/{{{}}}/detach-person/{{person_uuid}}'.format(url_prefix, model_key),
+            # TODO: this should require POST!
+            config.add_route('{}.detach_person'.format(route_prefix),
+                             '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix),
                              # request_method='POST',
             )
-            config.add_view(cls, attr='detach_person', route_name='{}.detach_person'.format(route_prefix),
+            config.add_view(cls, attr='detach_person',
+                            route_name='{}.detach_person'.format(route_prefix),
                             permission='{}.detach_person'.format(permission_prefix))
 
 
+class CustomerShopperView(MasterView):
+    """
+    Master view for the CustomerShopper class.
+    """
+    model_class = CustomerShopper
+    route_prefix = 'customer_shoppers'
+    url_prefix = '/customer-shoppers'
+
+    grid_columns = [
+        'customer_key',
+        'customer',
+        'shopper_number',
+        'person',
+        'active',
+    ]
+
+    form_fields = [
+        'customer',
+        'shopper_number',
+        'person',
+        'active',
+    ]
+
+    def should_expose_quickie_search(self):
+        if self.expose_quickie_search:
+            return True
+        app = self.get_rattail_app()
+        return app.get_people_handler().should_expose_quickie_search()
+
+    def get_quickie_perm(self):
+        return 'people.quickie'
+
+    def get_quickie_url(self):
+        return self.request.route_url('people.quickie')
+
+    def get_quickie_placeholder(self):
+        app = self.get_rattail_app()
+        return app.get_people_handler().get_quickie_search_placeholder()
+
+    def query(self, session):
+        query = super().query(session)
+        model = self.model
+        return query.join(model.Customer)\
+                    .join(model.Person,
+                          model.Person.uuid == model.CustomerShopper.person_uuid)
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+        app = self.get_rattail_app()
+        model = self.model
+
+        # customer_key
+        key = app.get_customer_key_field()
+        label = app.get_customer_key_label()
+        g.set_label('customer_key', label)
+        g.set_renderer('customer_key',
+                       lambda shopper, field: getattr(shopper.customer, key))
+        g.set_sorter('customer_key', getattr(model.Customer, key))
+        g.set_sort_defaults('customer_key')
+        g.set_filter('customer_key', getattr(model.Customer, key),
+                     label=f"Customer {label}",
+                     default_active=True,
+                     default_verb='equal')
+
+        # customer (name)
+        g.set_sorter('customer', model.Customer.name)
+        g.set_filter('customer', model.Customer.name,
+                     label="Customer Account Name")
+
+        # person (name)
+        g.set_sorter('person', model.Person.display_name)
+        g.set_filter('person', model.Person.display_name,
+                     label="Person Name")
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        f.set_renderer('customer', self.render_customer)
+        f.set_renderer('person', self.render_person)
+
+
+class PendingCustomerView(MasterView):
+    """
+    Master view for the Pending Customer class.
+    """
+    model_class = PendingCustomer
+    route_prefix = 'pending_customers'
+    url_prefix = '/customers/pending'
+
+    labels = {
+        'id': "ID",
+        'status_code': "Status",
+    }
+
+    grid_columns = [
+        'id',
+        'display_name',
+        'first_name',
+        'last_name',
+        'phone_number',
+        'email_address',
+        'status_code',
+    ]
+
+    form_fields = [
+        'id',
+        'display_name',
+        'first_name',
+        'middle_name',
+        'last_name',
+        'phone_number',
+        'phone_type',
+        'email_address',
+        'email_type',
+        'address_street',
+        'address_street2',
+        'address_city',
+        'address_state',
+        'address_zipcode',
+        'address_type',
+        'status_code',
+        'created',
+        'user',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS)
+        g.filters['status_code'].default_active = True
+        g.filters['status_code'].default_verb = 'not_equal'
+        g.filters['status_code'].default_value = str(self.enum.PENDING_CUSTOMER_STATUS_RESOLVED)
+
+        g.set_sort_defaults('display_name')
+        g.set_link('id')
+        g.set_link('display_name')
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS)
+
+        # created
+        if self.creating:
+            f.remove('created')
+        else:
+            f.set_readonly('created')
+
+        # user
+        if self.creating:
+            f.remove('user')
+        else:
+            f.set_readonly('user')
+            f.set_renderer('user', self.render_user)
+
+    def editable_instance(self, pending):
+        if pending.status_code == self.enum.PENDING_CUSTOMER_STATUS_RESOLVED:
+            return False
+        return True
+
+    def resolve_person(self):
+        model = self.model
+        pending = self.get_instance()
+        redirect = self.redirect(self.get_action_url('view', pending))
+
+        uuid = self.request.POST['person_uuid']
+        person = self.Session.get(model.Person, uuid)
+        if not person:
+            self.request.session.flash("Person not found!", 'error')
+            return redirect
+
+        app = self.get_rattail_app()
+        people_handler = app.get_people_handler()
+        people_handler.resolve_person(pending, person, self.request.user)
+        self.Session.flush()
+        return redirect
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._pending_customer_defaults(config)
+
+    @classmethod
+    def _pending_customer_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        model_title = cls.get_model_title()
+
+        # resolve person
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.resolve_person'.format(permission_prefix),
+                                       "Resolve a {} as a Person".format(model_title))
+        config.add_route('{}.resolve_person'.format(route_prefix),
+                         '{}/resolve-person'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='resolve_person',
+                        route_name='{}.resolve_person'.format(route_prefix),
+                        permission='{}.resolve_person'.format(permission_prefix))
+
+
 # # TODO: this is referenced by some custom apps, but should be moved??
 # def unique_id(value, field):
 #     customer = field.parent.model
@@ -420,54 +880,19 @@ class CustomersView(MasterView):
 # TODO: this only works when creating, need to add edit support?
 # TODO: can this just go away? since we have unique_id() view method above
 def unique_id(node, value):
-    customers = Session.query(model.Customer).filter(model.Customer.id == value)
+    customers = Session.query(Customer).filter(Customer.id == value)
     if customers.count():
         raise colander.Invalid(node, "Customer ID must be unique")
 
 
-class CustomerNameAutocomplete(AutocompleteView):
-    """
-    Autocomplete view which operates on customer name.
-    """
-    mapped_class = model.Customer
-    fieldname = 'name'
-
-
-class CustomerPhoneAutocomplete(AutocompleteView):
-    """
-    Autocomplete view which operates on customer phone number.
-
-    .. note::
-       As currently implemented, this view will only work with a PostgreSQL
-       database.  It normalizes the user's search term and the database values
-       to numeric digits only (i.e. removes special characters from each) in
-       order to be able to perform smarter matching.  However normalizing the
-       database value currently uses the PG SQL ``regexp_replace()`` function.
-    """
-    invalid_pattern = re.compile(r'\D')
-
-    def prepare_term(self, term):
-        return self.invalid_pattern.sub('', term)
-
-    def query(self, term):
-        return Session.query(model.CustomerPhoneNumber)\
-            .filter(sa.func.regexp_replace(model.CustomerPhoneNumber.number, r'\D', '', 'g').like('%{0}%'.format(term)))\
-            .order_by(model.CustomerPhoneNumber.number)\
-            .options(orm.joinedload(model.CustomerPhoneNumber.customer))
-
-    def display(self, phone):
-        return "{0} {1}".format(phone.number, phone.customer)
-
-    def value(self, phone):
-        return phone.customer.uuid
-
-
 def customer_info(request):
     """
     View which returns simple dictionary of info for a particular customer.
     """
+    app = request.rattail_config.get_app()
+    model = app.model
     uuid = request.params.get('uuid')
-    customer = Session.query(model.Customer).get(uuid) if uuid else None
+    customer = Session.get(model.Customer, uuid) if uuid else None
     if not customer:
         return {}
     return {
@@ -477,19 +902,27 @@ def customer_info(request):
         }
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
 
-    # autocomplete
-    config.add_route('customers.autocomplete', '/customers/autocomplete')
-    config.add_view(CustomerNameAutocomplete, route_name='customers.autocomplete',
-                    renderer='json', permission='customers.list')
-    config.add_route('customers.autocomplete.phone', '/customers/autocomplete/phone')
-    config.add_view(CustomerPhoneAutocomplete, route_name='customers.autocomplete.phone',
-                    renderer='json', permission='customers.list')
-
-    # info
+    # TODO: deprecate / remove this
     config.add_route('customer.info', '/customers/info')
+    customer_info = kwargs.get('customer_info', base['customer_info'])
     config.add_view(customer_info, route_name='customer.info',
                     renderer='json', permission='customers.view')
 
-    CustomersView.defaults(config)
+    CustomerView = kwargs.get('CustomerView',
+                              base['CustomerView'])
+    CustomerView.defaults(config)
+
+    CustomerShopperView = kwargs.get('CustomerShopperView',
+                                     base['CustomerShopperView'])
+    CustomerShopperView.defaults(config)
+
+    PendingCustomerView = kwargs.get('PendingCustomerView',
+                                     base['PendingCustomerView'])
+    PendingCustomerView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py
index c8b6280f..fa0df901 100644
--- a/tailbone/views/custorders/batch.py
+++ b/tailbone/views/custorders/batch.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,10 @@
 Base class for customer order batch views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
-from rattail.db import model
+from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow
 
 import colander
+from webhelpers2.html import tags
 
 from tailbone import forms
 from tailbone.views.batch import BatchMasterView
@@ -41,46 +38,109 @@ class CustomerOrderBatchView(BatchMasterView):
     Master view base class, for customer order batches.  The views for the
     various mode/workflow batches will derive from this.
     """
-    model_class = model.CustomerOrderBatch
-    model_row_class = model.CustomerOrderBatchRow
+    model_class = CustomerOrderBatch
+    model_row_class = CustomerOrderBatchRow
     default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler'
 
     grid_columns = [
         'id',
-        'customer',
-        'rows',
+        'contact_name',
+        'rowcount',
+        'total_price',
         'created',
         'created_by',
+        'executed',
+        'executed_by',
     ]
 
     form_fields = [
         'id',
+        'store',
         'customer',
         'person',
+        'pending_customer',
+        'contact_name',
         'phone_number',
         'email_address',
+        'params',
         'created',
         'created_by',
-        'rows',
+        'rowcount',
+        'total_price',
+    ]
+
+    row_labels = {
+        'product_brand': "Brand",
+        'product_description': "Description",
+        'product_size': "Size",
+        'order_uom': "Order UOM",
+    }
+
+    row_grid_columns = [
+        'sequence',
+        '_product_key_',
+        'product_brand',
+        'product_description',
+        'product_size',
+        'order_quantity',
+        'order_uom',
+        'case_quantity',
+        'total_price',
+        'status_code',
+    ]
+
+    product_key_fields = {
+        'upc': 'product_upc',
+        'item_id': 'product_item_id',
+        'scancode': 'product_scancode',
+    }
+
+    row_form_fields = [
+        'sequence',
+        'item_entry',
+        'product',
+        'pending_product',
+        '_product_key_',
+        'product_brand',
+        'product_description',
+        'product_size',
+        'product_weighed',
+        'product_unit_of_measure',
+        'department_number',
+        'department_name',
+        'product_unit_cost',
+        'case_quantity',
+        'unit_price',
+        'price_needs_confirmation',
+        'order_quantity',
+        'order_uom',
+        'discount_percent',
+        'total_price',
+        'paid_amount',
+        # 'payment_transaction_number',
         'status_code',
     ]
 
     def configure_grid(self, g):
-        super(CustomerOrderBatchView, self).configure_grid(g)
+        super().configure_grid(g)
 
-        g.set_link('customer')
+        g.set_type('total_price', 'currency')
+
+        g.set_link('contact_name')
         g.set_link('created')
         g.set_link('created_by')
 
     def configure_form(self, f):
-        super(CustomerOrderBatchView, self).configure_form(f)
+        super().configure_form(f)
         order = f.model_instance
-        model = self.rattail_config.get_model()
+        model = self.model
 
         # readonly fields
         f.set_readonly('rows')
         f.set_readonly('status_code')
 
+        f.set_renderer('store', self.render_store)
+
         # customer
         if 'customer' in f.fields and self.editing:
             f.replace('customer', 'customer_uuid')
@@ -88,12 +148,12 @@ class CustomerOrderBatchView(BatchMasterView):
             customer_display = ""
             if self.request.method == 'POST':
                 if self.request.POST.get('customer_uuid'):
-                    customer = self.Session.query(model.Customer)\
-                                           .get(self.request.POST['customer_uuid'])
+                    customer = self.Session.get(model.Customer,
+                                                self.request.POST['customer_uuid'])
                     if customer:
-                        customer_display = six.text_type(customer)
+                        customer_display = str(customer)
             elif self.editing:
-                customer_display = six.text_type(order.customer or "")
+                customer_display = str(order.customer or "")
             customers_url = self.request.route_url('customers.autocomplete')
             f.set_widget('customer_uuid', forms.widgets.JQueryAutocompleteWidget(
                 field_display=customer_display, service_url=customers_url))
@@ -108,15 +168,65 @@ class CustomerOrderBatchView(BatchMasterView):
             person_display = ""
             if self.request.method == 'POST':
                 if self.request.POST.get('person_uuid'):
-                    person = self.Session.query(model.Person)\
-                                         .get(self.request.POST['person_uuid'])
+                    person = self.Session.get(model.Person,
+                                              self.request.POST['person_uuid'])
                     if person:
-                        person_display = six.text_type(person)
+                        person_display = str(person)
             elif self.editing:
-                person_display = six.text_type(order.person or "")
+                person_display = str(order.person or "")
             people_url = self.request.route_url('people.autocomplete')
             f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
                 field_display=person_display, service_url=people_url))
             f.set_label('person_uuid', "Person")
         else:
             f.set_renderer('person', self.render_person)
+
+        # pending_customer
+        f.set_renderer('pending_customer', self.render_pending_customer)
+
+        f.set_type('total_price', 'currency')
+
+    def render_pending_customer(self, batch, field):
+        pending = batch.pending_customer
+        if not pending:
+            return
+        text = str(pending)
+        url = self.request.route_url('pending_customers.view', uuid=pending.uuid)
+        return tags.link_to(text, url)
+
+    def row_grid_extra_class(self, row, i):
+        if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
+            return 'warning'
+        if row.status_code == row.STATUS_PENDING_PRODUCT:
+            return 'notice'
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        g.set_type('case_quantity', 'quantity')
+        g.set_type('cases_ordered', 'quantity')
+        g.set_type('units_ordered', 'quantity')
+        g.set_type('order_quantity', 'quantity')
+        g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
+        g.set_type('unit_price', 'currency')
+        g.set_type('total_price', 'currency')
+
+        g.set_link('product_upc')
+        g.set_link('product_description')
+
+    def configure_row_form(self, f):
+        super().configure_row_form(f)
+
+        f.set_renderer('product', self.render_product)
+        f.set_renderer('pending_product', self.render_pending_product)
+
+        f.set_renderer('product_upc', self.render_upc)
+
+        f.set_type('case_quantity', 'quantity')
+        f.set_type('cases_ordered', 'quantity')
+        f.set_type('units_ordered', 'quantity')
+        f.set_type('order_quantity', 'quantity')
+        f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
+        f.set_type('unit_price', 'currency')
+        f.set_type('total_price', 'currency')
+        f.set_type('paid_amount', 'currency')
diff --git a/tailbone/views/custorders/creating.py b/tailbone/views/custorders/creating.py
index c14448eb..cbdf6d5e 100644
--- a/tailbone/views/custorders/creating.py
+++ b/tailbone/views/custorders/creating.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -40,10 +40,17 @@ class CreateCustomerOrderBatchView(CustomerOrderBatchView):
     """
     route_prefix = 'new_custorders'
     url_prefix = '/new-customer-orders'
-    model_title = "New Customer Order"
-    model_title_plural = "New Customer Orders"
+    model_title = "New Customer Order Batch"
+    model_title_plural = "New Customer Order Batches"
     creatable = False
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    CreateCustomerOrderBatchView = kwargs.get('CreateCustomerOrderBatchView', base['CreateCustomerOrderBatchView'])
     CreateCustomerOrderBatchView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index da56e7de..e7edf3aa 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,164 +24,666 @@
 Customer order item views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
 from sqlalchemy import orm
 
-from rattail.db import model
-from rattail.time import localtime
+from rattail.db.model import CustomerOrderItem
 
-from webhelpers2.html import tags
+from webhelpers2.html import HTML, tags
 
 from tailbone.views import MasterView
-from tailbone.util import raw_datetime
+from tailbone.util import raw_datetime, csrf_token
 
 
-class CustomerOrderItemsView(MasterView):
+class CustomerOrderItemView(MasterView):
     """
     Master view for customer order items
     """
-    model_class = model.CustomerOrderItem
+    model_class = CustomerOrderItem
     route_prefix = 'custorders.items'
     url_prefix = '/custorders/items'
     creatable = False
     editable = False
     deletable = False
 
+    labels = {
+        'order': "Customer Order",
+        'order_id': "Order ID",
+        'order_uom': "Order UOM",
+        'status_code': "Status",
+    }
+
     grid_columns = [
+        'order_id',
         'person',
+        '_product_key_',
         'product_brand',
         'product_description',
         'product_size',
+        'department_name',
         'case_quantity',
-        'cases_ordered',
-        'units_ordered',
+        'order_quantity',
+        'order_uom',
+        'total_price',
         'order_created',
         'status_code',
-    ]
-
-    has_rows = True
-    model_row_class = model.CustomerOrderItemEvent
-    rows_title = "Event History"
-    rows_filterable = False
-    rows_sortable = False
-    rows_pageable = False
-    rows_viewable = False
-
-    row_grid_columns = [
-        'occurred',
-        'type_code',
-        'user',
-        'note',
+        'flagged',
     ]
 
     form_fields = [
+        'order',
+        'customer',
         'person',
+        'sequence',
+        '_product_key_',
         'product',
+        'pending_product',
         'product_brand',
         'product_description',
         'product_size',
         'case_quantity',
-        'cases_ordered',
-        'units_ordered',
+        'order_quantity',
+        'order_uom',
         'unit_price',
         'total_price',
+        'special_order',
+        'price_needs_confirmation',
         'paid_amount',
+        'payment_transaction_number',
         'status_code',
+        'flagged',
+        'contact_attempts',
+        'last_contacted',
+        'events',
     ]
 
+    def __init__(self, request):
+        super().__init__(request)
+        app = self.get_rattail_app()
+        self.custorder_handler = app.get_custorder_handler()
+        self.batch_handler = app.get_batch_handler(
+            'custorder',
+            default='rattail.batch.custorder:CustomerOrderBatchHandler')
+
     def query(self, session):
+        model = self.model
         return session.query(model.CustomerOrderItem)\
                       .join(model.CustomerOrder)\
                       .options(orm.joinedload(model.CustomerOrderItem.order)\
                                .joinedload(model.CustomerOrder.person))
 
     def configure_grid(self, g):
-        super(CustomerOrderItemsView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
+        # order_id
+        g.set_renderer('order_id', self.render_order_id)
+        g.set_link('order_id')
+
+        # person
+        g.set_label('person', "Person Name")
+        g.set_renderer('person', self.render_person_text)
+        g.set_link('person')
         g.set_joiner('person', lambda q: q.outerjoin(model.Person))
-
-        g.filters['person'] = g.make_filter('person', model.Person.display_name,
-                                            default_active=True, default_verb='contains')
-
         g.set_sorter('person', model.Person.display_name)
-        g.set_sorter('order_created', model.CustomerOrder.created)
+        g.set_filter('person', model.Person.display_name,
+                     default_active=True, default_verb='contains')
 
-        g.set_sort_defaults('order_created', 'desc')
+        # product_key
+        field = self.get_product_key_field()
+        g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}'))
 
+        # product_*
+        g.set_label('product_brand', "Brand")
+        g.set_link('product_brand')
+        g.set_label('product_description', "Description")
+        g.set_link('product_description')
+        g.set_label('product_size', "Size")
+
+        # "numbers"
         g.set_type('case_quantity', 'quantity')
+        g.set_type('order_quantity', 'quantity')
+        g.set_type('total_price', 'currency')
+        # TODO: deprecate / remove these
         g.set_type('cases_ordered', 'quantity')
         g.set_type('units_ordered', 'quantity')
-        g.set_type('total_price', 'currency')
 
-        g.set_renderer('person', self.render_person)
+        # order_uom
+        # nb. this is not relevant if "case orders only"
+        if not self.batch_handler.allow_unit_orders():
+            g.remove('order_uom')
+        else:
+            g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
+
+        # order_created
         g.set_renderer('order_created', self.render_order_created)
+        g.set_sorter('order_created', model.CustomerOrder.created)
+        g.set_sort_defaults('order_created', 'desc')
 
-        g.set_label('person', "Person Name")
-        g.set_label('product_brand', "Brand")
-        g.set_label('product_description', "Description")
-        g.set_label('product_size', "Size")
-        g.set_label('status_code', "Status")
+        # status_code
+        g.set_renderer('status_code', self.render_status_code_column)
 
-    def render_person(self, item, column):
-        return item.order.person
+        # abbreviate some labels, only in grid header
+        g.set_label('case_quantity', "Case Qty")
+        g.filters['case_quantity'].label = "Case Quantity"
+        g.set_label('order_quantity', "Order Qty")
+        g.filters['order_quantity'].label = "Order Quantity"
+        g.set_label('department_name', "Department")
+        g.filters['department_name'].label = "Department Name"
+        g.set_label('total_price', "Total")
+        g.filters['total_price'].label = "Total Price"
+        g.set_label('order_created', "Ordered")
+        if 'order_created' in g.filters:
+            g.filters['order_created'].label = "Order Created"
+
+    def render_order_id(self, item, field):
+        return item.order.id
+
+    def render_person_text(self, item, field):
+        person = item.order.person
+        if person:
+            text = str(person)
+            return text
 
     def render_order_created(self, item, column):
-        value = localtime(self.rattail_config, item.order.created, from_utc=True)
+        app = self.get_rattail_app()
+        value = app.localtime(item.order.created, from_utc=True)
         return raw_datetime(self.rattail_config, value)
 
+    def render_status_code_column(self, item, field):
+        text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code,
+                                                   str(item.status_code))
+        if item.status_text:
+            return HTML.tag('span', title=item.status_text, c=[text])
+        return text
+
     def configure_form(self, f):
-        super(CustomerOrderItemsView, self).configure_form(f)
+        super().configure_form(f)
+        item = f.model_instance
 
         # order
         f.set_renderer('order', self.render_order)
 
-        # product
-        f.set_renderer('product', self.render_product)
+        # contact
+        if self.batch_handler.new_order_requires_customer():
+            f.remove('person')
+        else:
+            f.remove('customer')
 
-        # product uom
+        # product key
+        key = self.get_product_key_field()
+        f.set_renderer(key, lambda item, field: getattr(item, f'product_{key}'))
+
+        # (pending) product
+        f.set_renderer('product', self.render_product)
+        f.set_renderer('pending_product', self.render_pending_product)
+        if self.viewing:
+            if item.product and not item.pending_product:
+                f.remove('pending_product')
+            elif item.pending_product and not item.product:
+                f.remove('product')
+
+        # product*
+        if not self.creating and item.product:
+            f.remove('product_brand', 'product_description')
         f.set_enum('product_unit_of_measure', self.enum.UNIT_OF_MEASURE)
 
+        # highlight pending fields
+        f.set_renderer('product_brand', self.highlight_pending_field)
+        f.set_renderer('product_description', self.highlight_pending_field)
+        f.set_renderer('product_size', self.highlight_pending_field)
+        f.set_renderer('case_quantity', self.highlight_pending_field_quantity)
+
         # quantity fields
-        f.set_type('case_quantity', 'quantity')
         f.set_type('cases_ordered', 'quantity')
         f.set_type('units_ordered', 'quantity')
+        f.set_type('order_quantity', 'quantity')
+        f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
 
-        # currency fields
-        f.set_type('unit_price', 'currency')
-        f.set_type('total_price', 'currency')
+        # price fields
+        f.set_renderer('unit_price', self.render_price_with_confirmation)
+        f.set_renderer('total_price', self.render_price_with_confirmation)
+        f.set_renderer('price_needs_confirmation', self.render_price_needs_confirmation)
         f.set_type('paid_amount', 'currency')
 
         # person
         f.set_renderer('person', self.render_person)
 
-        # label overrides
-        f.set_label('status_code', "Status")
+        # status_code
+        f.set_renderer('status_code', self.render_status_code)
+
+        # flagged
+        f.set_renderer('flagged', self.render_flagged)
+
+        # events
+        f.set_renderer('events', self.render_events)
+
+    def render_flagged(self, item, field):
+        text = "Yes" if item.flagged else "No"
+        items = [HTML.tag('span', c=text)]
+
+        if self.has_perm('change_status'):
+            button_text = "Un-Flag This" if item.flagged else "Flag This"
+            form = [
+                tags.form(self.get_action_url('change_flagged', item),
+                          **{'@submit': 'changeFlaggedSubmit'}),
+                csrf_token(self.request),
+                tags.hidden('new_flagged',
+                            value='false' if item.flagged else 'true'),
+                HTML.tag('b-button',
+                         type='is-warning' if item.flagged else 'is-primary',
+                         c=f"{{{{ changeFlaggedSubmitting ? 'Working, please wait...' : '{button_text}' }}}}",
+                         native_type='submit',
+                         style='margin-left: 1rem;',
+                         icon_pack='fas', icon_left='flag',
+                         **{':disabled': 'changeFlaggedSubmitting'}),
+                tags.end_form(),
+            ]
+            items.append(HTML.literal('').join(form))
+
+        left = HTML.tag('div', class_='level-left', c=items)
+        outer = HTML.tag('div', class_='level', c=[left])
+        return outer
+
+    def change_flagged(self):
+        """
+        View for changing "flagged" status of one or more order products.
+        """
+        item = self.get_instance()
+        redirect = self.redirect(self.get_action_url('view', item))
+
+        new_flagged = self.request.POST['new_flagged'] == 'true'
+        item.flagged = new_flagged
+
+        flagged = "FLAGGED" if new_flagged else "UN-FLAGGED"
+        self.request.session.flash(f"Order item has been {flagged}")
+        return redirect
+
+    def highlight_pending_field(self, item, field, value=None):
+        if value is None:
+            value = getattr(item, field)
+        if not item.product_uuid and item.pending_product_uuid:
+            return HTML.tag('span', c=[value],
+                            class_='has-text-success')
+        return value
+
+    def highlight_pending_field_quantity(self, item, field):
+        app = self.get_rattail_app()
+        value = getattr(item, field)
+        value = app.render_quantity(value)
+        return self.highlight_pending_field(item, field, value)
+
+    def render_price_with_confirmation(self, item, field):
+        price = getattr(item, field)
+        app = self.get_rattail_app()
+        text = app.render_currency(price)
+        if not item.product_uuid and item.pending_product_uuid:
+            text = HTML.tag('span', c=[text],
+                            class_='has-text-success')
+        if item.price_needs_confirmation:
+            return HTML.tag('span', class_='has-background-warning',
+                            c=[text])
+        return text
+
+    def render_price_needs_confirmation(self, item, field):
+
+        value = item.price_needs_confirmation
+        text = "Yes" if value else "No"
+        items = [text]
+
+        if value and self.has_perm('confirm_price'):
+            button = HTML.tag('b-button', type='is-primary', c="Confirm Price",
+                              style='margin-left: 1rem;',
+                              icon_pack='fas', icon_left='check',
+                              **{'@click': "$emit('confirm-price')"})
+            items.append(button)
+
+        left = HTML.tag('div', class_='level-left', c=items)
+        outer = HTML.tag('div', class_='level', c=[left])
+        return outer
+
+    def render_status_code(self, item, field):
+        text = self.enum.CUSTORDER_ITEM_STATUS[item.status_code]
+        if item.status_text:
+            text = "{} ({})".format(text, item.status_text)
+        items = [HTML.tag('span', c=[text])]
+
+        if self.has_perm('change_status'):
+
+            # Mark Received
+            if self.can_be_received(item):
+                button = HTML.tag('b-button', type='is-primary', c="Mark Received",
+                                  style='margin-left: 1rem;',
+                                  icon_pack='fas', icon_left='check',
+                                  **{'@click': "$emit('mark-received')"})
+                items.append(button)
+
+            # Change Status
+            button = HTML.tag('b-button', type='is-primary', c="Change Status",
+                              style='margin-left: 1rem;',
+                              icon_pack='fas', icon_left='edit',
+                              **{'@click': "$emit('change-status')"})
+            items.append(button)
+
+        left = HTML.tag('div', class_='level-left', c=items)
+        outer = HTML.tag('div', class_='level', c=[left])
+        return outer
+
+    def can_be_received(self, item):
+
+        # TODO: is this generic enough?  probably belongs in handler anyway..
+        if item.status_code in (self.enum.CUSTORDER_ITEM_STATUS_INITIATED,
+                                self.enum.CUSTORDER_ITEM_STATUS_READY,
+                                self.enum.CUSTORDER_ITEM_STATUS_PLACED):
+            return True
+
+        return False
+
+    def render_events(self, item, field):
+        route_prefix = self.get_route_prefix()
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.events',
+            data=[],
+            columns=[
+                'occurred',
+                'type_code',
+                'user',
+                'note',
+            ],
+            labels={
+                'occurred': "When",
+                'type_code': "What",
+                'user': "Who",
+            },
+        )
+
+        table = HTML.literal(
+            g.render_table_element(data_prop='eventsData'))
+        elements = [table]
+
+        if self.has_perm('add_note'):
+            button = HTML.tag('b-button', type='is-primary', c="Add Note",
+                              class_='is-pulled-right',
+                              icon_pack='fas', icon_left='plus',
+                              **{'@click': "$emit('add-note')"})
+            button_wrapper = HTML.tag('div', c=[button],
+                                      style='margin-top: 0.5rem;')
+            elements.append(button_wrapper)
+
+        return HTML.tag('div',
+                        style='display: flex; flex-direction: column;',
+                        c=elements)
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        model = self.model
+        app = self.get_rattail_app()
+        item = kwargs['instance']
+
+        # fetch events for current item
+        kwargs['events_data'] = self.get_context_events(item)
+
+        # fetch "other" order items, siblings of current one
+        order = item.order
+        other_items = self.Session.query(model.CustomerOrderItem)\
+                                  .filter(model.CustomerOrderItem.order == order)\
+                                  .filter(model.CustomerOrderItem.uuid != item.uuid)\
+                                  .all()
+        other_data = []
+        product_key_field = self.get_product_key_field()
+        for other in other_items:
+
+            order_date = None
+            if order.created:
+                order_date = app.localtime(order.created, from_utc=True).date()
+
+            other_data.append({
+                'uuid': other.uuid,
+                'product_key': getattr(other, f'product_{product_key_field}'),
+                'brand_name': other.product_brand,
+                'product_description': other.product_description,
+                'product_size': other.product_size,
+                'product_case_quantity': app.render_quantity(other.case_quantity),
+                'order_quantity': app.render_quantity(other.order_quantity),
+                'order_uom': self.enum.UNIT_OF_MEASURE[other.order_uom],
+                'department_name': other.department_name,
+                'product_barcode': other.product_upc.pretty() if other.product_upc else None,
+                'unit_price': app.render_currency(other.unit_price),
+                'total_price': app.render_currency(other.total_price),
+                'order_date': app.render_date(order_date),
+                'status_code': self.enum.CUSTORDER_ITEM_STATUS[other.status_code],
+                'flagged': other.flagged,
+            })
+        kwargs['other_order_items_data'] = other_data
+
+        return kwargs
+
+    def get_context_events(self, item):
+        app = self.get_rattail_app()
+        events = []
+        for event in item.events:
+            occurred = app.localtime(event.occurred, from_utc=True)
+            events.append({
+                'occurred': raw_datetime(self.rattail_config, occurred),
+                'type_code': self.enum.CUSTORDER_ITEM_EVENT.get(event.type_code, event.type_code),
+                'user': str(event.user),
+                'note': event.note,
+            })
+        return events
+
+    def confirm_price(self):
+        """
+        View for confirming price of an order item.
+        """
+        item = self.get_instance()
+        redirect = self.redirect(self.get_action_url('view', item))
+
+        # locate user responsible for change
+        user = self.request.user
+
+        # grab user-provided note to attach to event
+        note = self.request.POST.get('note')
+
+        # declare item no longer in need of price confirmation
+        item.price_needs_confirmation = False
+        item.add_event(self.enum.CUSTORDER_ITEM_EVENT_PRICE_CONFIRMED,
+                       user, note=note)
+
+        # advance item to next status
+        if item.status_code == self.enum.CUSTORDER_ITEM_STATUS_INITIATED:
+            item.status_code = self.enum.CUSTORDER_ITEM_STATUS_READY
+            item.status_text = "price has been confirmed"
+
+        self.request.session.flash("Price has been confirmed.")
+        return redirect
+
+    def mark_received(self):
+        """
+        View to mark some order item(s) as having been received.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+        uuids = self.request.POST['order_item_uuids'].split(',')
+
+        order_items = self.Session.query(model.CustomerOrderItem)\
+                                  .filter(model.CustomerOrderItem.uuid.in_(uuids))\
+                                  .all()
+
+        handler = app.get_custorder_handler()
+        handler.mark_received(order_items, self.request.user)
+
+        msg = self.mark_received_get_flash(order_items)
+        self.request.session.flash(msg)
+        return self.redirect(self.request.get_referrer(default=self.get_index_url()))
+
+    def mark_received_get_flash(self, order_items):
+        return "Order item statuses have been updated."
+
+    def change_status(self):
+        """
+        View for changing status of one or more order items.
+        """
+        model = self.model
+        order_item = self.get_instance()
+        redirect = self.redirect(self.get_action_url('view', order_item))
+
+        # validate new status
+        new_status_code = int(self.request.POST['new_status_code'])
+        if new_status_code not in self.enum.CUSTORDER_ITEM_STATUS:
+            self.request.session.flash("Invalid status code", 'error')
+            return redirect
+
+        # locate order items to which new status will be applied
+        order_items = [order_item]
+        uuids = self.request.POST['uuids']
+        if uuids:
+            for uuid in uuids.split(','):
+                item = self.Session.get(model.CustomerOrderItem, uuid)
+                if item:
+                    order_items.append(item)
+
+        # locate user responsible for change
+        user = self.request.user
+
+        # maybe grab extra user-provided note to attach
+        extra_note = self.request.POST.get('note')
+
+        # apply new status to order item(s)
+        for item in order_items:
+            if item.status_code != new_status_code:
+
+                # attach event
+                note = "status changed from \"{}\" to \"{}\"".format(
+                    self.enum.CUSTORDER_ITEM_STATUS[item.status_code],
+                    self.enum.CUSTORDER_ITEM_STATUS[new_status_code])
+                if extra_note:
+                    note = "{} - NOTE: {}".format(note, extra_note)
+                item.events.append(model.CustomerOrderItemEvent(
+                    type_code=self.enum.CUSTORDER_ITEM_EVENT_STATUS_CHANGE,
+                    user=user, note=note))
+
+                # change status
+                item.status_code = new_status_code
+                # nb. must blank this out, b/c user cannot specify new
+                # text and the old text no longer applies
+                item.status_text = None
+
+        self.request.session.flash("Status has been updated to: {}".format(
+            self.enum.CUSTORDER_ITEM_STATUS[new_status_code]))
+        return redirect
+
+    def add_note(self):
+        """
+        View for adding a new note to current order item, optinally
+        also adding it to all other items under the parent order.
+        """
+        item = self.get_instance()
+        data = self.request.json_body
+
+        self.custorder_handler.add_note(item, data['note'], self.request.user,
+                                        apply_all=data['apply_all'] == True)
+
+        self.Session.flush()
+        self.Session.refresh(item)
+        return {'events': self.get_context_events(item)}
 
     def render_order(self, item, field):
         order = item.order
         if not order:
             return ""
-        text = six.text_type(order)
+        text = str(order)
         url = self.request.route_url('custorders.view', uuid=order.uuid)
         return tags.link_to(text, url)
 
-    def get_row_data(self, item):
-        return self.Session.query(model.CustomerOrderItemEvent)\
-                           .filter(model.CustomerOrderItemEvent.item == item)\
-                           .order_by(model.CustomerOrderItemEvent.occurred,
-                                     model.CustomerOrderItemEvent.type_code)
+    def render_person(self, item, field):
+        person = item.order.person
+        if person:
+            text = str(person)
+            url = self.request.route_url('people.view', uuid=person.uuid)
+            return tags.link_to(text, url)
 
-    def configure_row_grid(self, g):
-        super(CustomerOrderItemsView, self).configure_row_grid(g)
-        g.set_label('occurred', "When")
-        g.set_label('type_code', "What") # TODO: enum renderer
-        g.set_label('user', "Who")
-        g.set_label('note', "Notes")
+    @classmethod
+    def defaults(cls, config):
+        cls._order_item_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _order_item_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        model_title = cls.get_model_title()
+        model_title_plural = cls.get_model_title_plural()
+
+        # fix permission group name
+        config.add_tailbone_permission_group(permission_prefix, model_title_plural)
+
+        # confirm price
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.confirm_price'.format(permission_prefix),
+                                       "Confirm price for a {}".format(model_title))
+        config.add_route('{}.confirm_price'.format(route_prefix),
+                         '{}/confirm-price'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='confirm_price',
+                        route_name='{}.confirm_price'.format(route_prefix),
+                        permission='{}.confirm_price'.format(permission_prefix))
+
+        # mark received
+        config.add_route(f'{route_prefix}.mark_received',
+                         f'{url_prefix}/mark-received',
+                         request_method='POST')
+        config.add_view(cls, attr='mark_received',
+                        route_name=f'{route_prefix}.mark_received',
+                        permission=f'{permission_prefix}.change_status')
+
+        # change status
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.change_status'.format(permission_prefix),
+                                       "Change status for 1 or more {}".format(model_title_plural))
+        config.add_route('{}.change_status'.format(route_prefix),
+                         '{}/change-status'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='change_status',
+                        route_name='{}.change_status'.format(route_prefix),
+                        permission='{}.change_status'.format(permission_prefix))
+
+        # change flagged
+        config.add_route(f'{route_prefix}.change_flagged',
+                         f'{instance_url_prefix}/change-flagged',
+                         request_method='POST')
+        config.add_view(cls, attr='change_flagged',
+                        route_name=f'{route_prefix}.change_flagged',
+                        permission=f'{permission_prefix}.change_status')
+
+        # add note
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.add_note'.format(permission_prefix),
+                                       "Add arbitrary notes for {}".format(model_title_plural))
+        config.add_route('{}.add_note'.format(route_prefix),
+                         '{}/add-note'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='add_note',
+                        route_name='{}.add_note'.format(route_prefix),
+                        renderer='json',
+                        permission='{}.add_note'.format(permission_prefix))
+
+
+# TODO: deprecate / remove this
+CustomerOrderItemsView = CustomerOrderItemView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    CustomerOrderItemView = kwargs.get('CustomerOrderItemView', base['CustomerOrderItemView'])
+    CustomerOrderItemView.defaults(config)
 
 
 def includeme(config):
-    CustomerOrderItemsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index 9ffc06c8..b1a9831a 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,98 +24,300 @@
 Customer Order Views
 """
 
-from __future__ import unicode_literals, absolute_import
+import decimal
+import logging
 
-import six
 from sqlalchemy import orm
 
-from rattail.db import model
+from rattail.db.model import CustomerOrder, CustomerOrderItem
+from rattail.util import simple_error
+from rattail.batch import get_batch_handler
 
-from webhelpers2.html import tags
+from webhelpers2.html import tags, HTML
 
-from tailbone.db import Session
 from tailbone.views import MasterView
 
 
-class CustomerOrdersView(MasterView):
+log = logging.getLogger(__name__)
+
+
+class CustomerOrderView(MasterView):
     """
     Master view for customer orders
     """
-    model_class = model.CustomerOrder
+    model_class = CustomerOrder
     route_prefix = 'custorders'
     editable = False
-    deletable = False
+    configurable = True
+
+    labels = {
+        'id': "Order ID",
+        'status_code': "Status",
+    }
 
     grid_columns = [
         'id',
         'customer',
         'person',
-        'created',
         'status_code',
+        'created',
+        'created_by',
     ]
 
     form_fields = [
         'id',
+        'store',
         'customer',
         'person',
+        'pending_customer',
         'phone_number',
         'email_address',
-        'created',
+        'total_price',
         'status_code',
+        'created',
+        'created_by',
     ]
 
+    has_rows = True
+    model_row_class = CustomerOrderItem
+    rows_viewable = False
+
+    row_labels = {
+        'order_uom': "Order UOM",
+    }
+
+    row_grid_columns = [
+        'sequence',
+        '_product_key_',
+        'product_brand',
+        'product_description',
+        'product_size',
+        'order_quantity',
+        'order_uom',
+        'case_quantity',
+        'department_name',
+        'total_price',
+        'status_code',
+        'flagged',
+    ]
+
+    PENDING_PRODUCT_ENTRY_FIELDS = [
+        'key',
+        'department_uuid',
+        'brand_name',
+        'description',
+        'size',
+        'vendor_name',
+        'vendor_item_code',
+        'unit_cost',
+        'case_size',
+        'regular_price_amount',
+    ]
+
+    def __init__(self, request):
+        super().__init__(request)
+        self.batch_handler = self.get_batch_handler()
+
     def query(self, session):
+        model = self.app.model
         return session.query(model.CustomerOrder)\
                       .options(orm.joinedload(model.CustomerOrder.customer))
 
     def configure_grid(self, g):
-        super(CustomerOrdersView, self).configure_grid(g)
-
-        g.set_joiner('customer', lambda q: q.outerjoin(model.Customer))
-        g.set_joiner('person', lambda q: q.outerjoin(model.Person))
-
-        g.filters['customer'] = g.make_filter('customer', model.Customer.name,
-                                              label="Customer Name",
-                                              default_active=True,
-                                              default_verb='contains')
-        g.filters['person'] = g.make_filter('person', model.Person.display_name,
-                                            label="Person Name",
-                                            default_active=True,
-                                            default_verb='contains')
-
-        g.set_sorter('customer', model.Customer.name)
-        g.set_sorter('person', model.Person.display_name)
-
-        g.set_sort_defaults('created', 'desc')
-
-        # TODO: enum choices renderer
-        g.set_label('status_code', "Status")
-        g.set_label('id', "ID")
-
-    def configure_form(self, f):
-        super(CustomerOrdersView, self).configure_form(f)
+        super().configure_grid(g)
+        model = self.app.model
 
         # id
-        f.set_readonly('id')
-        f.set_label('id', "ID")
+        g.set_link('id')
+        g.filters['id'].default_active = True
+        g.filters['id'].default_verb = 'equal'
 
-        # person
-        f.set_renderer('person', self.render_person)
+        # import ipdb; ipdb.set_trace()
+
+        # customer or person
+        if self.batch_handler.new_order_requires_customer():
+            g.remove('person')
+            g.set_link('customer')
+            g.set_joiner('customer', lambda q: q.outerjoin(model.Customer))
+            g.set_sorter('customer', model.Customer.name)
+            g.filters['customer'] = g.make_filter('customer', model.Customer.name,
+                                                  label="Customer Name",
+                                                  default_active=True,
+                                                  default_verb='contains')
+        else:
+            g.remove('customer')
+            g.set_link('person')
+            g.set_joiner('person', lambda q: q.outerjoin(model.Person))
+            g.set_sorter('person', model.Person.display_name)
+            g.filters['person'] = g.make_filter('person', model.Person.display_name,
+                                                label="Person Name",
+                                                default_active=True,
+                                                default_verb='contains')
+
+        # status_code
+        g.set_enum('status_code', self.enum.CUSTORDER_STATUS)
 
         # created
+        g.set_sort_defaults('created', 'desc')
+
+    def get_instance_title(self, order):
+        return f"#{order.id} for {order.customer or order.person}"
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        order = f.model_instance
+
+        f.set_readonly('id')
+
+        f.set_renderer('store', self.render_store)
+
+        # (pending) customer
+        f.set_renderer('customer', self.render_customer)
+        f.set_renderer('person', self.render_person)
+        f.set_renderer('pending_customer', self.render_pending_customer)
+        if self.viewing:
+            if self.batch_handler.new_order_requires_customer():
+                f.remove('person')
+                if order.customer and not order.pending_customer:
+                    f.remove('pending_customer')
+                elif order.pending_customer and not order.customer:
+                    f.remove('customer')
+            else:
+                f.remove('customer')
+                if order.person and not order.pending_customer:
+                    f.remove('pending_customer')
+                elif order.pending_customer and not order.person:
+                    f.remove('person')
+
+        # contact info
+        f.set_renderer('phone_number', self.highlight_pending_field)
+        f.set_renderer('email_address', self.highlight_pending_field)
+
+        f.set_type('total_price', 'currency')
+
+        f.set_enum('status_code', self.enum.CUSTORDER_STATUS)
+
         f.set_readonly('created')
 
-        # label overrides
-        f.set_label('status_code', "Status")
+        f.set_readonly('created_by')
+        f.set_renderer('created_by', self.render_user)
+
+    def highlight_pending_field(self, order, field):
+        value = getattr(order, field)
+        pending = False
+        if self.batch_handler.new_order_requires_customer():
+            if not order.customer_uuid and order.pending_customer_uuid:
+                pending = True
+        else:
+            if not order.person_uuid and order.pending_customer_uuid:
+                pending = True
+        if pending:
+            return HTML.tag('span', c=[value],
+                            class_='has-text-success')
+        return value
 
     def render_person(self, order, field):
         person = order.person
         if not person:
             return ""
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
+    def render_pending_customer(self, batch, field):
+        pending = batch.pending_customer
+        if not pending:
+            return
+        text = str(pending)
+        url = self.request.route_url('pending_customers.view', uuid=pending.uuid)
+        return tags.link_to(text, url,
+                            class_='has-background-warning')
+
+    def get_row_data(self, order):
+        model = self.app.model
+        return self.Session.query(model.CustomerOrderItem)\
+                           .filter(model.CustomerOrderItem.order == order)
+
+    def get_parent(self, item):
+        return item.order
+
+    def make_row_grid_kwargs(self, **kwargs):
+        kwargs = super().make_row_grid_kwargs(**kwargs)
+
+        actions = kwargs.get('actions', [])
+        if not actions:
+            actions.append(self.make_action('view', icon='eye',
+                                            url=self.row_view_action_url))
+            kwargs['actions'] = actions
+
+        return kwargs
+
+    def row_view_action_url(self, item, i):
+        if self.request.has_perm('custorders.items.view'):
+            return self.request.route_url('custorders.items.view', uuid=item.uuid)
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+        app = self.get_rattail_app()
+        handler = app.get_batch_handler(
+            'custorder',
+            default='rattail.batch.custorder:CustomerOrderBatchHandler')
+
+        # product key
+        key = self.get_product_key_field()
+        g.set_renderer(key, lambda item, field: getattr(item, f'product_{key}'))
+
+        g.set_type('case_quantity', 'quantity')
+        g.set_type('order_quantity', 'quantity')
+        g.set_type('cases_ordered', 'quantity')
+        g.set_type('units_ordered', 'quantity')
+
+        if handler.product_price_may_be_questionable():
+            g.set_renderer('total_price', self.render_price_with_confirmation)
+        else:
+            g.set_type('total_price', 'currency')
+
+        g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
+        g.set_renderer('status_code', self.render_row_status_code)
+
+        g.set_label('sequence', "Seq.")
+        g.filters['sequence'].label = "Sequence"
+        g.set_label('product_brand', "Brand")
+        g.set_label('product_description', "Description")
+        g.set_label('product_size', "Size")
+        g.set_label('status_code', "Status")
+
+        g.set_sort_defaults('sequence')
+
+        g.set_link('product_brand')
+        g.set_link('product_description')
+
+    def row_grid_extra_class(self, item, i):
+        if not item.product_uuid and item.pending_product_uuid:
+            return 'has-text-success'
+
+    def render_price_with_confirmation(self, item, field):
+        price = getattr(item, field)
+        app = self.get_rattail_app()
+        text = app.render_currency(price)
+        if item.price_needs_confirmation:
+            return HTML.tag('span', class_='has-background-warning',
+                            c=[text])
+        return text
+
+    def render_row_status_code(self, item, field):
+        text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code,
+                                                   str(item.status_code))
+        if item.status_text:
+            return HTML.tag('span', title=item.status_text, c=[text])
+        return text
+
+    def get_batch_handler(self):
+        app = self.get_rattail_app()
+        return app.get_batch_handler(
+            'custorder',
+            default='rattail.batch.custorder:CustomerOrderBatchHandler')
+
     def create(self, form=None, template='create'):
         """
         View for creating a new customer order.  Note that it does so by way of
@@ -123,6 +325,9 @@ class CustomerOrdersView(MasterView):
         submits the order, at which point the batch is converted to a proper
         order.
         """
+        app = self.get_rattail_app()
+        # TODO: deprecate / remove this
+        self.handler = self.batch_handler
         batch = self.get_current_batch()
 
         if self.request.method == 'POST':
@@ -140,43 +345,110 @@ class CustomerOrdersView(MasterView):
             data = dict(self.request.json_body)
             action = data.get('action')
             json_actions = [
+                'assign_contact',
+                'unassign_contact',
+                'update_phone_number',
+                'update_email_address',
+                'update_pending_customer',
                 'get_customer_info',
-                'set_customer_data',
+                # 'set_customer_data',
+                'get_product_info',
+                'get_past_items',
+                'add_item',
+                'update_item',
+                'delete_item',
                 'submit_new_order',
             ]
             if action in json_actions:
                 result = getattr(self, action)(batch, data)
                 return self.json_response(result)
 
-        context = {'batch': batch}
+        items = [self.normalize_row(row)
+                 for row in batch.active_rows()]
+
+        context = self.get_context_contact(batch)
+
+        context.update({
+            'batch': batch,
+            'normalized_batch': self.normalize_batch(batch),
+            'new_order_requires_customer': self.batch_handler.new_order_requires_customer(),
+            'product_price_may_be_questionable': self.batch_handler.product_price_may_be_questionable(),
+            'allow_contact_info_choice': self.batch_handler.allow_contact_info_choice(),
+            'allow_contact_info_create': self.batch_handler.allow_contact_info_creation(),
+            'order_items': items,
+            'product_key_label': app.get_product_key_label(),
+            'allow_unknown_product': (self.batch_handler.allow_unknown_product()
+                                      and self.has_perm('create_unknown_product')),
+            'pending_product_required_fields': self.get_pending_product_required_fields(),
+            'unknown_product_confirm_price': self.rattail_config.getbool(
+                'rattail.custorders', 'unknown_product.always_confirm_price'),
+            'department_options': self.get_department_options(),
+            'default_uom_choices': self.batch_handler.uom_choices_for_product(None),
+            'default_uom': None,
+            'allow_item_discounts': self.batch_handler.allow_item_discounts(),
+            'allow_item_discounts_if_on_sale': self.batch_handler.allow_item_discounts_if_on_sale(),
+            # nb. render quantity so that '10.0' => '10'
+            'default_item_discount': app.render_quantity(
+                self.batch_handler.get_default_item_discount()),
+            'allow_past_item_reorder': self.batch_handler.allow_past_item_reorder(),
+        })
+
+        if self.batch_handler.allow_case_orders():
+            context['default_uom'] = self.enum.UNIT_OF_MEASURE_CASE
+        elif self.batch_handler.allow_unit_orders():
+            context['default_uom'] = self.enum.UNIT_OF_MEASURE_EACH
+
         return self.render_to_response(template, context)
 
+    def get_department_options(self):
+        model = self.model
+        departments = self.Session.query(model.Department)\
+                                  .order_by(model.Department.name)\
+                                  .all()
+        options = []
+        for department in departments:
+            options.append({'label': department.name,
+                            'value': department.uuid})
+        return options
+
+    def get_pending_product_required_fields(self):
+        required = []
+        for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
+            require = self.rattail_config.getbool('rattail.custorders',
+                                                  f'unknown_product.fields.{field}.required')
+            if require is None and field == 'description':
+                require = True
+            if require:
+                required.append(field)
+        return required
+
     def get_current_batch(self):
         user = self.request.user
         if not user:
             raise RuntimeError("this feature requires a user to be logged in")
 
+        model = self.app.model
         try:
             # there should be at most *one* new batch per user
             batch = self.Session.query(model.CustomerOrderBatch)\
                                 .filter(model.CustomerOrderBatch.mode == self.enum.CUSTORDER_BATCH_MODE_CREATING)\
                                 .filter(model.CustomerOrderBatch.created_by == user)\
+                                .filter(model.CustomerOrderBatch.executed == None)\
                                 .one()
 
         except orm.exc.NoResultFound:
             # no batch yet for this user, so make one
-            batch = model.CustomerOrderBatch()
-            batch.mode = self.enum.CUSTORDER_BATCH_MODE_CREATING
-            batch.created_by = user
+
+            batch = self.batch_handler.make_batch(
+                self.Session(), created_by=user,
+                mode=self.enum.CUSTORDER_BATCH_MODE_CREATING)
             self.Session.add(batch)
             self.Session.flush()
 
         return batch
 
     def start_over_entirely(self, batch):
-        # just delete current batch outright
-        # TODO: should use self.handler.do_delete() instead?
-        self.Session.delete(batch)
+        self.batch_handler.do_delete(batch)
         self.Session.flush()
 
         # send user back to normal "create" page; a new batch will be generated
@@ -186,60 +458,762 @@ class CustomerOrdersView(MasterView):
         return self.redirect(url)
 
     def delete_batch(self, batch):
-        # just delete current batch outright
-        # TODO: should use self.handler.do_delete() instead?
-        self.Session.delete(batch)
+        self.batch_handler.do_delete(batch)
         self.Session.flush()
 
         # set flash msg just to be more obvious
         self.request.session.flash("New customer order has been deleted.")
 
         # send user back to customer orders page, w/ no new batch generated
-        route_prefix = self.get_route_prefix()
-        url = self.request.route_url(route_prefix)
+        url = self.get_index_url()
         return self.redirect(url)
 
+    def customer_autocomplete(self):
+        """
+        Customer autocomplete logic, which invokes the handler.
+        """
+        # TODO: deprecate / remove this
+        self.handler = self.batch_handler
+        term = self.request.GET['term']
+        return self.batch_handler.customer_autocomplete(self.Session(), term,
+                                                        user=self.request.user)
+
+    def person_autocomplete(self):
+        """
+        Person autocomplete logic, which invokes the handler.
+        """
+        # TODO: deprecate / remove this
+        self.handler = self.batch_handler
+        term = self.request.GET['term']
+        return self.batch_handler.person_autocomplete(self.Session(), term,
+                                                      user=self.request.user)
+
     def get_customer_info(self, batch, data):
         uuid = data.get('uuid')
         if not uuid:
             return {'error': "Must specify a customer UUID"}
 
-        customer = self.Session.query(model.Customer).get(uuid)
+        model = self.app.model
+        customer = self.Session.get(model.Customer, uuid)
         if not customer:
             return {'error': "Customer not found"}
 
         return self.info_for_customer(batch, data, customer)
 
     def info_for_customer(self, batch, data, customer):
-        phone = customer.first_phone()
-        email = customer.first_email()
-        return {
-            'uuid': customer.uuid,
-            'phone_number': phone.number if phone else None,
-            'email_address': email.address if email else None,
+
+        # most info comes from handler
+        info = self.batch_handler.get_customer_info(batch)
+
+        # maybe add profile URL
+        if info['person_uuid']:
+            if self.request.has_perm('people.view_profile'):
+                info['contact_profile_url'] = self.request.route_url(
+                    'people.view_profile', uuid=info['person_uuid']),
+
+        return info
+
+    def assign_contact(self, batch, data):
+        model = self.app.model
+        kwargs = {}
+
+        # this will either be a Person or Customer UUID
+        uuid = data['uuid']
+
+        if self.batch_handler.new_order_requires_customer():
+
+            customer = self.Session.get(model.Customer, uuid)
+            if not customer:
+                return {'error': "Customer not found"}
+            kwargs['customer'] = customer
+
+        else:
+
+            person = self.Session.get(model.Person, uuid)
+            if not person:
+                return {'error': "Person not found"}
+            kwargs['person'] = person
+
+        # invoke handler to assign contact
+        try:
+            self.batch_handler.assign_contact(batch, **kwargs)
+        except ValueError as error:
+            return {'error': str(error)}
+
+        self.Session.flush()
+        context = self.get_context_contact(batch)
+        context['success'] = True
+        return context
+
+    def get_context_contact(self, batch):
+        context = {
+            'customer_uuid': batch.customer_uuid,
+            'person_uuid': batch.person_uuid,
+            'phone_number': batch.phone_number,
+            'contact_display': batch.contact_name,
+            'email_address': batch.email_address,
+            'contact_phones': self.batch_handler.get_contact_phones(batch),
+            'contact_emails': self.batch_handler.get_contact_emails(batch),
+            'contact_notes': self.batch_handler.get_contact_notes(batch),
+            'add_phone_number': bool(batch.get_param('add_phone_number')),
+            'add_email_address': bool(batch.get_param('add_email_address')),
+            'contact_profile_url': None,
+            'new_customer_name': None,
+            'new_customer_first_name': None,
+            'new_customer_last_name': None,
+            'new_customer_phone': None,
+            'new_customer_email': None,
         }
 
-    def set_customer_data(self, batch, data):
-        if 'customer_uuid' in data:
-            batch.customer_uuid = data['customer_uuid']
-            if 'person_uuid' in data:
-                batch.person_uuid = data['person_uuid']
-            elif batch.customer_uuid:
-                self.Session.flush()
-                batch.person = batch.customer.first_person()
-            else: # no customer set
-                batch.person_uuid = None
-        if 'phone_number' in data:
-            batch.phone_number = data['phone_number']
-        if 'email_address' in data:
-            batch.email_address = data['email_address']
+        pending = batch.pending_customer
+        if pending:
+            context.update({
+                'new_customer_first_name': pending.first_name,
+                'new_customer_last_name': pending.last_name,
+                'new_customer_name': pending.display_name,
+                'new_customer_phone': pending.phone_number,
+                'new_customer_email': pending.email_address,
+            })
+
+        # figure out if "contact is known" from user's perspective.
+        # if we have a uuid then it's definitely known, otherwise if
+        # we have a pending customer then it's definitely *not* known,
+        # but if no pending customer yet then we can still "assume" it
+        # is known, by default, until user specifies otherwise.
+        contact = self.batch_handler.get_contact(batch)
+        if contact:
+            context['contact_is_known'] = True
+        else:
+            context['contact_is_known'] = not bool(pending)
+
+        # maybe add profile URL
+        if batch.person_uuid:
+            if self.request.has_perm('people.view_profile'):
+                context['contact_profile_url'] = self.request.route_url(
+                    'people.view_profile', uuid=batch.person_uuid)
+
+        return context
+
+    def unassign_contact(self, batch, data):
+        self.batch_handler.unassign_contact(batch)
         self.Session.flush()
-        return {'success': True}
+        context = self.get_context_contact(batch)
+        context['success'] = True
+        return context
+
+    def update_phone_number(self, batch, data):
+        app = self.get_rattail_app()
+
+        batch.phone_number = app.format_phone_number(data['phone_number'])
+
+        if data.get('add_phone_number'):
+            batch.set_param('add_phone_number', True)
+        else:
+            batch.clear_param('add_phone_number')
+
+        self.Session.flush()
+        return {
+            'success': True,
+            'phone_number': batch.phone_number,
+            'add_phone_number': bool(batch.get_param('add_phone_number')),
+        }
+
+    def update_email_address(self, batch, data):
+
+        batch.email_address = data['email_address']
+
+        if data.get('add_email_address'):
+            batch.set_param('add_email_address', True)
+        else:
+            batch.clear_param('add_email_address')
+
+        self.Session.flush()
+        return {
+            'success': True,
+            'email_address': batch.email_address,
+            'add_email_address': bool(batch.get_param('add_email_address')),
+        }
+
+    def update_pending_customer(self, batch, data):
+
+        try:
+            self.batch_handler.update_pending_customer(batch, self.request.user,
+                                                       data)
+        except Exception as error:
+            return {'error': str(error)}
+
+        self.Session.flush()
+        context = self.get_context_contact(batch)
+        context['success'] = True
+        return context
+
+    def product_autocomplete(self):
+        """
+        Custom product autocomplete logic, which invokes the handler.
+        """
+        term = self.request.GET['term']
+
+        # if handler defines custom autocomplete, use that
+        handler = self.get_batch_handler()
+        if handler.has_custom_product_autocomplete:
+            return handler.custom_product_autocomplete(self.Session(), term,
+                                                       user=self.request.user)
+
+        # otherwise we use 'products.neworder' autocomplete
+        app = self.get_rattail_app()
+        autocomplete = app.get_autocompleter('products.neworder')
+        return autocomplete.autocomplete(self.Session(), term)
+
+    def get_product_info(self, batch, data):
+        uuid = data.get('uuid')
+        if not uuid:
+            return {'error': "Must specify a product UUID"}
+
+        model = self.app.model
+        product = self.Session.get(model.Product, uuid)
+        if not product:
+            return {'error': "Product not found"}
+
+        return self.info_for_product(batch, data, product)
+
+    def uom_choices_for_product(self, product):
+        return self.batch_handler.uom_choices_for_product(product)
+
+    def uom_choices_for_row(self, row):
+        return self.batch_handler.uom_choices_for_row(row)
+
+    def info_for_product(self, batch, data, product):
+        try:
+            info = self.batch_handler.get_product_info(batch, product)
+        except Exception as error:
+            return {'error': str(error)}
+        else:
+            info['url'] = self.request.route_url('products.view', uuid=info['uuid'])
+            app = self.get_rattail_app()
+            return app.json_friendly(info)
+
+    def get_past_items(self, batch, data):
+        app = self.get_rattail_app()
+        past_products = self.batch_handler.get_past_products(batch)
+        past_items = []
+
+        for product in past_products:
+            try:
+                item = self.batch_handler.get_product_info(batch, product)
+            except:
+                # nb. handler may raise error if product is "unsupported"
+                pass
+            else:
+                item = app.json_friendly(item)
+                past_items.append(item)
+
+        return {'past_items': past_items}
+
+    def normalize_batch(self, batch):
+        return {
+            'uuid': batch.uuid,
+            'total_price': str(batch.total_price or 0),
+            'total_price_display': "${:0.2f}".format(batch.total_price or 0),
+            'status_code': batch.status_code,
+            'status_text': batch.status_text,
+        }
+
+    def get_unit_price_display(self, obj):
+        """
+        Returns a display string for the given object's unit price.
+        The object can be either a ``Product`` instance, or a batch
+        row.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+        if isinstance(obj, model.Product):
+            products = app.get_products_handler()
+            return products.render_price(obj.regular_price)
+        else: # row
+            return app.render_currency(obj.unit_price)
+
+    def normalize_row(self, row):
+        products_handler = self.app.get_products_handler()
+
+        data = {
+            'uuid': row.uuid,
+            'sequence': row.sequence,
+            'item_entry': row.item_entry,
+            'product_uuid': row.product_uuid,
+            'product_upc': str(row.product_upc or ''),
+            'product_item_id': row.product_item_id,
+            'product_scancode': row.product_scancode,
+            'product_upc_pretty': row.product_upc.pretty() if row.product_upc else None,
+            'product_brand': row.product_brand,
+            'product_description': row.product_description,
+            'product_size': row.product_size,
+            'product_weighed': row.product_weighed,
+
+            'case_quantity': self.app.render_quantity(row.case_quantity),
+            'cases_ordered': self.app.render_quantity(row.cases_ordered),
+            'units_ordered': self.app.render_quantity(row.units_ordered),
+            'order_quantity': self.app.render_quantity(row.order_quantity),
+            'order_uom': row.order_uom,
+            'order_uom_choices': self.uom_choices_for_row(row),
+            'discount_percent': self.app.render_quantity(row.discount_percent),
+
+            'department_display': row.department_name,
+
+            'unit_price': float(row.unit_price) if row.unit_price is not None else None,
+            'unit_price_display': self.get_unit_price_display(row),
+            'total_price': float(row.total_price) if row.total_price is not None else None,
+            'total_price_display': self.app.render_currency(row.total_price),
+
+            'status_code': row.status_code,
+            'status_text': row.status_text,
+        }
+
+        if row.unit_regular_price:
+            data['unit_regular_price'] = float(row.unit_regular_price)
+            data['unit_regular_price_display'] = self.app.render_currency(row.unit_regular_price)
+
+        if row.unit_sale_price:
+            data['unit_sale_price'] = float(row.unit_sale_price)
+            data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price)
+        if row.sale_ends:
+            sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date()
+            data['sale_ends'] = str(sale_ends)
+            data['sale_ends_display'] = self.app.render_date(sale_ends)
+
+        if row.unit_sale_price and row.unit_price == row.unit_sale_price:
+            data['pricing_reflects_sale'] = True
+
+        if row.product or row.pending_product:
+            data['product_full_description'] = products_handler.make_full_description(
+                row.product or row.pending_product)
+
+        if row.product:
+            cost = row.product.cost
+            if cost:
+                data['vendor_display'] = cost.vendor.name
+        elif row.pending_product:
+            data['vendor_display'] = row.pending_product.vendor_name
+
+        if row.pending_product:
+            pending = row.pending_product
+            data['pending_product'] = {
+                'uuid': pending.uuid,
+                'upc': str(pending.upc) if pending.upc is not None else None,
+                'item_id': pending.item_id,
+                'scancode': pending.scancode,
+                'brand_name': pending.brand_name,
+                'description': pending.description,
+                'size': pending.size,
+                'department_uuid': pending.department_uuid,
+                'regular_price_amount': float(pending.regular_price_amount) if pending.regular_price_amount is not None else None,
+                'vendor_name': pending.vendor_name,
+                'vendor_item_code': pending.vendor_item_code,
+                'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
+                'case_size': float(pending.case_size) if pending.case_size is not None else None,
+                'notes': pending.notes,
+            }
+
+        case_price = self.batch_handler.get_case_price_for_row(row)
+        data['case_price'] = float(case_price) if case_price is not None else None
+        data['case_price_display'] = self.app.render_currency(case_price)
+
+        if self.batch_handler.product_price_may_be_questionable():
+            data['price_needs_confirmation'] = row.price_needs_confirmation
+
+        key = self.app.get_product_key_field()
+        if key == 'upc':
+            data['product_key'] = data['product_upc_pretty']
+        elif key == 'item_id':
+            data['product_key'] = row.product_item_id
+        elif key == 'scancode':
+            data['product_key'] = row.product_scancode
+        else: # TODO: this seems not useful
+            data['product_key'] = getattr(row.product, key, data['product_upc_pretty'])
+
+        if row.product:
+            data.update({
+                'product_url': self.request.route_url('products.view', uuid=row.product.uuid),
+                'product_image_url': products_handler.get_image_url(row.product),
+            })
+        elif row.product_upc:
+            data['product_image_url'] = products_handler.get_image_url(upc=row.product_upc)
+
+        unit_uom = self.enum.UNIT_OF_MEASURE_POUND if data['product_weighed'] else self.enum.UNIT_OF_MEASURE_EACH
+        if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE:
+            if row.case_quantity is None:
+                case_qty = unit_qty = '??'
+            else:
+                case_qty = data['case_quantity']
+                unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity)
+            data.update({
+                'order_quantity_display': "{} {} (&times; {} {} = {} {})".format(
+                    data['order_quantity'],
+                    self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE],
+                    case_qty,
+                    self.enum.UNIT_OF_MEASURE[unit_uom],
+                    unit_qty,
+                    self.enum.UNIT_OF_MEASURE[unit_uom]),
+            })
+        else:
+            data.update({
+                'order_quantity_display': "{} {}".format(
+                    self.app.render_quantity(row.order_quantity),
+                    self.enum.UNIT_OF_MEASURE[unit_uom]),
+            })
+
+        return data
+
+    def add_item(self, batch, data):
+        model = self.app.model
+
+        order_quantity = decimal.Decimal(data.get('order_quantity') or '0')
+        order_uom = data.get('order_uom')
+        discount_percent = decimal.Decimal(data.get('discount_percent') or '0')
+
+        if data.get('product_is_known'):
+
+            uuid = data.get('product_uuid')
+            if not uuid:
+                return {'error': "Must specify a product UUID"}
+
+            product = self.Session.get(model.Product, uuid)
+            if not product:
+                return {'error': "Product not found"}
+
+            kwargs = {}
+            if self.batch_handler.product_price_may_be_questionable():
+                kwargs['price_needs_confirmation'] = data.get('price_needs_confirmation')
+
+            if self.batch_handler.allow_item_discounts():
+                kwargs['discount_percent'] = discount_percent
+
+            row = self.batch_handler.add_product(batch, product,
+                                                 order_quantity, order_uom,
+                                                 **kwargs)
+
+        else: # unknown product; add pending
+            pending_info = dict(data['pending_product'])
+
+            if 'upc' in pending_info:
+                pending_info['upc'] = self.app.make_gpc(pending_info['upc'])
+
+            for field in ('unit_cost', 'regular_price_amount', 'case_size'):
+                if field in pending_info:
+                    try:
+                        pending_info[field] = decimal.Decimal(pending_info[field])
+                    except decimal.InvalidOperation:
+                        return {'error': f"Invalid entry for field: {field}"}
+
+            pending_info['user'] = self.request.user
+
+            kwargs = {}
+            if self.batch_handler.allow_item_discounts():
+                kwargs['discount_percent'] = discount_percent
+
+            row = self.batch_handler.add_pending_product(batch,
+                                                         pending_info,
+                                                         order_quantity, order_uom,
+                                                         **kwargs)
+
+        self.Session.flush()
+        return {'batch': self.normalize_batch(batch),
+                'row': self.normalize_row(row)}
+
+    def update_item(self, batch, data):
+        uuid = data.get('uuid')
+        if not uuid:
+            return {'error': "Must specify a row UUID"}
+
+        model = self.app.model
+        row = self.Session.get(model.CustomerOrderBatchRow, uuid)
+        if not row:
+            return {'error': "Row not found"}
+
+        if row not in batch.active_rows():
+            return {'error': "Row is not active for the batch"}
+
+        order_quantity = decimal.Decimal(data.get('order_quantity') or '0')
+        order_uom = data.get('order_uom')
+        discount_percent = decimal.Decimal(data.get('discount_percent') or '0')
+
+        if data.get('product_is_known'):
+
+            uuid = data.get('product_uuid')
+            if not uuid:
+                return {'error': "Must specify a product UUID"}
+
+            product = self.Session.get(model.Product, uuid)
+            if not product:
+                return {'error': "Product not found"}
+
+            row.item_entry = product.uuid
+            row.product = product
+            row.order_quantity = order_quantity
+            row.order_uom = order_uom
+
+            if self.batch_handler.product_price_may_be_questionable():
+                row.price_needs_confirmation = data.get('price_needs_confirmation')
+
+            if self.batch_handler.allow_item_discounts():
+                row.discount_percent = discount_percent
+
+            self.batch_handler.refresh_row(row)
+
+        else: # product is not known
+
+            # set these first, since row will be refreshed below
+            row.order_quantity = order_quantity
+            row.order_uom = order_uom
+
+            if self.batch_handler.allow_item_discounts():
+                row.discount_percent = discount_percent
+
+            # nb. this will refresh the row
+            pending_info = dict(data['pending_product'])
+            self.batch_handler.update_pending_product(row, pending_info)
+
+        self.Session.flush()
+        self.Session.refresh(row)
+        return {'batch': self.normalize_batch(batch),
+                'row': self.normalize_row(row)}
+
+    def delete_item(self, batch, data):
+
+        uuid = data.get('uuid')
+        if not uuid:
+            return {'error': "Must specify a row UUID"}
+
+        model = self.app.model
+        row = self.Session.get(model.CustomerOrderBatchRow, uuid)
+        if not row:
+            return {'error': "Row not found"}
+
+        if row not in batch.active_rows():
+            return {'error': "Row is not active for this batch"}
+
+        self.batch_handler.do_remove_row(row)
+        return {'ok': True,
+                'batch': self.normalize_batch(batch)}
 
     def submit_new_order(self, batch, data):
-        # TODO
-        return {'success': True}
+
+        reason = self.batch_handler.why_not_execute(batch, user=self.request.user)
+        if reason:
+            return {'error': reason}
+
+        try:
+            result = self.execute_new_order_batch(batch, data)
+        except Exception as error:
+            log.warning("failed to execute new order batch: %s", batch,
+                        exc_info=True)
+            return {'error': simple_error(error)}
+        else:
+            if not result:
+                return {'error': "Batch failed to execute"}
+
+        return {
+            'ok': True,
+            'next_url': self.get_next_url_after_submit_new_order(batch, result),
+        }
+
+    def get_next_url_after_submit_new_order(self, batch, result, **kwargs):
+        model = self.model
+
+        if isinstance(result, model.CustomerOrder):
+            return self.get_action_url('view', result)
+
+    def execute_new_order_batch(self, batch, data):
+        return self.batch_handler.do_execute(batch, self.request.user)
+
+    def fetch_order_data(self):
+        app = self.get_rattail_app()
+        model = self.model
+
+        order = None
+        uuid = self.request.GET.get('uuid')
+        if uuid:
+            order = self.Session.get(model.CustomerOrder, uuid)
+        if not order:
+            # raise self.notfound()
+            return {'error': "Customer order not found"}
+
+        address = None
+        if self.batch_handler.new_order_requires_customer():
+            contact = order.customer
+        else:
+            contact = order.person
+        if contact and contact.address:
+            a = contact.address
+            address = {
+                'street_1': a.street,
+                'street_2': a.street2,
+                'city': a.city,
+                'state': a.state,
+                'zip': a.zipcode,
+            }
+
+        # gather all the order items
+        items = []
+        grand_total = 0
+        for item in order.items:
+            item_data = {
+                'uuid': item.uuid,
+                'special_order': False, # TODO
+                'product_description': item.product_description,
+                'order_quantity': app.render_quantity(item.order_quantity),
+                'department': item.department_name,
+                'price': app.render_currency(item.unit_price),
+                'total': app.render_currency(item.total_price),
+            }
+            items.append(item_data)
+            grand_total += item.total_price
+
+        return {
+            'uuid': order.uuid,
+            'id': order.id,
+            'created_display': app.render_datetime(app.localtime(order.created, from_utc=True)),
+            'contact_display': str(contact or ''),
+            'address': address,
+            'phone_display': str(contact.phone) if contact and contact.phone else "",
+            'email_display': str(contact.email) if contact and contact.email else "",
+            'items': items,
+            'grand_total_display': app.render_currency(grand_total),
+        }
+
+    def configure_get_simple_settings(self):
+        settings = [
+
+            # customer handling
+            {'section': 'rattail.custorders',
+             'option': 'new_order_requires_customer',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'new_orders.allow_contact_info_choice',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'new_orders.allow_contact_info_create',
+             'type': bool},
+
+            # product handling
+            {'section': 'rattail.custorders',
+             'option': 'allow_case_orders',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'allow_unit_orders',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'product_price_may_be_questionable',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'allow_item_discounts',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'allow_item_discounts_if_on_sale',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'default_item_discount',
+             'type': float},
+            {'section': 'rattail.custorders',
+             'option': 'allow_past_item_reorder',
+             'type': bool},
+
+            # unknown products
+            {'section': 'rattail.custorders',
+             'option': 'allow_unknown_product',
+             'type': bool},
+            {'section': 'rattail.custorders',
+             'option': 'unknown_product.always_confirm_price',
+             'type': bool},
+        ]
+
+        for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
+            setting = {'section': 'rattail.custorders',
+                       'option': f'unknown_product.fields.{field}.required',
+                       'type': bool}
+            if field == 'description':
+                setting['default'] = True
+            settings.append(setting)
+
+        return settings
+
+    def configure_get_context(self, **kwargs):
+        context = super().configure_get_context(**kwargs)
+
+        context['pending_product_fields'] = self.PENDING_PRODUCT_ENTRY_FIELDS
+
+        return context
+
+    @classmethod
+    def defaults(cls, config):
+        cls._order_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _order_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        model_title = cls.get_model_title()
+        model_title_plural = cls.get_model_title_plural()
+        permission_prefix = cls.get_permission_prefix()
+
+        config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
+
+        config.add_tailbone_permission(permission_prefix,
+                                       f'{permission_prefix}.create_unknown_product',
+                                       f"Create new {model_title} for unknown product")
+
+        # add pseudo-index page for creating new custorder
+        # (makes it available when building menus etc.)
+        config.add_tailbone_index_page('{}.create'.format(route_prefix),
+                                       "New {}".format(model_title),
+                                       '{}.create'.format(permission_prefix))
+
+        # person autocomplete
+        config.add_route('{}.person_autocomplete'.format(route_prefix),
+                         '{}/person-autocomplete'.format(url_prefix),
+                         request_method='GET')
+        config.add_view(cls, attr='person_autocomplete',
+                        route_name='{}.person_autocomplete'.format(route_prefix),
+                        renderer='json',
+                        permission='people.list')
+
+        # customer autocomplete
+        config.add_route('{}.customer_autocomplete'.format(route_prefix),
+                         '{}/customer-autocomplete'.format(url_prefix),
+                         request_method='GET')
+        config.add_view(cls, attr='customer_autocomplete',
+                        route_name='{}.customer_autocomplete'.format(route_prefix),
+                        renderer='json',
+                        permission='customers.list')
+
+        # custom product autocomplete
+        config.add_route('{}.product_autocomplete'.format(route_prefix),
+                         '{}/product-autocomplete'.format(url_prefix),
+                         request_method='GET')
+        config.add_view(cls, attr='product_autocomplete',
+                        route_name='{}.product_autocomplete'.format(route_prefix),
+                        renderer='json',
+                        permission='products.list')
+
+        # fetch order data
+        config.add_route(f'{route_prefix}.fetch_order_data',
+                         f'{url_prefix}/fetch-order-data')
+        config.add_view(cls, attr='fetch_order_data',
+                        route_name=f'{route_prefix}.fetch_order_data',
+                        renderer='json',
+                        permission=f'{permission_prefix}.view')
+
+
+# TODO: deprecate / remove this
+CustomerOrdersView = CustomerOrderView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    CustomerOrderView = kwargs.get('CustomerOrderView', base['CustomerOrderView'])
+    CustomerOrderView.defaults(config)
 
 
 def includeme(config):
-    CustomerOrdersView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index 309b3bc2..2b955b5f 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,28 +24,379 @@
 DataSync Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import json
 import subprocess
 import logging
 
-from rattail.db import model
+import sqlalchemy as sa
+
+from rattail.db.model import DataSyncChange
+from rattail.datasync.util import purge_datasync_settings
+from rattail.util import simple_error
+
+from webhelpers2.html import tags
 
 from tailbone.views import MasterView
+from tailbone.util import raw_datetime
+from tailbone.config import should_expose_websockets
 
 
 log = logging.getLogger(__name__)
 
 
-class DataSyncChangesView(MasterView):
+class DataSyncThreadView(MasterView):
+    """
+    Master view for DataSync itself.
+
+    This should (eventually) show all running threads in the main
+    index view, with status for each, sort of akin to "dashboard".
+    For now it only serves the config view.
+    """
+    model_title = "DataSync Thread"
+    model_title_plural = "DataSync Status"
+    model_key = 'key'
+    route_prefix = 'datasync'
+    url_prefix = '/datasync'
+    listable = False
+    viewable = False
+    creatable = False
+    editable = False
+    deletable = False
+    filterable = False
+    pageable = False
+
+    configurable = True
+    config_title = "DataSync"
+
+    grid_columns = [
+        'key',
+    ]
+
+    def __init__(self, request, context=None):
+        super().__init__(request, context=context)
+        app = self.get_rattail_app()
+        self.datasync_handler = app.get_datasync_handler()
+
+    def get_context_menu_items(self, thread=None):
+        items = super().get_context_menu_items(thread)
+        route_prefix = self.get_route_prefix()
+
+        # nb. do not show this for /configure page
+        if self.request.matched_route.name != f'{route_prefix}.configure':
+            if self.request.has_perm('datasync_changes.list'):
+                url = self.request.route_url('datasyncchanges')
+                items.append(tags.link_to("View DataSync Changes", url))
+
+        return items
+
+    def status(self):
+        """
+        View to list/filter/sort the model data.
+
+        If this view receives a non-empty 'partial' parameter in the query
+        string, then the view will return the rendered grid only.  Otherwise
+        returns the full page.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+
+        try:
+            process_info = self.datasync_handler.get_supervisor_process_info()
+            supervisor_error = None
+        except Exception as error:
+            log.warning("failed to get supervisor process info", exc_info=True)
+            process_info = None
+            supervisor_error = simple_error(error)
+
+        try:
+            profiles = self.datasync_handler.get_configured_profiles()
+        except Exception as error:
+            log.warning("could not load profiles!", exc_info=True)
+            self.request.session.flash(simple_error(error), 'error')
+            profiles = {}
+
+        sql = """
+        select source, consumer, count(*) as changes
+        from datasync_change
+        group by source, consumer
+        """
+        result = self.Session.execute(sa.text(sql))
+        all_changes = {}
+        for row in result:
+            all_changes[(row.source, row.consumer)] = row.changes
+
+        watcher_data = []
+        consumer_data = []
+        now = app.localtime()
+        for key, profile in profiles.items():
+            watcher = profile.watcher
+
+            lastrun = self.datasync_handler.get_watcher_lastrun(
+                watcher.key, local=True, session=self.Session())
+
+            status = "okay"
+            if (now - lastrun).total_seconds() >= (watcher.delay * 2):
+                status = "dead watcher"
+
+            watcher_data.append({
+                'key': watcher.key,
+                'spec': profile.watcher_spec,
+                'dbkey': watcher.dbkey,
+                'delay': watcher.delay,
+                'lastrun': raw_datetime(self.rattail_config, lastrun, verbose=True),
+                'status': status,
+            })
+
+            for consumer in profile.consumers:
+                if consumer.watcher is watcher:
+
+                    changes = all_changes.get((watcher.key, consumer.key), 0)
+                    if changes:
+                        oldest = self.Session.query(sa.func.min(model.DataSyncChange.obtained))\
+                                             .filter(model.DataSyncChange.source == watcher.key)\
+                                             .filter(model.DataSyncChange.consumer == consumer.key)\
+                                             .scalar()
+                        oldest = app.localtime(oldest, from_utc=True)
+                        changes = "{} (oldest from {})".format(
+                            changes,
+                            app.render_time_ago(now - oldest))
+
+                    status = "okay"
+                    if changes:
+                        status = "processing changes"
+
+                    consumer_data.append({
+                        'key': '{} -> {}'.format(watcher.key, consumer.key),
+                        'spec': consumer.spec,
+                        'dbkey': consumer.dbkey,
+                        'delay': consumer.delay,
+                        'changes': changes,
+                        'status': status,
+                    })
+
+        watcher_data.sort(key=lambda w: w['key'])
+        consumer_data.sort(key=lambda c: c['key'])
+
+        context = {
+            'index_title': "DataSync Status",
+            'index_url': None,
+            'process_info': process_info,
+            'supervisor_error': supervisor_error,
+            'watcher_data': watcher_data,
+            'consumer_data': consumer_data,
+        }
+        return self.render_to_response('status', context)
+
+    def get_data(self, session=None):
+        data = []
+        return data
+
+    def restart(self):
+        try:
+            self.datasync_handler.restart_supervisor_process()
+            self.request.session.flash("DataSync daemon has been restarted.")
+
+        except Exception as error:
+            self.request.session.flash(simple_error(error), 'error')
+
+        return self.redirect(self.request.get_referrer(
+            default=self.request.route_url('datasyncchanges')))
+
+    def configure_get_simple_settings(self):
+        """ """
+        return [
+
+            # basic
+            {'section': 'rattail.datasync',
+             'option': 'use_profile_settings',
+             'type': bool},
+
+            # misc.
+            {'section': 'rattail.datasync',
+             'option': 'supervisor_process_name'},
+            {'section': 'rattail.datasync',
+             'option': 'batch_size_limit',
+             'type': int},
+
+            # legacy
+            {'section': 'tailbone',
+             'option': 'datasync.restart'},
+
+        ]
+
+    def configure_get_context(self, **kwargs):
+        """ """
+        context = super().configure_get_context(**kwargs)
+
+        profiles = self.datasync_handler.get_configured_profiles(
+            include_disabled=True,
+            ignore_problems=True)
+        context['profiles'] = profiles
+
+        profiles_data = []
+        for profile in sorted(profiles.values(), key=lambda p: p.key):
+            data = {
+                'key': profile.key,
+                'watcher_spec': profile.watcher_spec,
+                'watcher_dbkey': profile.watcher.dbkey,
+                'watcher_delay': profile.watcher.delay,
+                'watcher_retry_attempts': profile.watcher.retry_attempts,
+                'watcher_retry_delay': profile.watcher.retry_delay,
+                'watcher_default_runas': profile.watcher.default_runas,
+                'watcher_consumes_self': profile.watcher.consumes_self,
+                'watcher_kwargs_data': [{'key': key,
+                                         'value': profile.watcher_kwargs[key]}
+                                        for key in sorted(profile.watcher_kwargs)],
+                # 'notes': None,   # TODO
+                'enabled': profile.enabled,
+            }
+
+            consumers = []
+            if profile.watcher.consumes_self:
+                pass
+            else:
+                for consumer in sorted(profile.consumers, key=lambda c: c.key):
+                    consumers.append({
+                        'key': consumer.key,
+                        'consumer_spec': consumer.spec,
+                        'consumer_dbkey': consumer.dbkey,
+                        'consumer_runas': getattr(consumer, 'runas', None),
+                        'consumer_delay': consumer.delay,
+                        'consumer_retry_attempts': consumer.retry_attempts,
+                        'consumer_retry_delay': consumer.retry_delay,
+                        'enabled': consumer.enabled,
+                    })
+            data['consumers_data'] = consumers
+            profiles_data.append(data)
+
+        context['profiles_data'] = profiles_data
+        return context
+
+    def configure_gather_settings(self, data, **kwargs):
+        """ """
+        settings = super().configure_gather_settings(data, **kwargs)
+
+        if data.get('rattail.datasync.use_profile_settings') == 'true':
+            watch = []
+
+            for profile in json.loads(data['profiles']):
+                pkey = profile['key']
+                if profile['enabled']:
+                    watch.append(pkey)
+
+                settings.extend([
+                    {'name': 'rattail.datasync.{}.watcher.spec'.format(pkey),
+                     'value': profile['watcher_spec']},
+                    {'name': 'rattail.datasync.{}.watcher.db'.format(pkey),
+                     'value': profile['watcher_dbkey']},
+                    {'name': 'rattail.datasync.{}.watcher.delay'.format(pkey),
+                     'value': profile['watcher_delay']},
+                    {'name': 'rattail.datasync.{}.watcher.retry_attempts'.format(pkey),
+                     'value': profile['watcher_retry_attempts']},
+                    {'name': 'rattail.datasync.{}.watcher.retry_delay'.format(pkey),
+                     'value': profile['watcher_retry_delay']},
+                    {'name': 'rattail.datasync.{}.consumers.runas'.format(pkey),
+                     'value': profile['watcher_default_runas']},
+                ])
+
+                for kwarg in profile['watcher_kwargs_data']:
+                    settings.append({
+                        'name': 'rattail.datasync.{}.watcher.kwarg.{}'.format(
+                            pkey, kwarg['key']),
+                        'value': kwarg['value'],
+                    })
+
+                consumers = []
+                if profile['watcher_consumes_self']:
+                    consumers = ['self']
+                else:
+
+                    for consumer in profile['consumers_data']:
+                        ckey = consumer['key']
+                        if consumer['enabled']:
+                            consumers.append(ckey)
+                        settings.extend([
+                            {'name': f'rattail.datasync.{pkey}.consumer.{ckey}.spec',
+                             'value': consumer['consumer_spec']},
+                            {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey),
+                             'value': consumer['consumer_dbkey']},
+                            {'name': 'rattail.datasync.{}.consumer.{}.delay'.format(pkey, ckey),
+                             'value': consumer['consumer_delay']},
+                            {'name': 'rattail.datasync.{}.consumer.{}.retry_attempts'.format(pkey, ckey),
+                             'value': consumer['consumer_retry_attempts']},
+                            {'name': 'rattail.datasync.{}.consumer.{}.retry_delay'.format(pkey, ckey),
+                             'value': consumer['consumer_retry_delay']},
+                            {'name': 'rattail.datasync.{}.consumer.{}.runas'.format(pkey, ckey),
+                             'value': consumer['consumer_runas']},
+                        ])
+
+                settings.extend([
+                    {'name': 'rattail.datasync.{}.consumers.list'.format(pkey),
+                     'value': ', '.join(consumers)},
+                ])
+
+            if watch:
+                settings.append({'name': 'rattail.datasync.watch',
+                                 'value': ', '.join(watch)})
+
+        return settings
+
+    def configure_remove_settings(self, **kwargs):
+        """ """
+        super().configure_remove_settings(**kwargs)
+
+        purge_datasync_settings(self.rattail_config, self.Session())
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._datasync_defaults(config)
+
+    @classmethod
+    def _datasync_defaults(cls, config):
+        permission_prefix = cls.get_permission_prefix()
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        index_title = cls.get_index_title()
+
+        # view status
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.status'.format(permission_prefix),
+                                       "View status for DataSync daemon")
+        # nb. simple 'datasync' route points to 'datasync.status' for now..
+        config.add_route(route_prefix,
+                         '{}/status/'.format(url_prefix))
+        config.add_route('{}.status'.format(route_prefix),
+                         '{}/status/'.format(url_prefix))
+        config.add_view(cls, attr='status',
+                        route_name=route_prefix,
+                        permission='{}.status'.format(permission_prefix))
+        config.add_view(cls, attr='status',
+                        route_name='{}.status'.format(route_prefix),
+                        permission='{}.status'.format(permission_prefix))
+        config.add_tailbone_index_page(route_prefix, index_title,
+                                       '{}.status'.format(permission_prefix))
+
+        # restart
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.restart'.format(permission_prefix),
+                                       label="Restart the DataSync daemon")
+        config.add_route('{}.restart'.format(route_prefix),
+                         '{}/restart'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='restart',
+                        route_name='{}.restart'.format(route_prefix),
+                        permission='{}.restart'.format(permission_prefix))
+
+
+class DataSyncChangeView(MasterView):
     """
     Master view for the DataSyncChange model.
     """
-    model_class = model.DataSyncChange
+    model_class = DataSyncChange
     url_prefix = '/datasync/changes'
-    permission_prefix = 'datasync'
+    permission_prefix = 'datasync_changes'
     creatable = False
-    editable = False
     bulk_deletable = True
 
     labels = {
@@ -63,8 +414,19 @@ class DataSyncChangesView(MasterView):
         'consumer',
     ]
 
+    def get_context_menu_items(self, change=None):
+        items = super().get_context_menu_items(change)
+
+        if self.listing:
+
+            if self.request.has_perm('datasync.status'):
+                url = self.request.route_url('datasync.status')
+                items.append(tags.link_to("View DataSync Status", url))
+
+        return items
+
     def configure_grid(self, g):
-        super(DataSyncChangesView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # batch_sequence
         g.set_label('batch_sequence', "Batch Seq.")
@@ -77,42 +439,29 @@ class DataSyncChangesView(MasterView):
         kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart'))
         return kwargs
 
-    def restart(self):
-        # TODO: Add better validation (e.g. CSRF) here?
-        if self.request.method == 'POST':
-            cmd = self.rattail_config.getlist('tailbone', 'datasync.restart', default='/bin/sleep 3') # simulate by default
-            log.debug("attempting datasync restart with command: {}".format(cmd))
-            result = subprocess.call(cmd)
-            if result == 0:
-                self.request.session.flash("DataSync daemon has been restarted.")
-            else:
-                self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error')
-        return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges')))
+    def configure_form(self, f):
+        super().configure_form(f)
 
-    def mobile_index(self):
-        return {}
+        f.set_readonly('obtained')
 
-    @classmethod
-    def defaults(cls, config):
-        rattail_config = config.registry.settings.get('rattail_config')
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
 
-        # fix permission group title
-        config.add_tailbone_permission_group('datasync', label="DataSync")
+# TODO: deprecate / remove this
+DataSyncChangesView = DataSyncChangeView
 
-        # restart datasync
-        config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon")
-        # desktop
-        config.add_route('datasync.restart', '/datasync/restart')
-        config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart')
-        # mobile
-        if legacy_mobile:
-            config.add_route('datasync.mobile', '/mobile/datasync/')
-            config.add_view(cls, attr='mobile_index', route_name='datasync.mobile',
-                            permission='datasync.restart', renderer='/mobile/datasync.mako')
 
-        cls._defaults(config)
+def defaults(config, **kwargs):
+    base = globals()
+    rattail_config = config.registry['rattail_config']
+
+    DataSyncThreadView = kwargs.get('DataSyncThreadView', base['DataSyncThreadView'])
+    DataSyncThreadView.defaults(config)
+
+    DataSyncChangeView = kwargs.get('DataSyncChangeView', base['DataSyncChangeView'])
+    DataSyncChangeView.defaults(config)
+
+    if should_expose_websockets(rattail_config):
+        config.include('tailbone.views.asgi.datasync')
 
 
 def includeme(config):
-    DataSyncChangesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index c1369543..47de8dca 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,31 +24,30 @@
 Department Views
 """
 
-from __future__ import unicode_literals, absolute_import
+from rattail.db.model import Department, Product
 
-import six
+from webhelpers2.html import HTML
 
-from rattail.db import model
-
-from deform import widget as dfwidget
-
-from tailbone import grids
-from tailbone.views import MasterView, AutocompleteView
+from tailbone.views import MasterView
 
 
-class DepartmentsView(MasterView):
+class DepartmentView(MasterView):
     """
     Master view for the Department class.
     """
-    model_class = model.Department
+    model_class = Department
     touchable = True
     has_versions = True
+    results_downloadable = True
+    supports_autocomplete = True
 
     grid_columns = [
         'number',
         'name',
         'product',
         'personnel',
+        'tax',
+        'food_stampable',
         'exempt_from_gross_sales',
     ]
 
@@ -57,38 +56,114 @@ class DepartmentsView(MasterView):
         'name',
         'product',
         'personnel',
+        'tax',
+        'food_stampable',
         'exempt_from_gross_sales',
+        'default_custorder_discount',
+        'allow_product_deletions',
+        'employees',
+    ]
+
+    has_rows = True
+    model_row_class = Product
+    rows_title = "Products"
+
+    row_labels = {
+        'upc': "UPC",
+    }
+
+    row_grid_columns = [
+        'upc',
+        'brand',
+        'description',
+        'size',
+        'vendor',
+        'regular_price',
+        'current_price',
     ]
 
     def configure_grid(self, g):
-        super(DepartmentsView, self).configure_grid(g)
+        super().configure_grid(g)
+
+        # number
+        g.set_sort_defaults('number')
+        g.set_link('number')
+
+        # name
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
-        g.set_sort_defaults('number')
-        g.set_type('product', 'boolean')
-        g.set_type('personnel', 'boolean')
-        g.set_link('number')
         g.set_link('name')
 
+        g.set_type('product', 'boolean')
+        g.set_type('personnel', 'boolean')
+
     def configure_form(self, f):
-        super(DepartmentsView, self).configure_form(f)
+        super().configure_form(f)
+
         f.remove_field('subdepartments')
-        f.remove_field('employees')
+
+        if self.creating or self.editing:
+            f.remove('employees')
+        else:
+            f.set_renderer('employees', self.render_employees)
+
         f.set_type('product', 'boolean')
         f.set_type('personnel', 'boolean')
 
-    def template_kwargs_view(self, **kwargs):
-        department = kwargs['instance']
-        if department.employees:
-            employees = sorted(department.employees, key=six.text_type)
-            actions = [
-                grids.GridAction('view', icon='zoomin',
-                                 url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid))
-            ]
-            kwargs['employees'] = grids.Grid(None, employees, ['display_name'], request=self.request,
-                                             model_class=model.Employee, main_actions=actions)
+        # tax
+        if self.creating:
+            # TODO: make this editable instead
+            f.remove('tax')
         else:
-            kwargs['employees'] = None
+            f.set_renderer('tax', self.render_tax)
+            # TODO: make this editable
+            f.set_readonly('tax')
+
+        # default_custorder_discount
+        f.set_type('default_custorder_discount', 'percent')
+
+    def render_employees(self, department, field):
+        route_prefix = self.get_route_prefix()
+        permission_prefix = self.get_permission_prefix()
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.employees',
+            data=[],
+            columns=[
+                'first_name',
+                'last_name',
+            ],
+            sortable=True,
+            sorters={'first_name': True, 'last_name': True},
+        )
+
+        if self.request.has_perm('employees.view'):
+            g.actions.append(self.make_action('view', icon='eye'))
+        if self.request.has_perm('employees.edit'):
+            g.actions.append(self.make_action('edit', icon='edit'))
+
+        return HTML.literal(
+            g.render_table_element(data_prop='employeesData'))
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        department = kwargs['instance']
+        department_employees = sorted(department.employees, key=str)
+
+        employees = []
+        for employee in department_employees:
+            person = employee.person
+            employees.append({
+                'uuid': employee.uuid,
+                'first_name': person.first_name,
+                'last_name': person.last_name,
+                '_action_url_view': self.request.route_url('employees.view', uuid=employee.uuid),
+                '_action_url_edit': self.request.route_url('employees.edit', uuid=employee.uuid),
+            })
+        kwargs['employees_data'] = employees
+
         return kwargs
 
     def before_delete(self, department):
@@ -96,6 +171,7 @@ class DepartmentsView(MasterView):
         Check to see if there are any products which belong to the department;
         if there are then we do not allow delete and redirect the user.
         """
+        model = self.model
         count = self.Session.query(model.Product)\
                             .filter(model.Product.department == department)\
                             .count()
@@ -104,10 +180,38 @@ class DepartmentsView(MasterView):
                 count, department), 'error')
             raise self.redirect(self.get_action_url('view', department))
 
+    def get_row_data(self, department):
+        model = self.model
+        return self.Session.query(model.Product)\
+                           .filter(model.Product.department == department)
+
+    def get_parent(self, product):
+        return product.department
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        app = self.get_rattail_app()
+        self.handler = app.get_products_handler()
+        g.set_renderer('regular_price', self.render_price)
+        g.set_renderer('current_price', self.render_price)
+
+        g.set_sort_defaults('upc')
+
+    def render_price(self, product, field):
+        if not product.not_for_sale:
+            price = product[field]
+            if price:
+                return self.handler.render_price(price)
+
+    def row_view_action_url(self, product, i):
+        return self.request.route_url('products.view', uuid=product.uuid)
+
     def list_by_vendor(self):
         """
         View list of departments by vendor
         """
+        model = self.model
         data = self.Session.query(model.Department)\
                            .outerjoin(model.Product)\
                            .join(model.ProductCost)\
@@ -116,20 +220,14 @@ class DepartmentsView(MasterView):
                            .distinct()\
                            .order_by(model.Department.name)
 
-        def configure(g):
-            g.configure(include=[
-                g.name,
-            ], readonly=True)
+        def normalize(dept):
+            return {
+                'uuid': dept.uuid,
+                'number': dept.number,
+                'name': dept.name,
+            }
 
-        def row_attrs(row, i):
-            return {'data-uuid': row.uuid}
-
-        grid = self.make_grid(data=data, sortable=False, filterable=False, pageable=False,
-                              configure=configure, width=None, checkboxes=True,
-                              row_attrs=row_attrs, main_actions=[], more_actions=[])
-        self.request.response.content_type = str('text/html')
-        self.request.response.text = grid.render_grid()
-        return self.request.response
+        return self.json_response([normalize(d) for d in data])
 
     @classmethod
     def defaults(cls, config):
@@ -145,17 +243,12 @@ class DepartmentsView(MasterView):
         cls._defaults(config)
 
 
-class DepartmentsAutocomplete(AutocompleteView):
+def defaults(config, **kwargs):
+    base = globals()
 
-    mapped_class = model.Department
-    fieldname = 'name'
+    DepartmentView = kwargs.get('DepartmentView', base['DepartmentView'])
+    DepartmentView.defaults(config)
 
 
 def includeme(config):
-
-    # autocomplete
-    config.add_route('departments.autocomplete',        '/departments/autocomplete')
-    config.add_view(DepartmentsAutocomplete, route_name='departments.autocomplete',
-                    renderer='json', permission='departments.list')
-
-    DepartmentsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py
index db28e3f6..1c9abde1 100644
--- a/tailbone/views/depositlinks.py
+++ b/tailbone/views/depositlinks.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -31,7 +31,7 @@ from rattail.db import model
 from tailbone.views import MasterView
 
 
-class DepositLinksView(MasterView):
+class DepositLinkView(MasterView):
     """
     Master view for deposit links.
     """
@@ -52,7 +52,7 @@ class DepositLinksView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(DepositLinksView, self).configure_grid(g)
+        super(DepositLinkView, self).configure_grid(g)
         g.filters['description'].default_active = True
         g.filters['description'].default_verb = 'contains'
         g.set_sort_defaults('code')
@@ -60,6 +60,16 @@ class DepositLinksView(MasterView):
         g.set_link('code')
         g.set_link('description')
 
+# TODO: deprecate / remove this
+DepositLinksView = DepositLinkView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    DepositLinkView = kwargs.get('DepositLinkView', base['DepositLinkView'])
+    DepositLinkView.defaults(config)
+
 
 def includeme(config):
-    DepositLinksView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index 1349d4cc..98bd4295 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,23 +24,27 @@
 Email Views
 """
 
-from __future__ import unicode_literals, absolute_import
+import logging
+import re
+import warnings
 
-import six
+from wuttjamaican.util import parse_list
 
-from rattail import mail
-from rattail.db import api, model
-from rattail.config import parse_list
+from rattail.db.model import EmailAttempt
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
-from webhelpers2.html import HTML
 
+from tailbone import grids
 from tailbone.db import Session
 from tailbone.views import View, MasterView
 
 
-class ProfilesView(MasterView):
+log = logging.getLogger(__name__)
+
+
+class EmailSettingView(MasterView):
     """
     Master view for email admin (settings/preview).
     """
@@ -52,6 +56,8 @@ class ProfilesView(MasterView):
     pageable = False
     creatable = False
     deletable = False
+    configurable = True
+    config_title = "Email"
 
     grid_columns = [
         'key',
@@ -59,6 +65,7 @@ class ProfilesView(MasterView):
         'subject',
         'to',
         'enabled',
+        'hidden',
     ]
 
     form_fields = [
@@ -73,37 +80,70 @@ class ProfilesView(MasterView):
         'cc',
         'bcc',
         'enabled',
+        'hidden',
     ]
 
     def __init__(self, request):
-        super(ProfilesView, self).__init__(request)
-        self.handler = self.get_handler()
+        super().__init__(request)
+        self.email_handler = self.get_handler()
+
+    @property
+    def handler(self):
+        warnings.warn("the `handler` property is deprecated!  "
+                      "please use `email_handler` instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.email_handler
 
     def get_handler(self):
-        # TODO: should let config override which handler we use
-        return mail.EmailHandler(self.rattail_config)
+        app = self.get_rattail_app()
+        return app.get_email_handler()
 
     def get_data(self, session=None):
         data = []
-        for email in self.handler.iter_emails():
-            key = email.key or email.__name__
-            email = email(self.rattail_config, key)
-            data.append(self.normalize(email))
+        if self.has_perm('configure'):
+            emails = self.email_handler.get_all_emails()
+        else:
+            emails = self.email_handler.get_available_emails()
+        for key, Email in emails.items():
+            email = Email(self.rattail_config, key)
+            try:
+                normalized = self.normalize(email)
+            except:
+                log.warning("cannot normalize email: %s", email,
+                            exc_info=True)
+            else:
+                data.append(normalized)
         return data
 
     def configure_grid(self, g):
-        g.sorters['key'] = g.make_simple_sorter('key', foldcase=True)
-        g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True)
-        g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True)
-        g.sorters['enabled'] = g.make_simple_sorter('enabled')
+        super().configure_grid(g)
+
+        g.sort_on_backend = False
+        g.sort_multiple = False
         g.set_sort_defaults('key')
+
         g.set_type('enabled', 'boolean')
         g.set_link('key')
         g.set_link('subject')
 
+        g.set_searchable('key')
+        g.set_searchable('subject')
+
         # to
         g.set_renderer('to', self.render_to_short)
-        g.sorters['to'] = g.make_simple_sorter('to', foldcase=True)
+
+        # hidden
+        if self.has_perm('configure'):
+            g.set_type('hidden', 'boolean')
+        else:
+            g.remove('hidden')
+
+        # toggle hidden
+        if self.has_perm('configure'):
+            g.actions.append(
+                self.make_action('toggle_hidden', url='#', icon='ban',
+                                 click_handler='toggleHidden(props.row)',
+                                 factory=ToggleHidden))
 
     def render_to_short(self, email, column):
         profile = email['_email']
@@ -126,7 +166,7 @@ class ProfilesView(MasterView):
             if recips:
                 return ', '.join(recips)
         data = email.obtain_sample_data(self.request)
-        return {
+        normal = {
             '_email': email,
             'key': email.key,
             'fallback_key': email.fallback_key,
@@ -140,10 +180,13 @@ class ProfilesView(MasterView):
             'bcc': get_recips('bcc') or '',
             'enabled': email.get_enabled(),
         }
+        if self.has_perm('configure'):
+            normal['hidden'] = self.email_handler.email_is_hidden(email.key)
+        return normal
 
     def get_instance(self):
         key = self.request.matchdict['key']
-        return self.normalize(self.handler.get_email(key))
+        return self.normalize(self.email_handler.get_email(key))
 
     def get_instance_title(self, email):
         return email['_email'].get_complete_subject(render=False)
@@ -159,7 +202,7 @@ class ProfilesView(MasterView):
         return True
 
     def configure_form(self, f):
-        super(ProfilesView, self).configure_form(f)
+        super().configure_form(f)
         profile = f.model_instance['_email']
 
         # key
@@ -200,28 +243,148 @@ class ProfilesView(MasterView):
         # enabled
         f.set_type('enabled', 'boolean')
 
+        # hidden
+        if self.has_perm('configure'):
+            f.set_type('hidden', 'boolean')
+        else:
+            f.remove('hidden')
+
     def make_form_schema(self):
-        return EmailProfileSchema()
+        schema = EmailProfileSchema()
+
+        if not self.has_perm('configure'):
+            hidden = schema.get('hidden')
+            schema.children.remove(hidden)
+
+        return schema
 
     def save_edit_form(self, form):
         key = self.request.matchdict['key']
         data = self.form_deserialized
+        app = self.get_rattail_app()
         session = self.Session()
-        api.save_setting(session, 'rattail.mail.{}.prefix'.format(key), data['prefix'])
-        api.save_setting(session, 'rattail.mail.{}.subject'.format(key), data['subject'])
-        api.save_setting(session, 'rattail.mail.{}.from'.format(key), data['sender'])
-        api.save_setting(session, 'rattail.mail.{}.replyto'.format(key), data['replyto'])
-        api.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', '))
-        api.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', '))
-        api.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', '))
-        api.save_setting(session, 'rattail.mail.{}.enabled'.format(key), six.text_type(data['enabled']).lower())
+        app.save_setting(session, 'rattail.mail.{}.prefix'.format(key), data['prefix'])
+        app.save_setting(session, 'rattail.mail.{}.subject'.format(key), data['subject'])
+        app.save_setting(session, 'rattail.mail.{}.from'.format(key), data['sender'])
+        app.save_setting(session, 'rattail.mail.{}.replyto'.format(key), data['replyto'])
+        app.save_setting(session, 'rattail.mail.{}.to'.format(key), (data['to'] or '').replace('\n', ', '))
+        app.save_setting(session, 'rattail.mail.{}.cc'.format(key), (data['cc'] or '').replace('\n', ', '))
+        app.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (data['bcc'] or '').replace('\n', ', '))
+        app.save_setting(session, 'rattail.mail.{}.enabled'.format(key), str(data['enabled']).lower())
+        if self.has_perm('configure'):
+            app.save_setting(session, 'rattail.mail.{}.hidden'.format(key), str(data['hidden']).lower())
         return data
 
     def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
+
         key = self.request.matchdict['key']
-        kwargs['email'] = self.handler.get_email(key)
+        kwargs['email'] = self.email_handler.get_email(key)
+
+        kwargs['user_email_address'] = app.get_contact_email_address(self.request.user)
+
         return kwargs
 
+    def configure_get_simple_settings(self):
+        config = self.rattail_config
+        return [
+
+            # general
+            {'section': 'rattail.mail',
+             'option': 'handler'},
+            {'section': 'rattail.mail',
+             'option': 'templates'},
+
+            # sending
+            {'section': 'rattail.mail',
+             'option': 'record_attempts',
+             'type': bool},
+            {'section': 'rattail.mail',
+             'option': 'send_email_on_failure',
+             'type': bool},
+        ]
+
+    def configure_get_context(self, *args, **kwargs):
+        context = super().configure_get_context(*args, **kwargs)
+        app = self.get_rattail_app()
+
+        # prettify list of template paths
+        templates = self.rattail_config.parse_list(
+            context['simple_settings']['rattail.mail.templates'])
+        context['simple_settings']['rattail.mail.templates'] = ', '.join(templates)
+
+        context['user_email_address'] = app.get_contact_email_address(self.request.user)
+
+        return context
+
+    def toggle_hidden(self):
+        app = self.get_rattail_app()
+        data = self.request.json_body
+        name = 'rattail.mail.{}.hidden'.format(data['key'])
+        app.save_setting(self.Session(), name,
+                         'true' if data['hidden'] else 'false')
+        return {'ok': True}
+
+    def send_test(self):
+        """
+        AJAX view for sending a test email.
+        """
+        data = self.request.json_body
+
+        recip = data.get('recipient')
+        if not recip:
+            return {'error': "Must specify recipient"}
+
+        app = self.get_rattail_app()
+        app.send_email('hello', to=[recip], cc=None, bcc=None,
+                       default_subject="Hello world")
+
+        return {'ok': True}
+
+    @classmethod
+    def defaults(cls, config):
+        cls._email_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _email_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        model_title_plural = cls.get_model_title_plural()
+
+        # toggle hidden
+        config.add_route('{}.toggle_hidden'.format(route_prefix),
+                         '{}/toggle-hidden'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='toggle_hidden',
+                        route_name='{}.toggle_hidden'.format(route_prefix),
+                        permission='{}.configure'.format(permission_prefix),
+                        renderer='json')
+
+        # send test
+        config.add_route('{}.send_test'.format(route_prefix),
+                         '{}/send-test'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='send_test',
+                        route_name='{}.send_test'.format(route_prefix),
+                        permission='{}.configure'.format(permission_prefix),
+                        renderer='json')
+
+
+# TODO: deprecate / remove this
+ProfilesView = EmailSettingView
+
+
+class ToggleHidden(grids.GridAction):
+    """
+    Grid action for toggling the 'hidden' flag for an email profile.
+    """
+
+    def render_label(self):
+        return '{{ renderLabelToggleHidden(props.row) }}'
+
 
 class RecipientsType(colander.String):
     """
@@ -263,6 +426,8 @@ class EmailProfileSchema(colander.MappingSchema):
 
     enabled = colander.SchemaNode(colander.Boolean())
 
+    hidden = colander.SchemaNode(colander.Boolean())
+
 
 class EmailPreview(View):
     """
@@ -270,12 +435,23 @@ class EmailPreview(View):
     """
 
     def __init__(self, request):
-        super(EmailPreview, self).__init__(request)
-        self.handler = self.get_handler()
+        super().__init__(request)
 
-    def get_handler(self):
-        # TODO: should let config override which handler we use
-        return mail.EmailHandler(self.rattail_config)
+        if hasattr(self, 'get_handler'):
+            warnings.warn("defining a get_handler() method is deprecated; "
+                          "please use AppHandler.get_email_handler() instead",
+                          DeprecationWarning, stacklevel=2)
+            self.email_handler = get_handler()
+        else:
+            app = self.get_rattail_app()
+            self.email_handler = app.get_email_handler()
+
+    @property
+    def handler(self):
+        warnings.warn("the `handler` property is deprecated!  "
+                      "please use `email_handler` instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.email_handler
 
     def __call__(self):
 
@@ -298,30 +474,31 @@ class EmailPreview(View):
         if recipient:
             key = self.request.POST.get('email_key')
             if key:
-                email = self.handler.get_email(key)
-                data = email.obtain_sample_data(self.request)
-                msg = email.make_message(data)
+                email = self.email_handler.get_email(key)
 
-                subject = msg['Subject']
-                del msg['Subject']
-                msg['Subject'] = "[preview] {0}".format(subject)
+                context = self.email_handler.make_context()
+                context.update(email.obtain_sample_data(self.request))
 
-                del msg['To']
-                del msg['Cc']
-                del msg['Bcc']
-                msg['To'] = recipient
-
-                # TODO: should refactor this to use email handler
-                sent = mail.deliver_message(self.rattail_config, key, msg)
-
-                self.request.session.flash("Preview for '{}' was {}emailed to {}".format(
-                    key, '' if sent else '(NOT) ', recipient))
+                try:
+                    self.email_handler.send_message(email, context,
+                                                    subject_prefix="[PREVIEW] ",
+                                                    to=[recipient],
+                                                    cc=None, bcc=None)
+                except Exception as error:
+                    self.request.session.flash(simple_error(error), 'error')
+                else:
+                    self.request.session.flash(
+                        "Preview for '{}' was emailed to {}".format(
+                            key, recipient))
 
     def preview_template(self, key, type_):
-        email = self.handler.get_email(key)
+        email = self.email_handler.get_email(key)
         template = email.get_template(type_)
-        data = email.obtain_sample_data(self.request)
-        self.request.response.text = template.render(**data)
+
+        context = self.email_handler.make_context()
+        context.update(email.obtain_sample_data(self.request))
+
+        self.request.response.text = template.render(**context)
         if type_ == 'txt':
             self.request.response.content_type = str('text/plain')
         return self.request.response
@@ -341,7 +518,7 @@ class EmailAttemptView(MasterView):
     """
     Master view for email attempts.
     """
-    model_class = model.EmailAttempt
+    model_class = EmailAttempt
     route_prefix = 'email_attempts'
     url_prefix = '/email/attempts'
     creatable = False
@@ -374,7 +551,7 @@ class EmailAttemptView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(EmailAttemptView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # sent
         g.set_sort_defaults('sent', 'desc')
@@ -382,20 +559,54 @@ class EmailAttemptView(MasterView):
         # status_code
         g.set_enum('status_code', self.enum.EMAIL_ATTEMPT)
 
+        # to
+        g.set_renderer('to', self.render_to_short)
+
         # links
         g.set_link('key')
         g.set_link('sender')
         g.set_link('subject')
         g.set_link('to')
 
+    to_pattern = re.compile(r'^\{(.*)\}$')
+
+    def render_to_short(self, attempt, column):
+        value = attempt.to
+        if not value:
+            return
+
+        match = self.to_pattern.match(value)
+        if match:
+            recips = parse_list(match.group(1))
+            if len(recips) > 2:
+                recips = recips[:2]
+                recips.append('...')
+            return ', '.join(recips)
+
+        return value
+
     def configure_form(self, f):
-        super(EmailAttemptView, self).configure_form(f)
+        super().configure_form(f)
+
+        # key
+        f.set_renderer('key', self.render_email_key)
 
         # status_code
         f.set_enum('status_code', self.enum.EMAIL_ATTEMPT)
 
 
-def includeme(config):
-    ProfilesView.defaults(config)
+def defaults(config, **kwargs):
+    base = globals()
+
+    EmailSettingView = kwargs.get('EmailSettingView', base['EmailSettingView'])
+    EmailSettingView.defaults(config)
+
+    EmailPreview = kwargs.get('EmailPreview', base['EmailPreview'])
     EmailPreview.defaults(config)
+
+    EmailAttemptView = kwargs.get('EmailAttemptView', base['EmailAttemptView'])
     EmailAttemptView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py
index dac93e67..debd8fcb 100644
--- a/tailbone/views/employees.py
+++ b/tailbone/views/employees.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,6 @@
 Employee Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 import sqlalchemy as sa
 
 from rattail.db import model
@@ -36,16 +33,19 @@ from deform import widget as dfwidget
 from webhelpers2.html import tags, HTML
 
 from tailbone import grids
-from tailbone.db import Session
-from tailbone.views import MasterView, AutocompleteView
+from tailbone.views import MasterView
 
 
-class EmployeesView(MasterView):
+class EmployeeView(MasterView):
     """
     Master view for the Employee class.
     """
     model_class = model.Employee
     has_versions = True
+    touchable = True
+    supports_autocomplete = True
+    results_downloadable = True
+    configurable = True
 
     labels = {
         'id': "ID",
@@ -79,8 +79,24 @@ class EmployeesView(MasterView):
         'departments',
     ]
 
+    def should_expose_quickie_search(self):
+        if self.expose_quickie_search:
+            return True
+        app = self.get_rattail_app()
+        return app.get_people_handler().should_expose_quickie_search()
+
+    def get_quickie_perm(self):
+        return 'people.quickie'
+
+    def get_quickie_url(self):
+        return self.request.route_url('people.quickie')
+
+    def get_quickie_placeholder(self):
+        app = self.get_rattail_app()
+        return app.get_people_handler().get_quickie_search_placeholder()
+
     def configure_grid(self, g):
-        super(EmployeesView, self).configure_grid(g)
+        super().configure_grid(g)
         route_prefix = self.get_route_prefix()
 
         # phone
@@ -99,9 +115,20 @@ class EmployeesView(MasterView):
         g.filters['email'] = g.make_filter('email', model.EmployeeEmailAddress.address,
                                            label="Email Address")
 
-        # first/last name
-        g.filters['first_name'] = g.make_filter('first_name', model.Person.first_name)
-        g.filters['last_name'] = g.make_filter('last_name', model.Person.last_name)
+        # first_name
+        g.set_link('first_name')
+        g.set_sorter('first_name', model.Person.first_name)
+        g.set_sort_defaults('first_name')
+        g.set_filter('first_name', model.Person.first_name,
+                     default_active=True,
+                     default_verb='contains')
+
+        # last_name
+        g.set_link('last_name')
+        g.set_sorter('last_name', model.Person.last_name)
+        g.set_filter('last_name', model.Person.last_name,
+                     default_active=True,
+                     default_verb='contains')
 
         # username
         if self.request.has_perm('users.view'):
@@ -110,49 +137,66 @@ class EmployeesView(MasterView):
             g.set_sorter('username', model.User.username)
             g.set_renderer('username', self.grid_render_username)
         else:
-            g.hide_column('username')
+            g.remove('username')
 
         # id
-        if self.request.has_perm('{}.edit'.format(route_prefix)):
+        if self.has_perm('edit'):
             g.set_link('id')
         else:
-            g.hide_column('id')
+            g.remove('id')
             del g.filters['id']
 
         # status
-        if self.request.has_perm('{}.edit'.format(route_prefix)):
+        if self.has_perm('view_all'):
             g.set_enum('status', self.enum.EMPLOYEE_STATUS)
             g.filters['status'].default_active = True
             g.filters['status'].default_verb = 'equal'
-            # TODO: why must we set unicode string value here?
-            g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT)
+            g.filters['status'].default_value = str(self.enum.EMPLOYEE_STATUS_CURRENT)
         else:
-            g.hide_column('status')
+            g.remove('status')
             del g.filters['status']
 
-        g.filters['first_name'].default_active = True
-        g.filters['first_name'].default_verb = 'contains'
-
-        g.filters['last_name'].default_active = True
-        g.filters['last_name'].default_verb = 'contains'
-
-        g.sorters['first_name'] = lambda q, d: q.order_by(getattr(model.Person.first_name, d)())
-        g.sorters['last_name'] = lambda q, d: q.order_by(getattr(model.Person.last_name, d)())
-
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.EmployeeEmailAddress.address, d)())
-
-        g.set_sort_defaults('first_name')
+        g.set_sorter('email', model.EmployeeEmailAddress.address)
 
         g.set_label('email', "Email Address")
 
-        g.set_link('first_name')
-        g.set_link('last_name')
+        if (self.request.has_perm('people.view_profile')
+            and self.should_link_straight_to_profile()):
+
+            # add View Raw action
+            url = lambda r, i: self.request.route_url(
+                f'{route_prefix}.view', **self.get_action_route_kwargs(r))
+            # nb. insert to slot 1, just after normal View action
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
+
+    def default_view_url(self):
+        if (self.request.has_perm('people.view_profile')
+            and self.should_link_straight_to_profile()):
+            app = self.get_rattail_app()
+
+            def url(employee, i):
+                person = app.get_person(employee)
+                if person:
+                    return self.request.route_url(
+                        'people.view_profile', uuid=person.uuid,
+                        _anchor='employee')
+                return self.get_action_url('view', employee)
+
+            return url
+
+        return super().default_view_url()
+
+    def should_link_straight_to_profile(self):
+        return self.rattail_config.getbool('rattail',
+                                           'employees.straight_to_profile',
+                                           default=False)
 
     def query(self, session):
-        q = session.query(model.Employee).join(model.Person)
-        if not self.request.has_perm('employees.edit'):
-            q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
-        return q
+        query = super().query(session)
+        query = query.join(model.Person)
+        if not self.has_perm('view_all'):
+            query = query.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
+        return query
 
     def grid_render_username(self, employee, field):
         person = employee.person if employee else None
@@ -164,28 +208,39 @@ class EmployeesView(MasterView):
         if employee.status == self.enum.EMPLOYEE_STATUS_FORMER:
             return 'warning'
 
+    def is_employee_protected(self, employee):
+        for user in employee.person.users:
+            if self.user_is_protected(user):
+                return True
+        return False
+
     def editable_instance(self, employee):
-        if self.rattail_config.demo():
-            return not bool(employee.user and employee.user.username == 'chuck')
-        return True
+        if self.request.is_root:
+            return True
+        return not self.is_employee_protected(employee)
 
     def deletable_instance(self, employee):
-        if self.rattail_config.demo():
-            return not bool(employee.user and employee.user.username == 'chuck')
-        return True
+        if self.request.is_root:
+            return True
+        return not self.is_employee_protected(employee)
 
     def configure_form(self, f):
-        super(EmployeesView, self).configure_form(f)
+        super().configure_form(f)
         employee = f.model_instance
 
         f.set_renderer('person', self.render_person)
-        f.set_renderer('users', self.render_users)
+
+        if self.creating or self.editing:
+            f.remove('users')
+        else:
+            f.set_readonly('users')
+            f.set_renderer('users', self.render_users)
 
         f.set_renderer('stores', self.render_stores)
         f.set_label('stores', "Stores") # TODO: should not be necessary
         if self.creating or self.editing:
             stores = self.get_possible_stores().all()
-            store_values = [(s.uuid, six.text_type(s)) for s in stores]
+            store_values = [(s.uuid, str(s)) for s in stores]
             f.set_node('stores', colander.SchemaNode(colander.Set()))
             f.set_widget('stores', dfwidget.SelectWidget(multiple=True,
                                                          size=len(stores),
@@ -197,7 +252,7 @@ class EmployeesView(MasterView):
         f.set_label('departments', "Departments") # TODO: should not be necessary
         if self.creating or self.editing:
             departments = self.get_possible_departments().all()
-            dept_values = [(d.uuid, six.text_type(d)) for d in departments]
+            dept_values = [(d.uuid, str(d)) for d in departments]
             f.set_node('departments', colander.SchemaNode(colander.Set()))
             f.set_widget('departments', dfwidget.SelectWidget(multiple=True,
                                                               size=len(departments),
@@ -224,7 +279,7 @@ class EmployeesView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        employee = super(EmployeesView, self).objectify(form, data)
+        employee = super().objectify(form, data)
         self.update_stores(employee, data)
         self.update_departments(employee, data)
         return employee
@@ -239,7 +294,7 @@ class EmployeesView(MasterView):
                 employee._stores.append(model.EmployeeStore(store_uuid=uuid))
         for uuid in old_stores:
             if uuid not in new_stores:
-                store = self.Session.query(model.Store).get(uuid)
+                store = self.Session.get(model.Store, uuid)
                 employee.stores.remove(store)
 
     def update_departments(self, employee, data):
@@ -252,7 +307,7 @@ class EmployeesView(MasterView):
                 employee._departments.append(model.EmployeeDepartment(department_uuid=uuid))
         for uuid in old_depts:
             if uuid not in new_depts:
-                dept = self.Session.query(model.Department).get(uuid)
+                dept = self.Session.get(model.Department, uuid)
                 employee.departments.remove(dept)
 
     def get_possible_stores(self):
@@ -267,7 +322,7 @@ class EmployeesView(MasterView):
         person = employee.person if employee else None
         if not person:
             return ""
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
@@ -276,8 +331,8 @@ class EmployeesView(MasterView):
         if not stores:
             return ""
         items = []
-        for store in sorted(stores, key=six.text_type):
-            items.append(HTML.tag('li', c=six.text_type(store)))
+        for store in sorted(stores, key=str):
+            items.append(HTML.tag('li', c=str(store)))
         return HTML.tag('ul', c=items)
 
     def render_departments(self, employee, field):
@@ -285,10 +340,15 @@ class EmployeesView(MasterView):
         if not departments:
             return ""
         items = []
-        for department in sorted(departments, key=six.text_type):
-            items.append(HTML.tag('li', c=six.text_type(department)))
+        for department in sorted(departments, key=str):
+            items.append(HTML.tag('li', c=str(department)))
         return HTML.tag('ul', c=items)
 
+    def touch_instance(self, employee):
+        app = self.get_rattail_app()
+        employment = app.get_employment_handler()
+        employment.touch_employee(self.Session(), employee)
+
     def get_version_child_classes(self):
         return [
             (model.Person, 'uuid', 'person_uuid'),
@@ -298,28 +358,43 @@ class EmployeesView(MasterView):
             (model.EmployeeDepartment, 'employee_uuid'),
         ]
 
+    def configure_get_simple_settings(self):
+        return [
 
-class EmployeesAutocomplete(AutocompleteView):
-    """
-    Autocomplete view for the Employee model, but restricted to return only
-    results for current employees.
-    """
-    mapped_class = model.Person
-    fieldname = 'display_name'
+            # General
+            {'section': 'rattail',
+             'option': 'employees.straight_to_profile',
+             'type': bool},
+        ]
 
-    def filter_query(self, q):
-        return q.join(model.Employee)\
-            .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._employee_defaults(config)
 
-    def value(self, person):
-        return person.employee.uuid
+    @classmethod
+    def _employee_defaults(cls, config):
+        permission_prefix = cls.get_permission_prefix()
+        model_title = cls.get_model_title()
+        model_title_plural = cls.get_model_title_plural()
+
+        # view *all* employees
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.view_all'.format(permission_prefix),
+                                       "View *all* (not just current) {}".format(model_title_plural))
+
+        # view employee "secrets"
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.view_secrets'.format(permission_prefix),
+                                       "View \"secrets\" for {} (e.g. login ID, passcode)".format(model_title))
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    EmployeeView = kwargs.get('EmployeeView', base['EmployeeView'])
+    EmployeeView.defaults(config)
 
 
 def includeme(config):
-
-    # autocomplete
-    config.add_route('employees.autocomplete',  '/employees/autocomplete')
-    config.add_view(EmployeesAutocomplete, route_name='employees.autocomplete',
-                    renderer='json', permission='employees.list')
-
-    EmployeesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py
new file mode 100644
index 00000000..08d2e0c4
--- /dev/null
+++ b/tailbone/views/essentials.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Essential views for convenient includes
+"""
+
+
+def defaults(config, **kwargs):
+    mod = lambda spec: kwargs.get(spec, spec)
+
+    config.include(mod('tailbone.views.auth'))
+    config.include(mod('tailbone.views.common'))
+    config.include(mod('tailbone.views.datasync'))
+    config.include(mod('tailbone.views.email'))
+    config.include(mod('tailbone.views.importing'))
+    config.include(mod('tailbone.views.luigi'))
+    config.include(mod('tailbone.views.menus'))
+    config.include(mod('tailbone.views.people'))
+    config.include(mod('tailbone.views.permissions'))
+    config.include(mod('tailbone.views.progress'))
+    config.include(mod('tailbone.views.reports'))
+    config.include(mod('tailbone.views.roles'))
+    config.include(mod('tailbone.views.settings'))
+    config.include(mod('tailbone.views.tables'))
+    config.include(mod('tailbone.views.upgrades'))
+    config.include(mod('tailbone.views.users'))
+    config.include(mod('tailbone.views.views'))
+
+    # include project views by default, but let caller avoid that by
+    # passing False
+    projects = kwargs.get('tailbone.views.projects', True)
+    if projects:
+        if projects is True:
+            projects = 'tailbone.views.projects'
+        config.include(projects)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py
index c136359a..44df359f 100644
--- a/tailbone/views/exports.py
+++ b/tailbone/views/exports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,19 +24,12 @@
 Master class for generic export history views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import shutil
 
-import six
-
-from rattail.db import model
-
 from pyramid.response import FileResponse
-from webhelpers2.html import HTML, tags
+from webhelpers2.html import tags
 
-from tailbone import forms
 from tailbone.views import MasterView
 
 
@@ -49,6 +42,11 @@ class ExportMasterView(MasterView):
     downloadable = False
     delete_export_files = False
 
+    labels = {
+        'id': "ID",
+        'created_by': "Created by",
+    }
+
     grid_columns = [
         'id',
         'created',
@@ -81,26 +79,30 @@ class ExportMasterView(MasterView):
         return self.get_file_path(export)
 
     def configure_grid(self, g):
-        super(ExportMasterView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
-        g.joiners['created_by'] = lambda q: q.join(model.User)
-        g.sorters['created_by'] = g.make_sorter(model.User.username)
-        g.filters['created_by'] = g.make_filter('created_by', model.User.username)
+        # id
+        g.set_renderer('id', self.render_id)
+        g.set_link('id')
+
+        # filename
+        g.set_link('filename')
+
+        # created
         g.set_sort_defaults('created', 'desc')
 
-        g.set_renderer('id', self.render_id)
-
-        g.set_label('id', "ID")
-        g.set_label('created_by', "Created by")
-
-        g.set_link('id')
-        g.set_link('filename')
+        # created_by
+        g.set_joiner('created_by',
+                     lambda q: q.join(model.User).outerjoin(model.Person))
+        g.set_sorter('created_by', model.Person.display_name)
+        g.set_filter('created_by', model.Person.display_name)
 
     def render_id(self, export, field):
         return export.id_str
 
     def configure_form(self, f):
-        super(ExportMasterView, self).configure_form(f)
+        super().configure_form(f)
         export = f.model_instance
 
         # NOTE: we try to handle the 'creating' scenario even though this class
@@ -143,22 +145,16 @@ class ExportMasterView(MasterView):
             f.set_renderer('filename', self.render_downloadable_file)
 
     def objectify(self, form, data=None):
-        obj = super(ExportMasterView, self).objectify(form, data=data)
+        obj = super().objectify(form, data=data)
         if self.creating:
             obj.created_by = self.request.user
         return obj
 
-    def render_download(self, export, field):
-        path = self.get_file_path(export)
-        text = "{} ({})".format(export.filename, self.readable_size(path))
-        url = self.request.route_url('{}.download'.format(self.get_route_prefix()), uuid=export.uuid)
-        return tags.link_to(text, url)
-
     def render_created_by(self, export, field):
         user = export.created_by
         if not user:
             return ""
-        text = six.text_type(user)
+        text = str(user)
         if self.request.has_perm('users.view'):
             url = self.request.route_url('users.view', uuid=user.uuid)
             return tags.link_to(text, url)
@@ -175,12 +171,8 @@ class ExportMasterView(MasterView):
         export = self.get_instance()
         path = self.get_file_path(export)
         response = FileResponse(path, request=self.request)
-        if six.PY3:
-            response.headers['Content-Length'] = str(os.path.getsize(path))
-            response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename)
-        else:
-            response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path))
-            response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename)
+        response.headers['Content-Length'] = str(os.path.getsize(path))
+        response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename)
         return response
 
     def delete_instance(self, export):
@@ -195,4 +187,4 @@ class ExportMasterView(MasterView):
                 shutil.rmtree(dirname)
 
         # continue w/ normal deletion
-        super(ExportMasterView, self).delete_instance(export)
+        super().delete_instance(export)
diff --git a/tailbone/views/families.py b/tailbone/views/families.py
index 997255b3..2d445b78 100644
--- a/tailbone/views/families.py
+++ b/tailbone/views/families.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -31,7 +31,7 @@ from rattail.db import model
 from tailbone.views import MasterView
 
 
-class FamiliesView(MasterView):
+class FamilyView(MasterView):
     """
     Master view for the Family class.
     """
@@ -39,6 +39,7 @@ class FamiliesView(MasterView):
     model_title_plural = "Families"
     route_prefix = 'families'
     has_versions = True
+    results_downloadable = True
     grid_key = 'families'
 
     grid_columns = [
@@ -51,12 +52,68 @@ class FamiliesView(MasterView):
         'name',
     ]
 
+    has_rows = True
+    model_row_class = model.Product
+
+    row_grid_columns = [
+        '_product_key_',
+        'brand',
+        'description',
+        'size',
+        'department',
+        'vendor',
+        'regular_price',
+        'current_price',
+    ]
+
     def configure_grid(self, g):
-        super(FamiliesView, self).configure_grid(g)
+        super(FamilyView, self).configure_grid(g)
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
+
         g.set_sort_defaults('code')
 
+        g.set_link('code')
+        g.set_link('name')
+
+    def get_row_data(self, family):
+        return self.Session.query(model.Product)\
+                           .filter(model.Product.family == family)
+
+    def get_parent(self, product):
+        return product.family
+
+    def configure_row_grid(self, g):
+        super(FamilyView, self).configure_row_grid(g)
+
+        app = self.get_rattail_app()
+        self.handler = app.get_products_handler()
+        g.set_renderer('regular_price', self.render_price)
+        g.set_renderer('current_price', self.render_price)
+
+        key = self.rattail_config.product_key()
+        field = self.product_key_fields.get(key, key)
+        g.set_sort_defaults(field)
+
+    def render_price(self, product, field):
+        if not product.not_for_sale:
+            price = product[field]
+            if price:
+                return self.handler.render_price(price)
+
+    def row_view_action_url(self, product, i):
+        return self.request.route_url('products.view', uuid=product.uuid)
+
+# TODO: deprecate / remove this
+FamiliesView = FamilyView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    FamilyView = kwargs.get('FamilyView', base['FamilyView'])
+    FamilyView.defaults(config)
+
 
 def includeme(config):
-    FamiliesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/features.py b/tailbone/views/features.py
new file mode 100644
index 00000000..d9417452
--- /dev/null
+++ b/tailbone/views/features.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Feature views
+"""
+
+import colander
+import markdown
+
+from tailbone import forms
+from tailbone.views import View
+
+
+class GenerateFeatureView(View):
+    """
+    View for generating new feature source code
+    """
+
+    def __init__(self, request):
+        super(GenerateFeatureView, self).__init__(request)
+        self.handler = self.get_handler()
+
+    def get_handler(self):
+        app = self.get_rattail_app()
+        handler = app.get_feature_handler()
+        return handler
+
+    def __call__(self):
+        schema = self.handler.make_schema()
+        app_form = forms.Form(schema=schema, request=self.request)
+        for key, value in self.handler.get_defaults().items():
+            app_form.set_default(key, value)
+
+        feature_forms = {}
+        for feature in self.handler.iter_features():
+            schema = feature.make_schema()
+            form = forms.Form(schema=schema, request=self.request)
+            for key, value in feature.get_defaults().items():
+                form.set_default(key, value)
+            feature_forms[feature.feature_key] = form
+
+        result = rendered_result = None
+        feature_type = 'new-report'
+        if self.request.method == 'POST':
+            if app_form.validate():
+
+                feature_type = self.request.POST['feature_type']
+                feature = self.handler.get_feature(feature_type)
+                if not feature:
+                    raise ValueError("Unknown feature type: {}".format(feature_type))
+
+                feature_form = feature_forms[feature.feature_key]
+                if feature_form.validate():
+                    context = dict(app_form.validated)
+                    context.update(feature_form.validated)
+                    result = self.handler.do_generate(feature, **context)
+                    rendered_result = self.render_result(result)
+
+        context = {
+            'index_title': "Generate Feature",
+            'handler': self.handler,
+            'app_form': app_form,
+            'feature_type': feature_type,
+            'feature_forms': feature_forms,
+            'result': result,
+            'rendered_result': rendered_result,
+        }
+
+        return context
+
+    def render_result(self, result):
+        return  markdown.markdown(result, extensions=['fenced_code',
+                                                      'codehilite'])
+
+    @classmethod
+    def defaults(cls, config):
+
+        # generate feature
+        config.add_tailbone_permission('common', 'common.generate_feature',
+                                       "Generate new feature source code")
+        config.add_route('generate_feature', '/generate-feature')
+        config.add_view(cls, route_name='generate_feature',
+                        permission='common.generate_feature',
+                        renderer='/generate_feature.mako')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    GenerateFeatureView = kwargs.get('GenerateFeatureView', base['GenerateFeatureView'])
+    GenerateFeatureView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/filemon.py b/tailbone/views/filemon.py
index 1d164c83..b0c81b45 100644
--- a/tailbone/views/filemon.py
+++ b/tailbone/views/filemon.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -63,5 +63,12 @@ class FilemonView(View):
         config.add_view(cls, attr='restart', route_name='filemon.restart', permission='filemon.restart')
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    FilemonView = kwargs.get('FilemonView', base['FilemonView'])
     FilemonView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py
index 66cd480c..34211c30 100644
--- a/tailbone/views/handheld.py
+++ b/tailbone/views/handheld.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -21,196 +21,17 @@
 #
 ################################################################################
 """
-Views for handheld batches
+(DEPRECATED) Views for handheld batches
 """
 
-from __future__ import unicode_literals, absolute_import
+import warnings
 
-import os
-
-from rattail.db import model
-from rattail.util import OrderedDict
-
-import colander
-from webhelpers2.html import tags
-
-from tailbone import forms
-from tailbone.db import Session
-from tailbone.views.batch import FileBatchMasterView
-
-
-ACTION_OPTIONS = OrderedDict([
-    ('make_label_batch', "Make a new Label Batch"),
-    ('make_inventory_batch', "Make a new Inventory Batch"),
-])
-
-
-class ExecutionOptions(colander.Schema):
-
-    action = colander.SchemaNode(
-        colander.String(),
-        validator=colander.OneOf(ACTION_OPTIONS),
-        widget=forms.widgets.PlainSelectWidget(values=ACTION_OPTIONS.items()))
-
-
-class HandheldBatchView(FileBatchMasterView):
-    """
-    Master view for handheld batches.
-    """
-    model_class = model.HandheldBatch
-    default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler'
-    model_title_plural = "Handheld Batches"
-    route_prefix = 'batch.handheld'
-    url_prefix = '/batch/handheld'
-    execution_options_schema = ExecutionOptions
-    editable = False
-
-    model_row_class = model.HandheldBatchRow
-    rows_creatable = False
-    rows_editable = True
-
-    grid_columns = [
-        'id',
-        'device_type',
-        'device_name',
-        'created',
-        'created_by',
-        'rowcount',
-        'status_code',
-        'executed',
-    ]
-
-    form_fields = [
-        'id',
-        'device_type',
-        'device_name',
-        'filename',
-        'created',
-        'created_by',
-        'rowcount',
-        'status_code',
-        'executed',
-        'executed_by',
-    ]
-
-    row_labels = {
-        'upc': "UPC",
-    }
-
-    row_grid_columns = [
-        'sequence',
-        'upc',
-        'brand_name',
-        'description',
-        'size',
-        'cases',
-        'units',
-        'status_code',
-    ]
-
-    row_form_fields = [
-        'sequence',
-        'upc',
-        'brand_name',
-        'description',
-        'size',
-        'status_code',
-        'cases',
-        'units',
-    ]
-
-    def configure_grid(self, g):
-        super(HandheldBatchView, self).configure_grid(g)
-        device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(),
-                                          key=lambda item: item[1]))
-        g.set_enum('device_type', device_types)
-
-    def grid_extra_class(self, batch, i):
-        if batch.status_code is not None and batch.status_code != batch.STATUS_OK:
-            return 'notice'
-
-    def configure_form(self, f):
-        super(HandheldBatchView, self).configure_form(f)
-        batch = f.model_instance
-
-        # device_type
-        device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(),
-                                          key=lambda item: item[1]))
-        f.set_enum('device_type', device_types)
-        f.widgets['device_type'].values.insert(0, ('', "(none)"))
-
-        if self.creating:
-            f.set_fields([
-                'filename',
-                'device_type',
-                'device_name',
-            ])
-
-        if self.viewing:
-            if batch.inventory_batch:
-                f.append('inventory_batch')
-                f.set_renderer('inventory_batch', self.render_inventory_batch)
-
-    def render_inventory_batch(self, handheld_batch, field):
-        batch = handheld_batch.inventory_batch
-        if not batch:
-            return ""
-        text = batch.id_str
-        url = self.request.route_url('batch.inventory.view', uuid=batch.uuid)
-        return tags.link_to(text, url)
-
-    def get_batch_kwargs(self, batch):
-        kwargs = super(HandheldBatchView, self).get_batch_kwargs(batch)
-        kwargs['device_type'] = batch.device_type
-        kwargs['device_name'] = batch.device_name
-        return kwargs
-
-    def configure_row_grid(self, g):
-        super(HandheldBatchView, self).configure_row_grid(g)
-        g.set_type('cases', 'quantity')
-        g.set_type('units', 'quantity')
-        g.set_label('brand_name', "Brand")
-
-    def row_grid_extra_class(self, row, i):
-        if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
-            return 'warning'
-
-    def configure_row_form(self, f):
-        super(HandheldBatchView, self).configure_row_form(f)
-
-        # readonly fields
-        f.set_readonly('upc')
-        f.set_readonly('brand_name')
-        f.set_readonly('description')
-        f.set_readonly('size')
-
-        # upc
-        f.set_renderer('upc', self.render_upc)
-
-    def render_upc(self, row, field):
-        upc = row.upc
-        if not upc:
-            return ""
-        text = upc.pretty()
-        if row.product_uuid:
-            url = self.request.route_url('products.view', uuid=row.product_uuid)
-            return tags.link_to(text, url)
-        return text
-
-    def get_execute_success_url(self, batch, result, **kwargs):
-        if kwargs['action'] == 'make_inventory_batch':
-            return self.request.route_url('batch.inventory.view', uuid=result.uuid)
-        elif kwargs['action'] == 'make_label_batch':
-            return self.request.route_url('labels.batch.view', uuid=result.uuid)
-        return super(HandheldBatchView, self).get_execute_success_url(batch)
-
-    def get_execute_results_success_url(self, result, **kwargs):
-        if result is True:
-            # no batches were actually executed
-            return self.get_index_url()
-        batch = result
-        return self.get_execute_success_url(batch, result, **kwargs)
+# nb. this is imported only for sake of legacy callers
+from tailbone.views.batch.handheld import HandheldBatchView
 
 
 def includeme(config):
-    HandheldBatchView.defaults(config)
+    warnings.warn("tailbone.views.handheld is a deprecated module; "
+                  "please use tailbone.views.batch.handheld instead",
+                  DeprecationWarning, stacklevel=2)
+    config.include('tailbone.views.batch.handheld')
diff --git a/tailbone/views/ifps.py b/tailbone/views/ifps.py
new file mode 100644
index 00000000..af626ef3
--- /dev/null
+++ b/tailbone/views/ifps.py
@@ -0,0 +1,101 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+IFPS Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from rattail.db import model
+
+from tailbone.views import MasterView
+
+
+class IFPS_PLUView(MasterView):
+    """
+    Master view for the Department class.
+    """
+    model_class = model.IFPS_PLU
+    route_prefix = 'ifps_plus'
+    url_prefix = '/ifps-plu-codes'
+    results_downloadable = True
+    has_versions = True
+
+    labels = {
+        'plu': "PLU",
+        'gpc': "GPC",
+        'aka': "AKA",
+        'measurements_north_america': "Measurements (North America)",
+        'measurements_rest_of_world': "Measurements (rest of world)",
+    }
+
+    grid_columns = [
+        'plu',
+        'category',
+        'commodity',
+        'variety',
+        'size',
+        'botanical_name',
+        'revision_date',
+    ]
+
+    def configure_grid(self, g):
+        super(IFPS_PLUView, self).configure_grid(g)
+
+        g.filters['plu'].default_active = True
+        g.filters['plu'].default_verb = 'equal'
+
+        g.filters['commodity'].default_active = True
+        g.filters['commodity'].default_verb = 'contains'
+
+        g.set_sort_defaults('plu')
+
+        # variety
+        # this is actually a TEXT field, so potentially large
+        g.set_renderer('variety', self.render_truncated_value)
+
+        g.set_link('plu')
+        g.set_link('commodity')
+        g.set_link('variety')
+
+    def configure_form(self, f):
+        super(IFPS_PLUView, self).configure_form(f)
+
+        if self.creating:
+            f.remove('revision_date',
+                     'date_added')
+        else:
+            f.set_readonly('revision_date')
+            f.set_readonly('date_added')
+
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    IFPS_PLUView = kwargs.get('IFPS_PLUView', base['IFPS_PLUView'])
+    IFPS_PLUView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py
new file mode 100644
index 00000000..48b32cc2
--- /dev/null
+++ b/tailbone/views/importing.py
@@ -0,0 +1,675 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+View for running arbitrary import/export jobs
+"""
+
+import getpass
+import json
+import logging
+import socket
+import subprocess
+import sys
+import time
+
+import sqlalchemy as sa
+
+from rattail.threads import Thread
+
+import colander
+import markdown
+from deform import widget as dfwidget
+from webhelpers2.html import HTML
+
+from tailbone.views import MasterView
+
+
+log = logging.getLogger(__name__)
+
+
+class ImportingView(MasterView):
+    """
+    View for running arbitrary import/export jobs
+    """
+    normalized_model_name = 'importhandler'
+    model_title = "Import / Export Handler"
+    model_key = 'key'
+    route_prefix = 'importing'
+    url_prefix = '/importing'
+    index_title = "Importing / Exporting"
+    creatable = False
+    editable = False
+    deletable = False
+    filterable = False
+    pageable = False
+
+    configurable = True
+    config_title = "Import / Export"
+
+    labels = {
+        'host_title': "Data Source",
+        'local_title': "Data Target",
+        'direction_display': "Direction",
+    }
+
+    grid_columns = [
+        'host_title',
+        'local_title',
+        'direction_display',
+        'handler_spec',
+    ]
+
+    form_fields = [
+        'key',
+        'local_key',
+        'host_key',
+        'handler_spec',
+        'host_title',
+        'local_title',
+        'direction_display',
+        'models',
+    ]
+
+    runjob_form_fields = [
+        'handler_spec',
+        'host_title',
+        'local_title',
+        'models',
+        'create',
+        'update',
+        'delete',
+        # 'runas',
+        'versioning',
+        'dry_run',
+        'warnings',
+    ]
+
+    def get_data(self, session=None):
+        app = self.get_rattail_app()
+        data = []
+
+        for handler in app.get_designated_import_handlers(
+                ignore_errors=True, sort=True):
+            data.append(self.normalize(handler))
+
+        return data
+
+    def normalize(self, handler, keep_handler=True):
+        data = {
+            'key': handler.get_key(),
+            'generic_title': handler.get_generic_title(),
+            'host_key': handler.host_key,
+            'host_title': handler.get_generic_host_title(),
+            'local_key': handler.local_key,
+            'local_title': handler.get_generic_local_title(),
+            'handler_spec': handler.get_spec(),
+            'direction': handler.direction,
+            'direction_display': handler.direction.capitalize(),
+            'safe_for_web_app': handler.safe_for_web_app,
+        }
+
+        if keep_handler:
+            data['_handler'] = handler
+
+        alternates = getattr(handler, 'alternate_handlers', None)
+        if alternates:
+            data['alternates'] = []
+            for alternate in alternates:
+                data['alternates'].append(self.normalize(
+                    alternate, keep_handler=keep_handler))
+
+        cmd = self.get_cmd_for_handler(handler, ignore_errors=True)
+        if cmd:
+            data['cmd'] = ' '.join(cmd)
+            data['command'] = cmd[0]
+            data['subcommand'] = cmd[1]
+
+        runas = self.get_runas_for_handler(handler)
+        if runas:
+            data['default_runas'] = runas
+
+        return data
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.set_link('host_title')
+        g.set_searchable('host_title')
+
+        g.set_link('local_title')
+        g.set_searchable('local_title')
+
+        g.set_searchable('handler_spec')
+
+    def get_instance(self):
+        """
+        Fetch the current model instance by inspecting the route kwargs and
+        doing a database lookup.  If the instance cannot be found, raises 404.
+        """
+        key = self.request.matchdict['key']
+        app = self.get_rattail_app()
+        handler = app.get_import_handler(key, ignore_errors=True)
+        if handler:
+            return self.normalize(handler)
+        raise self.notfound()
+
+    def get_instance_title(self, handler_info):
+        handler = handler_info['_handler']
+        return handler.get_generic_title()
+
+    def make_form_schema(self):
+        return ImportHandlerSchema()
+
+    def make_form_kwargs(self, **kwargs):
+        kwargs = super().make_form_kwargs(**kwargs)
+
+        # nb. this is set as sort of a hack, to prevent SA model
+        # inspection logic
+        kwargs['renderers'] = {}
+
+        return kwargs
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        f.set_renderer('models', self.render_models)
+
+    def render_models(self, handler, field):
+        handler = handler['_handler']
+        items = []
+        for key in handler.get_importer_keys():
+            items.append(HTML.tag('li', c=[key]))
+        return HTML.tag('ul', c=items)
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        handler_info = kwargs['instance']
+        kwargs['handler'] = handler_info['_handler']
+        return kwargs
+
+    def runjob(self):
+        """
+        View for running an import / export job
+        """
+        handler_info = self.get_instance()
+        handler = handler_info['_handler']
+        form = self.make_runjob_form(handler_info)
+
+        if self.request.method == 'POST':
+            if self.validate_form(form):
+
+                self.cache_runjob_form_values(handler, form)
+
+                try:
+                    return self.do_runjob(handler_info, form)
+                except Exception as error:
+                    self.request.session.flash(str(error), 'error')
+                    return self.redirect(self.request.current_route_url())
+
+        return self.render_to_response('runjob', {
+            'handler_info': handler_info,
+            'handler': handler,
+            'form': form,
+        })
+
+    def cache_runjob_form_values(self, handler, form):
+        handler_key = handler.get_key()
+
+        def make_key(field):
+            return 'rattail.importing.{}.{}'.format(handler_key, field)
+
+        for field in form.fields:
+            key = make_key(field)
+            self.request.session[key] = form.validated[field]
+
+    def read_cached_runjob_values(self, handler, form):
+        handler_key = handler.get_key()
+
+        def make_key(field):
+            return 'rattail.importing.{}.{}'.format(handler_key, field)
+
+        for field in form.fields:
+            key = make_key(field)
+            if key in self.request.session:
+                form.set_default(field, self.request.session[key])
+
+    def make_runjob_form(self, handler_info, **kwargs):
+        """
+        Creates a new form for the given model class/instance
+        """
+        handler = handler_info['_handler']
+        factory = self.get_form_factory()
+        fields = list(self.runjob_form_fields)
+        schema = RunJobSchema()
+
+        kwargs = self.make_runjob_form_kwargs(handler_info, **kwargs)
+        form = factory(fields, schema, **kwargs)
+        self.configure_runjob_form(handler, form)
+
+        self.read_cached_runjob_values(handler, form)
+
+        return form
+
+    def make_runjob_form_kwargs(self, handler_info, **kwargs):
+        route_prefix = self.get_route_prefix()
+        handler = handler_info['_handler']
+        defaults = {
+            'request': self.request,
+            'model_instance': handler,
+            'cancel_url': self.request.route_url('{}.view'.format(route_prefix),
+                                                 key=handler.get_key()),
+            # nb. these next 2 are set as sort of a hack, to prevent
+            # SA model inspection logic
+            'renderers': {},
+            'appstruct': handler_info,
+        }
+        defaults.update(kwargs)
+        return defaults
+
+    def configure_runjob_form(self, handler, f):
+        self.set_labels(f)
+
+        f.set_readonly('handler_spec')
+        f.set_renderer('handler_spec', lambda handler, field: handler.get_spec())
+
+        f.set_readonly('host_title')
+        f.set_readonly('local_title')
+
+        keys = handler.get_importer_keys()
+        f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys],
+                                                     multiple=True,
+                                                     size=len(keys)))
+
+        allow_create = True
+        allow_update = True
+        allow_delete = True
+        if len(keys) == 1:
+            importers = handler.get_importers().values()
+            importer = list(importers)[0]
+            allow_create = importer.allow_create
+            allow_update = importer.allow_update
+            allow_delete = importer.allow_delete
+
+        if allow_create:
+            f.set_default('create', True)
+        else:
+            f.remove('create')
+
+        if allow_update:
+            f.set_default('update', True)
+        else:
+            f.remove('update')
+
+        if allow_delete:
+            f.set_default('delete', False)
+        else:
+            f.remove('delete')
+
+        # f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '')
+
+        f.set_default('versioning', True)
+        f.set_helptext('versioning', "If set, version history will be updated as appropriate")
+
+        f.set_default('dry_run', False)
+        f.set_helptext('dry_run', "If set, data will not actually be written")
+
+        f.set_default('warnings', False)
+        f.set_helptext('warnings', "If set, will send an email if any diffs")
+
+    def do_runjob(self, handler_info, form):
+        handler = handler_info['_handler']
+        handler_key = handler.get_key()
+
+        if self.request.POST.get('runjob') == 'true':
+
+            # will invoke handler to run job..
+
+            # ..but only if it is safe to do so
+            if not handler.safe_for_web_app:
+                self.request.session.flash("Handler is not (yet) safe to run "
+                                           "with this tool", 'error')
+                return self.redirect(self.request.current_route_url())
+
+            # TODO: this socket progress business was lifted from
+            # tailbone.views.batch.core:BatchMasterView.handler_action
+            # should probably refactor to share somehow
+
+            # make progress object
+            key = 'rattail.importing.{}'.format(handler_key)
+            progress = self.make_progress(key)
+
+            # make socket for progress thread to listen to action thread
+            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            sock.bind(('127.0.0.1', 0))
+            sock.listen(1)
+            port = sock.getsockname()[1]
+
+            # launch thread to monitor progress
+            success_url = self.request.current_route_url()
+            thread = Thread(target=self.progress_thread, 
+                            args=(sock, success_url, progress))
+            thread.start()
+
+            true_cmd = self.make_runjob_cmd(handler, form, 'true', port=port)
+
+            # launch thread to invoke handler
+            thread = Thread(target=self.do_runjob_thread,
+                            args=(handler, true_cmd, port, progress))
+            thread.start()
+
+            return self.render_progress(progress, {
+                'can_cancel': False,
+                'cancel_url': self.request.current_route_url(),
+            })
+
+        else: # explain only
+            notes_cmd = self.make_runjob_cmd(handler, form, 'notes')
+            self.cache_runjob_notes(handler, notes_cmd)
+
+        return self.redirect(self.request.current_route_url())
+
+    def do_runjob_thread(self, handler, cmd, port, progress):
+
+        # invoke handler command via subprocess
+        try:
+            result = subprocess.run(cmd, check=True,
+                                    stdout=subprocess.PIPE,
+                                    stderr=subprocess.STDOUT)
+            output = result.stdout.decode('utf_8').strip()
+
+        except Exception as error:
+            log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True)
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                msg = """\
+{} failed!  Here is the command I tried to run:
+
+```
+{}
+```
+
+And here is the output:
+
+```
+{}
+```
+""".format(handler.direction.capitalize(),
+           ' '.join(cmd),
+           error.stdout.decode('utf_8').strip())
+                msg = markdown.markdown(msg, extensions=['fenced_code'])
+                msg = HTML.literal(msg)
+                msg = HTML.tag('div', class_='tailbone-markdown', c=[msg])
+                progress.session['error_msg'] = msg
+                progress.session.save()
+
+        else: # success
+
+            if progress:
+                progress.session.load()
+                msg = self.get_runjob_success_msg(handler, output)
+                progress.session['complete'] = True
+                progress.session['success_url'] = self.request.current_route_url()
+                progress.session['success_msg'] = msg
+                progress.session.save()
+
+            suffix = "\n\n.".encode('utf_8')
+            cxn = socket.create_connection(('127.0.0.1', port))
+            data = json.dumps({
+                'everything_complete': True,
+            })
+            data = data.encode('utf_8')
+            cxn.send(data)
+            cxn.send(suffix)
+            cxn.close()
+
+    def get_runjob_success_msg(self, handler, output):
+        notes = """\
+{} went okay, here is the output:
+
+```
+{}
+```
+""".format(handler.direction.capitalize(), output)
+
+        notes = markdown.markdown(notes, extensions=['fenced_code'])
+        notes = HTML.literal(notes)
+        return HTML.tag('div', class_='tailbone-markdown', c=[notes])
+
+    def get_cmd_for_handler(self, handler, ignore_errors=False):
+        return handler.get_cmd(ignore_errors=ignore_errors)
+
+    def get_runas_for_handler(self, handler):
+        handler_key = handler.get_key()
+        runas = self.rattail_config.get('rattail.importing',
+                                        '{}.runas'.format(handler_key))
+        if runas:
+            return runas
+        return self.rattail_config.get('rattail', 'runas.default')
+
+    def make_runjob_cmd(self, handler, form, typ, port=None):
+        command, subcommand = self.get_cmd_for_handler(handler)
+        runas = self.get_runas_for_handler(handler)
+        data = form.validated
+
+        if typ == 'true':
+            cmd = [
+                '{}/bin/{}'.format(sys.prefix, command),
+                '--config={}/app/quiet.conf'.format(sys.prefix),
+                '--progress',
+                '--progress-socket=127.0.0.1:{}'.format(port),
+            ]
+        else:
+            cmd = [
+                'sudo', '-u', getpass.getuser(),
+                'bin/{}'.format(command),
+                '-c', 'app/quiet.conf',
+                '-P',
+            ]
+
+        if runas:
+            if typ == 'true':
+                cmd.append('--runas={}'.format(runas))
+            else:
+                cmd.extend(['--runas', runas])
+
+        cmd.append(subcommand)
+
+        cmd.extend(data['models'])
+
+        if data['create']:
+            if typ == 'true':
+                cmd.append('--create')
+        else:
+            cmd.append('--no-create')
+
+        if data['update']:
+            if typ == 'true':
+                cmd.append('--update')
+        else:
+            cmd.append('--no-update')
+
+        if data['delete']:
+            cmd.append('--delete')
+        else:
+            if typ == 'true':
+                cmd.append('--no-delete')
+
+        if data['versioning']:
+            if typ == 'true':
+                cmd.append('--versioning')
+        else:
+            cmd.append('--no-versioning')
+
+        if data['dry_run']:
+            cmd.append('--dry-run')
+
+        if data['warnings']:
+            if typ == 'true':
+                cmd.append('--warnings')
+            else:
+                cmd.append('-W')
+
+        return cmd
+
+    def cache_runjob_notes(self, handler, notes_cmd):
+        notes = """\
+You can run this {direction} job manually via command line:
+
+```sh
+cd {prefix}
+{cmd}
+```
+""".format(direction=handler.direction,
+           prefix=sys.prefix,
+           cmd=' '.join(notes_cmd))
+
+        self.request.session['rattail.importing.runjob.notes'] = markdown.markdown(
+            notes, extensions=['fenced_code', 'codehilite'])
+
+    def configure_get_context(self):
+        app = self.get_rattail_app()
+        handlers_data = []
+
+        for handler in app.get_designated_import_handlers(
+                with_alternates=True,
+                ignore_errors=True, sort=True):
+
+            data = self.normalize(handler, keep_handler=False)
+
+            data['spec_options'] = [handler.get_spec()]
+            for alternate in handler.alternate_handlers:
+                data['spec_options'].append(alternate.get_spec())
+            data['spec_options'].sort()
+
+            handlers_data.append(data)
+
+        return {
+            'handlers_data': handlers_data,
+        }
+
+    def configure_gather_settings(self, data):
+        settings = []
+
+        for handler in json.loads(data['handlers']):
+            key = handler['key']
+
+            settings.extend([
+                {'name': 'rattail.importing.{}.handler'.format(key),
+                 'value': handler['handler_spec']},
+                {'name': 'rattail.importing.{}.cmd'.format(key),
+                 'value': '{} {}'.format(handler['command'],
+                                         handler['subcommand'])},
+                {'name': 'rattail.importing.{}.runas'.format(key),
+                 'value': handler['default_runas']},
+            ])
+
+        return settings
+
+    def configure_remove_settings(self):
+        app = self.get_rattail_app()
+        model = self.model
+        session = self.Session()
+
+        to_delete = session.query(model.Setting)\
+                           .filter(sa.or_(
+                               model.Setting.name.like('rattail.importing.%.handler'),
+                               model.Setting.name.like('rattail.importing.%.cmd'),
+                               model.Setting.name.like('rattail.importing.%.runas')))\
+                           .all()
+
+        for setting in to_delete:
+            app.delete_setting(session, setting)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._importing_defaults(config)
+
+    @classmethod
+    def _importing_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
+
+        # run job
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.runjob'.format(permission_prefix),
+                                       "Run an arbitrary Import / Export Job")
+        config.add_route('{}.runjob'.format(route_prefix),
+                         '{}/runjob'.format(instance_url_prefix))
+        config.add_view(cls, attr='runjob', 
+                        route_name='{}.runjob'.format(route_prefix),
+                        permission='{}.runjob'.format(permission_prefix))
+
+
+class ImportHandlerSchema(colander.MappingSchema):
+
+    host_key = colander.SchemaNode(colander.String())
+
+    local_key = colander.SchemaNode(colander.String())
+
+    host_title = colander.SchemaNode(colander.String())
+
+    local_title = colander.SchemaNode(colander.String())
+
+    handler_spec = colander.SchemaNode(colander.String())
+
+
+class RunJobSchema(colander.MappingSchema):
+
+    handler_spec = colander.SchemaNode(colander.String(),
+                                       missing=colander.null)
+    
+    host_title = colander.SchemaNode(colander.String(),
+                                       missing=colander.null)
+
+    local_title = colander.SchemaNode(colander.String(),
+                                       missing=colander.null)
+
+    models = colander.SchemaNode(colander.List())
+
+    create = colander.SchemaNode(colander.Bool())
+
+    update = colander.SchemaNode(colander.Bool())
+
+    delete = colander.SchemaNode(colander.Bool())
+
+    # runas = colander.SchemaNode(colander.String())
+
+    versioning = colander.SchemaNode(colander.Bool())
+
+    dry_run = colander.SchemaNode(colander.Bool())
+
+    warnings = colander.SchemaNode(colander.Bool())
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ImportingView = kwargs.get('ImportingView', base['ImportingView'])
+    ImportingView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py
index 7e4ed33f..4622fa9f 100644
--- a/tailbone/views/inventory.py
+++ b/tailbone/views/inventory.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -33,7 +33,7 @@ import colander
 from tailbone.views import MasterView
 
 
-class InventoryAdjustmentReasonsView(MasterView):
+class InventoryAdjustmentReasonView(MasterView):
     """
     Master view for inventory adjustment reasons.
     """
@@ -48,11 +48,11 @@ class InventoryAdjustmentReasonsView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(InventoryAdjustmentReasonsView, self).configure_grid(g)
+        super(InventoryAdjustmentReasonView, self).configure_grid(g)
         g.set_sort_defaults('code')
 
     def configure_form(self, f):
-        super(InventoryAdjustmentReasonsView, self).configure_form(f)
+        super(InventoryAdjustmentReasonView, self).configure_form(f)
 
         # code
         f.set_validator('code', self.unique_code)
@@ -66,6 +66,16 @@ class InventoryAdjustmentReasonsView(MasterView):
         if query.count():
             raise colander.Invalid(node, "Code must be unique")
 
+# TODO: deprecate / remove this
+InventoryAdjustmentReasonsView = InventoryAdjustmentReasonView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    InventoryAdjustmentReasonView = kwargs.get('InventoryAdjustmentReasonView', base['InventoryAdjustmentReasonView'])
+    InventoryAdjustmentReasonView.defaults(config)
+
 
 def includeme(config):
-    InventoryAdjustmentReasonsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py
index b4910466..e9d2971b 100644
--- a/tailbone/views/labels/batch.py
+++ b/tailbone/views/labels/batch.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,8 +26,6 @@
 Please use `tailbone.views.batch.labels` instead.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 
@@ -35,6 +33,6 @@ def includeme(config):
 
     warnings.warn("The `tailbone.views.labels.batch` module is deprecated, "
                   "please use `tailbone.views.batch.labels` instead.",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
 
     config.include('tailbone.views.batch.labels')
diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py
index 3fb5dd34..fa878448 100644
--- a/tailbone/views/labels/profiles.py
+++ b/tailbone/views/labels/profiles.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Label Profile Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 import colander
@@ -34,7 +32,7 @@ from tailbone import forms
 from tailbone.views import MasterView
 
 
-class ProfilesView(MasterView):
+class LabelProfileView(MasterView):
     """
     Master view for the LabelProfile model.
     """
@@ -62,25 +60,37 @@ class ProfilesView(MasterView):
         'sync_me',
     ]
 
+    def __init__(self, request):
+        super(LabelProfileView, self).__init__(request)
+        app = self.get_rattail_app()
+        self.label_handler = app.get_label_handler()
+
     def configure_grid(self, g):
-        super(ProfilesView, self).configure_grid(g)
+        super(LabelProfileView, self).configure_grid(g)
         g.set_sort_defaults('ordinal')
         g.set_type('visible', 'boolean')
         g.set_link('code')
         g.set_link('description')
 
     def configure_form(self, f):
-        super(ProfilesView, self).configure_form(f)
+        super(LabelProfileView, self).configure_form(f)
 
         # format
         f.set_type('format', 'codeblock')
 
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super(LabelProfileView, self).template_kwargs_view(**kwargs)
+
+        kwargs['label_handler'] = self.label_handler
+
+        return kwargs
+
     def after_create(self, profile):
         self.after_edit(profile)
 
     def after_edit(self, profile):
         if not profile.format:
-            formatter = profile.get_formatter(self.rattail_config)
+            formatter = self.label_handler.get_formatter(profile)
             if formatter:
                 try:
                     profile.format = formatter.default_format
@@ -88,18 +98,16 @@ class ProfilesView(MasterView):
                     pass
 
     def make_printer_settings_form(self, profile, printer):
-        use_buefy = self.get_use_buefy()
         schema = colander.Schema()
 
         for name, label in printer.required_settings.items():
             node = colander.SchemaNode(colander.String(),
                                        name=name,
                                        title=label,
-                                       default=profile.get_printer_setting(name))
+                                       default=self.label_handler.get_printer_setting(profile, name))
             schema.add(node)
 
         form = forms.Form(schema=schema, request=self.request,
-                          use_buefy=use_buefy,
                           model_instance=profile,
                           # TODO: ugh, this is necessary to avoid some logic
                           # which assumes a ColanderAlchemy schema i think?
@@ -122,17 +130,17 @@ class ProfilesView(MasterView):
         View for editing extended Printer Settings, for a given Label Profile.
         """
         profile = self.get_instance()
-        read_profile = self.redirect(self.get_action_url('view', profile))
+        redirect = self.redirect(self.get_action_url('view', profile))
 
-        printer = profile.get_printer(self.rattail_config)
+        printer = self.label_handler.get_printer(profile)
         if not printer:
             msg = "Label profile \"{}\" does not have a functional printer spec.".format(profile)
             self.request.session.flash(msg)
-            return read_profile
+            return redirect
         if not printer.required_settings:
             msg = "Printer class for label profile \"{}\" does not require any settings.".format(profile)
             self.request.session.flash(msg)
-            return read_profile
+            return redirect
 
         form = self.make_printer_settings_form(profile, printer)
 
@@ -140,8 +148,9 @@ class ProfilesView(MasterView):
         if self.request.method == 'POST':
             for setting in printer.required_settings:
                 if setting in self.request.POST:
-                    profile.save_printer_setting(setting, self.request.POST[setting])
-            return read_profile
+                    self.label_handler.save_printer_setting(
+                        profile, setting, self.request.POST[setting])
+            return redirect
 
         return self.render_to_response('printer', {
             'form': form,
@@ -167,6 +176,16 @@ class ProfilesView(MasterView):
         config.add_view(cls, attr='printer_settings', route_name='{}.printer_settings'.format(route_prefix),
                         permission='{}.edit'.format(permission_prefix))
 
+# TODO: deprecate / remove this
+ProfilesView = LabelProfileView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView'])
+    LabelProfileView.defaults(config)
+
 
 def includeme(config):
-    ProfilesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py
new file mode 100644
index 00000000..568183ad
--- /dev/null
+++ b/tailbone/views/luigi.py
@@ -0,0 +1,291 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Views for Luigi
+"""
+
+import json
+import logging
+import os
+import re
+import shlex
+
+import sqlalchemy as sa
+
+from rattail.util import simple_error
+
+from tailbone.views import MasterView
+
+
+log = logging.getLogger(__name__)
+
+
+class LuigiTaskView(MasterView):
+    """
+    Simple views for Luigi tasks.
+    """
+    normalized_model_name = 'luigitasks'
+    model_key = 'key'
+    model_title = "Luigi Task"
+    route_prefix = 'luigi'
+    url_prefix = '/luigi'
+
+    viewable = False
+    creatable = False
+    editable = False
+    deletable = False
+    configurable = True
+
+    def __init__(self, request, context=None):
+        super(LuigiTaskView, self).__init__(request, context=context)
+        app = self.get_rattail_app()
+
+        # nb. luigi may not be installed, which (for now) may prevent
+        # us from getting our handler; in which case warn user
+        try:
+            self.luigi_handler = app.get_luigi_handler()
+        except Exception as error:
+            self.luigi_handler = None
+            self.luigi_handler_error = error
+            log.warning("could not get luigi handler", exc_info=True)
+
+    def index(self):
+
+        if not self.luigi_handler:
+            self.request.session.flash("Could not create handler: {}".format(
+                simple_error(self.luigi_handler_error)), 'error')
+
+        luigi_url = self.rattail_config.get('rattail.luigi', 'url')
+        history_url = '{}/history'.format(luigi_url.rstrip('/')) if luigi_url else None
+        return self.render_to_response('index', {
+            'index_url': None,
+            'luigi_url': luigi_url,
+            'luigi_history_url': history_url,
+            'overnight_tasks': self.get_overnight_tasks(),
+            'backfill_tasks': self.get_backfill_tasks(),
+        })
+
+    def launch_overnight(self):
+        app = self.get_rattail_app()
+        data = self.request.json_body
+
+        key = data.get('key')
+        task = self.luigi_handler.get_overnight_task(key) if key else None
+        if not task:
+            return self.json_response({'error': "Task not found"})
+
+        try:
+            self.luigi_handler.launch_overnight_task(task, app.yesterday(),
+                                                     keep_config=False,
+                                                     email_if_empty=True,
+                                                     wait=False)
+        except Exception as error:
+            log.warning("failed to launch overnight task: %s", task,
+                        exc_info=True)
+            return self.json_response({'error': simple_error(error)})
+        return self.json_response({'ok': True})
+
+    def launch_backfill(self):
+        app = self.get_rattail_app()
+        data = self.request.json_body
+
+        key = data.get('key')
+        task = self.luigi_handler.get_backfill_task(key) if key else None
+        if not task:
+            return self.json_response({'error': "Task not found"})
+
+        start_date = app.parse_date(data['start_date'])
+        end_date = app.parse_date(data['end_date'])
+        try:
+            self.luigi_handler.launch_backfill_task(task, start_date, end_date,
+                                                    keep_config=False,
+                                                    email_if_empty=True,
+                                                    wait=False)
+        except Exception as error:
+            log.warning("failed to launch backfill task: %s", task,
+                        exc_info=True)
+            return self.json_response({'error': simple_error(error)})
+        return self.json_response({'ok': True})
+
+    def restart_scheduler(self):
+        try:
+            self.luigi_handler.restart_supervisor_process()
+            self.request.session.flash("Luigi scheduler has been restarted.")
+
+        except Exception as error:
+            log.warning("restart failed", exc_info=True)
+            self.request.session.flash(simple_error(error), 'error')
+
+        return self.redirect(self.request.get_referrer(
+            default=self.get_index_url()))
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # luigi proper
+            {'section': 'rattail.luigi',
+             'option': 'url'},
+            {'section': 'rattail.luigi',
+             'option': 'scheduler.supervisor_process_name'},
+            {'section': 'rattail.luigi',
+             'option': 'scheduler.restart_command'},
+
+        ]
+
+    def configure_get_context(self, **kwargs):
+        context = super(LuigiTaskView, self).configure_get_context(**kwargs)
+        context['overnight_tasks'] = self.get_overnight_tasks()
+        context['backfill_tasks'] = self.get_backfill_tasks()
+        return context
+
+    def get_overnight_tasks(self):
+        if self.luigi_handler:
+            tasks = self.luigi_handler.get_all_overnight_tasks()
+        else:
+            tasks = []
+        for task in tasks:
+            if task['last_date']:
+                task['last_date'] = str(task['last_date'])
+        return tasks
+
+    def get_backfill_tasks(self):
+        if self.luigi_handler:
+            tasks = self.luigi_handler.get_all_backfill_tasks()
+        else:
+            tasks = []
+        for task in tasks:
+            if task['last_date']:
+                task['last_date'] = str(task['last_date'])
+            if task['target_date']:
+                task['target_date'] = str(task['target_date'])
+        return tasks
+
+    def configure_gather_settings(self, data):
+        settings = super(LuigiTaskView, self).configure_gather_settings(data)
+        app = self.get_rattail_app()
+
+        # overnight tasks
+        keys = []
+        for task in json.loads(data['overnight_tasks']):
+            key = task['key']
+            keys.append(key)
+            settings.extend([
+                {'name': 'rattail.luigi.overnight.task.{}.description'.format(key),
+                 'value': task['description']},
+                {'name': 'rattail.luigi.overnight.task.{}.module'.format(key),
+                 'value': task['module']},
+                {'name': 'rattail.luigi.overnight.task.{}.class_name'.format(key),
+                 'value': task['class_name']},
+                {'name': 'rattail.luigi.overnight.task.{}.script'.format(key),
+                 'value': task['script']},
+                {'name': 'rattail.luigi.overnight.task.{}.notes'.format(key),
+                 'value': task['notes']},
+            ])
+        if keys:
+            settings.append({'name': 'rattail.luigi.overnight.tasks',
+                             'value': ', '.join(keys)})
+
+        # backfill tasks
+        keys = []
+        for task in json.loads(data['backfill_tasks']):
+            key = task['key']
+            keys.append(key)
+            settings.extend([
+                {'name': 'rattail.luigi.backfill.task.{}.description'.format(key),
+                 'value': task['description']},
+                {'name': 'rattail.luigi.backfill.task.{}.script'.format(key),
+                 'value': task['script']},
+                {'name': 'rattail.luigi.backfill.task.{}.forward'.format(key),
+                 'value': 'true' if task['forward'] else 'false'},
+                {'name': 'rattail.luigi.backfill.task.{}.notes'.format(key),
+                 'value': task['notes']},
+                {'name': 'rattail.luigi.backfill.task.{}.target_date'.format(key),
+                 'value': str(task['target_date'])},
+            ])
+        if keys:
+            settings.append({'name': 'rattail.luigi.backfill.tasks',
+                             'value': ', '.join(keys)})
+
+        return settings
+
+    def configure_remove_settings(self):
+        super(LuigiTaskView, self).configure_remove_settings()
+
+        self.luigi_handler.purge_overnight_settings(self.Session())
+        self.luigi_handler.purge_backfill_settings(self.Session())
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._luigi_defaults(config)
+
+    @classmethod
+    def _luigi_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        url_prefix = cls.get_url_prefix()
+        model_title_plural = cls.get_model_title_plural()
+
+        # launch overnight
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.launch_overnight'.format(permission_prefix),
+                                       label="Launch any Overnight Task")
+        config.add_route('{}.launch_overnight'.format(route_prefix),
+                         '{}/launch-overnight'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='launch_overnight',
+                        route_name='{}.launch_overnight'.format(route_prefix),
+                        permission='{}.launch_overnight'.format(permission_prefix))
+
+        # launch backfill
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.launch_backfill'.format(permission_prefix),
+                                       label="Launch any Backfill Task")
+        config.add_route('{}.launch_backfill'.format(route_prefix),
+                         '{}/launch-backfill'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='launch_backfill',
+                        route_name='{}.launch_backfill'.format(route_prefix),
+                        permission='{}.launch_backfill'.format(permission_prefix))
+
+        # restart luigid scheduler
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.restart_scheduler'.format(permission_prefix),
+                                       label="Restart the Luigi Scheduler daemon")
+        config.add_route('{}.restart_scheduler'.format(route_prefix),
+                         '{}/restart-scheduler'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='restart_scheduler',
+                        route_name='{}.restart_scheduler'.format(route_prefix),
+                        permission='{}.restart_scheduler'.format(permission_prefix))
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    LuigiTaskView = kwargs.get('LuigiTaskView', base['LuigiTaskView'])
+    LuigiTaskView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 4210f62e..21a5e58f 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,46 +24,50 @@
 Model Master View
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import io
 import os
+import csv
 import datetime
-import tempfile
+import getpass
+import shutil
 import logging
+from collections import OrderedDict
 
-import six
+import json
 import sqlalchemy as sa
 from sqlalchemy import orm
-
 import sqlalchemy_continuum as continuum
 from sqlalchemy_utils.functions import get_primary_keys, get_columns
 
-
-from rattail.db import model, Session as RattailSession
+from wuttjamaican.util import get_class_hierarchy
 from rattail.db.continuum import model_transaction_query
-from rattail.util import prettify, OrderedDict, simple_error
-from rattail.time import localtime
+from rattail.util import simple_error
 from rattail.threads import Thread
 from rattail.csvutil import UnicodeDictWriter
-from rattail.files import temp_path
 from rattail.excel import ExcelWriter
 from rattail.gpc import GPC
 
 import colander
 import deform
+from deform import widget as dfwidget
 from pyramid import httpexceptions
 from pyramid.renderers import get_renderer, render_to_response, render
-from pyramid.response import FileResponse
 from webhelpers2.html import HTML, tags
+from webob.compat import cgi_FieldStorage
 
 from tailbone import forms, grids, diffs
 from tailbone.views import View
+from tailbone.db import Session
 from tailbone.config import global_help_url
 
 
 log = logging.getLogger(__name__)
 
 
+class EverythingComplete(Exception):
+    pass
+
+
 class MasterView(View):
     """
     Base "master" view class.  All model master views should derive from this.
@@ -72,32 +76,49 @@ class MasterView(View):
     pageable = True
     checkboxes = False
 
+    # set to True to allow user to click "anywhere" in a row in order
+    # to toggle its checkbox
+    clicking_row_checks_box = False
+
     # set to True in order to encode search values as utf-8
     use_byte_string_filters = False
 
+    # set to True if all timestamps are "local" instead of UTC
+    has_local_times = False
+
     listable = True
     sortable = True
+    results_downloadable = False
     results_downloadable_csv = False
     results_downloadable_xlsx = False
+    results_rows_downloadable = False
     creatable = True
     show_create_link = True
     viewable = True
     editable = True
     deletable = True
+    delete_requires_progress = False
     delete_confirm = 'full'
     bulk_deletable = False
     set_deletable = False
+    supports_autocomplete = False
     supports_set_enabled_toggle = False
+    supports_grid_totals = False
     populatable = False
     mergeable = False
+    merge_handler = None
     downloadable = False
     cloneable = False
     touchable = False
     executable = False
     execute_progress_template = None
     execute_progress_initial_msg = None
+    execute_can_cancel = True
     supports_prev_next = False
     supports_import_batch_from_file = False
+    has_input_file_templates = False
+    has_output_file_templates = False
+    configurable = False
 
     # set to True to add "View *global* Objects" permission, and
     # expose / leverage the ``local_only`` object flag
@@ -109,14 +130,6 @@ class MasterView(View):
     # set to True to declare model as "contact"
     is_contact = False
 
-    supports_mobile = False
-    mobile_creatable = False
-    mobile_editable = False
-    mobile_pageable = True
-    mobile_filterable = False
-    mobile_executable = False
-
-    mobile = False
     listing = False
     creating = False
     creates_multiple = False
@@ -125,6 +138,7 @@ class MasterView(View):
     deleting = False
     executing = False
     cloning = False
+    configuring = False
     has_pk_fields = False
     has_image = False
     has_thumbnail = False
@@ -143,36 +157,37 @@ class MasterView(View):
     use_index_links = False
 
     has_versions = False
+    default_help_url = None
     help_url = None
 
     labels = {'uuid': "UUID"}
 
+    customer_key_fields = {}
+    member_key_fields = {}
+    product_key_fields = {}
+
     # ROW-RELATED ATTRS FOLLOW:
 
     has_rows = False
     model_row_class = None
+    rows_title = None
     rows_pageable = True
     rows_sortable = True
     rows_filterable = True
     rows_viewable = True
     rows_creatable = False
     rows_editable = False
+    rows_editable_but_not_directly = False
     rows_deletable = False
     rows_deletable_speedbump = True
     rows_bulk_deletable = False
-    rows_default_pagesize = 20
+    rows_default_pagesize = None
     rows_downloadable_csv = False
     rows_downloadable_xlsx = False
 
-    mobile_rows_creatable = False
-    mobile_rows_creatable_via_browse = False
-    mobile_rows_quickable = False
-    mobile_rows_filterable = False
-    mobile_rows_viewable = False
-    mobile_rows_editable = False
-    mobile_rows_deletable = False
-
-    row_labels = {}
+    row_labels = {
+        'upc': "UPC",
+    }
 
     @property
     def Session(self):
@@ -190,6 +205,23 @@ class MasterView(View):
         from tailbone.db import Session
         return Session
 
+    def make_isolated_session(self):
+        """
+        This method should return a newly-created SQLAlchemy Session instance.
+        The use case here is primarily for secondary threads, which may be
+        employed for long-running processes such as executing a batch.  The
+        session returned should *not* have any web hooks to auto-commit with
+        the request/response cycle etc.  It should just be a plain old session,
+        "isolated" from the rest of the web app in a sense.
+
+        So whereas ``self.Session`` by default will return a reference to
+        ``tailbone.db.Session``, which is a "scoped" session wrapper specific
+        to the current thread (one per request), this method should instead
+        return e.g. a new independent ``rattail.db.Session`` instance.
+        """
+        app = self.get_rattail_app()
+        return app.make_session()
+
     @classmethod
     def get_grid_factory(cls):
         """
@@ -198,6 +230,12 @@ class MasterView(View):
         """
         return getattr(cls, 'grid_factory', grids.Grid)
 
+    @classmethod
+    def get_rows_title(cls):
+        # nb. we do not provide a default value for this, since it
+        # will not always make sense to show a row title
+        return cls.rows_title
+
     @classmethod
     def get_row_grid_factory(cls):
         """
@@ -214,27 +252,9 @@ class MasterView(View):
         """
         return getattr(cls, 'version_grid_factory', grids.Grid)
 
-    @classmethod
-    def get_mobile_grid_factory(cls):
-        """
-        Must return a callable to be used when creating new mobile grid
-        instances.  Instead of overriding this, you can set
-        :attr:`mobile_grid_factory`.  Default factory is :class:`MobileGrid`.
-        """
-        return getattr(cls, 'mobile_grid_factory', grids.MobileGrid)
-
-    @classmethod
-    def get_mobile_row_grid_factory(cls):
-        """
-        Must return a callable to be used when creating new mobile row grid
-        instances.  Instead of overriding this, you can set
-        :attr:`mobile_row_grid_factory`.  Default factory is :class:`MobileGrid`.
-        """
-        return getattr(cls, 'mobile_row_grid_factory', grids.MobileGrid)
-
     def set_labels(self, obj):
         labels = self.collect_labels()
-        for key, label in six.iteritems(labels):
+        for key, label in labels.items():
             obj.set_label(key, label)
 
     def collect_labels(self):
@@ -242,6 +262,8 @@ class MasterView(View):
         Collect all labels defined within the master class hierarchy.
         """
         labels = {}
+        for supp in self.iter_view_supplements():
+            labels.update(supp.labels)
         hierarchy = self.get_class_hierarchy()
         for cls in hierarchy:
             if hasattr(cls, 'labels'):
@@ -249,21 +271,11 @@ class MasterView(View):
         return labels
 
     def get_class_hierarchy(self):
-        hierarchy = []
-
-        def traverse(cls):
-            if cls is not object:
-                hierarchy.append(cls)
-                for parent in cls.__bases__:
-                    traverse(parent)
-
-        traverse(self.__class__)
-        hierarchy.reverse()
-        return hierarchy
+        return get_class_hierarchy(self.__class__)
 
     def set_row_labels(self, obj):
         labels = self.collect_row_labels()
-        for key, label in six.iteritems(labels):
+        for key, label in labels.items():
             obj.set_label(key, label)
 
     def collect_row_labels(self):
@@ -287,6 +299,18 @@ class MasterView(View):
         return self.request.has_perm('{}.{}'.format(
             self.get_permission_prefix(), name))
 
+    def has_any_perm(self, *names):
+        for name in names:
+            if self.has_perm(name):
+                return True
+        return False
+
+    @classmethod
+    def get_config_url(cls):
+        if hasattr(cls, 'config_url'):
+            return cls.config_url
+        return '{}/configure'.format(cls.get_url_prefix())
+
     ##############################
     # Available Views
     ##############################
@@ -299,36 +323,67 @@ class MasterView(View):
         string, then the view will return the rendered grid only.  Otherwise
         returns the full page.
         """
+        # nb. normally this "save defaults" flag is checked within make_grid()
+        # but it returns JSON data so we can't just do a redirect when there
+        # is no user; must return JSON error message instead
+        if (self.request.GET.get('save-current-filters-as-defaults') == 'true'
+            and not self.request.user):
+            return self.json_response({'error': "User is not currently logged in"})
+
         self.listing = True
         grid = self.make_grid()
-        use_buefy = self.get_use_buefy()
 
         # If user just refreshed the page with a reset instruction, issue a
         # redirect in order to clear out the query string.
-        if self.request.GET.get('reset-to-default-filters') == 'true':
-            return self.redirect(self.request.current_route_url(_query=None))
+        if self.request.GET.get('reset-view'):
+            kw = {'_query': None}
+            hash_ = self.request.GET.get('hash')
+            if hash_:
+                kw['_anchor'] = hash_
+            return self.redirect(self.request.current_route_url(**kw))
 
         # Stash some grid stats, for possible use when generating URLs.
-        if grid.pageable and hasattr(grid, 'pager'):
+        if grid.paginated and hasattr(grid, 'pager'):
             self.first_visible_grid_index = grid.pager.first_item
 
-        # return grid only, if partial page was requested
-        if self.request.params.get('partial'):
-            if use_buefy:
-                # render grid data only, as JSON
-                return render_to_response('json', grid.get_buefy_data(),
-                                          request=self.request)
-            else: # just do traditional thing, render grid HTML
-                self.request.response.content_type = str('text/html')
-                self.request.response.text = grid.render_grid()
-                return self.request.response
+        # return grid data only, if partial page was requested
+        if self.request.GET.get('partial'):
+            context = grid.get_table_data()
+            return self.json_response(context)
 
         context = {
+            'index_url': None, # nb. avoid title link since this *is* the index
             'grid': grid,
         }
+
+        if self.results_downloadable and self.has_perm('download_results'):
+            route_prefix = self.get_route_prefix()
+            context['download_results_path'] = self.request.session.pop(
+                '{}.results.generated'.format(route_prefix), None)
+            available = self.download_results_fields_available()
+            context['download_results_fields_available'] = available
+            context['download_results_fields_default'] = self.download_results_fields_default(available)
+
+        if self.has_rows and self.results_rows_downloadable and self.has_perm('download_results_rows'):
+            route_prefix = self.get_route_prefix()
+            context['download_results_rows_path'] = self.request.session.pop(
+                '{}.results_rows.generated'.format(route_prefix), None)
+            available = self.download_results_fields_available()
+            context['download_results_rows_fields_available'] = available
+            context['download_results_rows_fields_default'] = self.download_results_rows_fields_default(available)
+
+        self.before_render_index()
         return self.render_to_response('index', context)
 
-    def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
+    def before_render_index(self):
+        """
+        Perform any needed logic just prior to rendering the index
+        response.  Note that this logic is invoked only when rendering
+        the main index page, but *not* invoked when refreshing partial
+        grid contents etc.
+        """
+
+    def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs):
         """
         Creates a new grid instance
         """
@@ -337,13 +392,12 @@ class MasterView(View):
         if key is None:
             key = self.get_grid_key()
         if data is None:
-            data = self.get_data(session=kwargs.get('session'))
+            data = self.get_data(session=session)
         if columns is None:
             columns = self.get_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_grid(grid)
         grid.load_settings()
         return grid
@@ -356,9 +410,9 @@ class MasterView(View):
         """
         if session is None:
             session = self.Session()
-        kwargs.setdefault('pageable', False)
+        kwargs.setdefault('paginated', False)
         grid = self.make_grid(session=session, **kwargs)
-        return grid.make_visible_data()
+        return grid.get_visible_data()
 
     def get_grid_columns(self):
         """
@@ -373,7 +427,7 @@ class MasterView(View):
         Return a dictionary of kwargs to be passed to the factory when creating
         new grid instances.
         """
-        checkboxes = self.checkboxes
+        checkboxes = kwargs.get('checkboxes', self.checkboxes)
         if not checkboxes and self.mergeable and self.has_perm('merge'):
             checkboxes = True
         if not checkboxes and self.supports_set_enabled_toggle and self.has_perm('enable_disable_set'):
@@ -389,19 +443,54 @@ class MasterView(View):
             'filterable': self.filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.sortable,
-            'pageable': self.pageable,
+            'sort_multiple': not self.request.use_oruga,
+            'paginated': self.pageable,
             'extra_row_class': self.grid_extra_class,
             'url': lambda obj: self.get_action_url('view', obj),
             'checkboxes': checkboxes,
             'checked': self.checked,
+            'checkable': self.checkbox,
+            'clicking_row_checks_box': self.clicking_row_checks_box,
+            'assume_local_times': self.has_local_times,
+            'row_uuid_getter': self.get_uuid_for_grid_row,
         }
-        if 'main_actions' not in kwargs and 'more_actions' not in kwargs:
-            main, more = self.get_grid_actions()
-            defaults['main_actions'] = main
-            defaults['more_actions'] = more
+
+        if self.sortable or self.pageable or self.filterable:
+            defaults['expose_direct_link'] = True
+
+        if 'actions' not in kwargs:
+
+            if 'main_actions' in kwargs:
+                warnings.warn("main_actions param is deprecated for make_grid_kwargs(); "
+                              "please use actions param instead",
+                              DeprecationWarning, stacklevel=2)
+                main = kwargs.pop('main_actions')
+            else:
+                main = self.get_main_actions()
+
+            if 'more_actions' in kwargs:
+                warnings.warn("more_actions param is deprecated for make_grid_kwargs(); "
+                              "please use actions param instead",
+                              DeprecationWarning, stacklevel=2)
+                more = kwargs.pop('more_actions')
+            else:
+                more = self.get_more_actions()
+
+            defaults['actions'] = main + more
+
         defaults.update(kwargs)
         return defaults
 
+    def get_uuid_for_grid_row(self, obj):
+        """
+        If possible, this should return a "UUID" value to uniquely
+        identify the given object.  Default of course is to use the
+        actual ``uuid`` attribute of the object, if present.  This
+        value is needed by grids when checkboxes are used.
+        """
+        if hasattr(obj, 'uuid'):
+            return obj.uuid
+
     def configure_grid(self, grid):
         """
         Perform "final" configuration for the main data grid.
@@ -411,18 +500,22 @@ class MasterView(View):
         # hide "local only" grid filter, unless global access allowed
         if self.secure_global_objects:
             if not self.has_perm('view_global'):
-                grid.hide_column('local_only')
+                grid.remove('local_only')
                 grid.remove_filter('local_only')
 
+        self.configure_column_customer_key(grid)
+        self.configure_column_member_key(grid)
+        self.configure_column_product_key(grid)
+
+        for supp in self.iter_view_supplements():
+            supp.configure_grid(grid)
+
     def grid_extra_class(self, obj, i):
         """
         Returns string of extra class(es) for the table row corresponding to
         the given object, or ``None``.
         """
 
-    def quickie(self):
-        raise NotImplementedError
-
     def get_quickie_url(self):
         route_prefix = self.get_route_prefix()
         return self.request.route_url('{}.quickie'.format(route_prefix))
@@ -434,7 +527,32 @@ class MasterView(View):
     def get_quickie_placeholder(self):
         pass
 
-    def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
+    def quickie(self):
+        """
+        Quickie search - tries to do a simple lookup based on a key
+        value.  If a record is found, user is redirected to its view.
+        """
+        entry = self.request.params.get('entry', '').strip()
+        if not entry:
+            self.request.session.flash("No search criteria specified", 'error')
+            return self.redirect(self.request.get_referrer())
+
+        obj = self.do_quickie_lookup(entry)
+        if not obj:
+            model_title = self.get_model_title()
+            self.request.session.flash(f"{model_title} not found: {entry}", 'error')
+            return self.redirect(self.request.get_referrer())
+
+        return self.redirect(self.get_quickie_result_url(obj))
+
+    def do_quickie_lookup(self, entry):
+        pass
+
+    def get_quickie_result_url(self, obj):
+        return self.get_action_url('view', obj)
+
+    def make_row_grid(self, factory=None, key=None, data=None, columns=None,
+                      session=None, **kwargs):
         """
         Make and return a new (configured) rows grid instance.
         """
@@ -451,9 +569,8 @@ class MasterView(View):
         if columns is None:
             columns = self.get_row_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_row_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_row_grid(grid)
         grid.load_settings()
         return grid
@@ -472,41 +589,47 @@ class MasterView(View):
             'filterable': self.rows_filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.rows_sortable,
-            'pageable': self.rows_pageable,
-            'default_pagesize': self.rows_default_pagesize,
+            'sort_multiple': not self.request.use_oruga,
+            'paginated': self.rows_pageable,
             'extra_row_class': self.row_grid_extra_class,
             'url': lambda obj: self.get_row_action_url('view', obj),
         }
 
-        if self.has_rows and 'main_actions' not in defaults:
+        if self.rows_default_pagesize:
+            defaults['pagesize'] = self.rows_default_pagesize
+
+        if self.has_rows and 'actions' not in defaults:
             actions = []
-            use_buefy = self.get_use_buefy()
 
             # view action
             if self.rows_viewable:
-                view = lambda r, i: self.get_row_action_url('view', r)
-                icon = 'eye' if use_buefy else 'zoomin'
-                actions.append(self.make_action('view', icon=icon, url=view))
+                actions.append(self.make_action('view', icon='eye',
+                                                url=self.row_view_action_url))
 
             # edit action
             if self.rows_editable and self.has_perm('edit_row'):
-                icon = 'edit' if use_buefy else 'pencil'
-                actions.append(self.make_action('edit', icon=icon, url=self.row_edit_action_url))
+                actions.append(self.make_action('edit', icon='edit',
+                                                url=self.row_edit_action_url))
 
             # delete action
             if self.rows_deletable and self.has_perm('delete_row'):
-                actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url))
+                actions.append(self.make_action('delete', icon='trash',
+                                                url=self.row_delete_action_url,
+                                                link_class='has-text-danger'))
                 defaults['delete_speedbump'] = self.rows_deletable_speedbump
 
-            defaults['main_actions'] = actions
+            defaults['actions'] = actions
 
         defaults.update(kwargs)
         return defaults
 
     def configure_row_grid(self, grid):
-        # super(MasterView, self).configure_row_grid(grid)
         self.set_row_labels(grid)
 
+        self.configure_column_customer_key(grid)
+        self.configure_column_member_key(grid)
+        self.configure_column_product_key(grid)
+
     def row_grid_extra_class(self, obj, i):
         """
         Returns string of extra class(es) for the table row corresponding to
@@ -530,9 +653,8 @@ class MasterView(View):
         if columns is None:
             columns = self.get_version_grid_columns()
 
-        kwargs.setdefault('request', self.request)
         kwargs = self.make_version_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
+        grid = factory(self.request, key=key, data=data, columns=columns, **kwargs)
         self.configure_version_grid(grid)
         grid.load_settings()
         return grid
@@ -553,19 +675,18 @@ class MasterView(View):
         Return a dictionary of kwargs to be passed to the factory when
         constructing a new version grid.
         """
-        use_buefy = self.get_use_buefy()
         instance = kwargs.get('instance') or self.get_instance()
         route = '{}.version'.format(self.get_route_prefix())
         defaults = {
             'model_class': continuum.transaction_class(self.get_model_class()),
             'width': 'full',
-            'pageable': True,
+            'paginated': True,
             'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id),
         }
-        if 'main_actions' not in kwargs:
+        if 'actions' not in kwargs:
             url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
-            defaults['main_actions'] = [
-                self.make_action('view', icon='eye' if use_buefy else 'zoomin', url=url),
+            defaults['actions'] = [
+                self.make_action('view', icon='eye', url=url),
             ]
         defaults.update(kwargs)
         return defaults
@@ -584,170 +705,13 @@ class MasterView(View):
     def render_version_comment(self, transaction, column):
         return transaction.meta.get('comment', "")
 
-    def mobile_index(self):
-        """
-        Mobile "home" page for the data model
-        """
-        self.mobile = True
-        self.listing = True
-        grid = self.make_mobile_grid()
-        return self.render_to_response('index', {'grid': grid}, mobile=True)
-
-    @classmethod
-    def get_mobile_grid_key(cls):
-        """
-        Must return a unique "config key" for the mobile grid, for sort/filter
-        purposes etc.  (It need only be unique among *mobile* grids.)  Instead
-        of overriding this, you can set :attr:`mobile_grid_key`.  Default is
-        the value returned by :meth:`get_route_prefix()`.
-        """
-        if hasattr(cls, 'mobile_grid_key'):
-            return cls.mobile_grid_key
-        return 'mobile.{}'.format(cls.get_route_prefix())
-
-    def make_mobile_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
-        """
-        Creates a new mobile grid instance
-        """
-        if factory is None:
-            factory = self.get_mobile_grid_factory()
-        if key is None:
-            key = self.get_mobile_grid_key()
-        if data is None:
-            data = self.get_mobile_data(session=kwargs.get('session'))
-        if columns is None:
-            columns = self.get_mobile_grid_columns()
-
-        kwargs.setdefault('request', self.request)
-        kwargs.setdefault('mobile', True)
-        kwargs = self.make_mobile_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
-        self.configure_mobile_grid(grid)
-        grid.load_settings()
-        return grid
-
-    def get_mobile_grid_columns(self):
-        if hasattr(self, 'mobile_grid_columns'):
-            return self.mobile_grid_columns
-        # TODO
-        return ['listitem']
-
-    def get_mobile_data(self, session=None):
-        """
-        Must return the "raw" / full data set for the mobile grid.  This data
-        should *not* yet be sorted or filtered in any way; that happens later.
-        Default is the value returned by :meth:`get_data()`, in which case all
-        records visible in the traditional view, are visible in mobile too.
-        """
-        return self.get_data(session=session)
-
-    def make_mobile_grid_kwargs(self, **kwargs):
-        """
-        Must return a dictionary of kwargs to be passed to the factory when
-        creating new mobile grid instances.
-        """
-        defaults = {
-            'model_class': getattr(self, 'model_class', None),
-            'pageable': self.mobile_pageable,
-            'sortable': False,
-            'filterable': self.mobile_filterable,
-            'renderers': self.make_mobile_grid_renderers(),
-            'url': lambda obj: self.get_action_url('view', obj, mobile=True),
-        }
-        # TODO: this seems wrong..
-        if self.mobile_filterable:
-            defaults['filters'] = self.make_mobile_filters()
-        defaults.update(kwargs)
-        return defaults
-
-    def make_mobile_grid_renderers(self):
-        return {
-            'listitem': self.render_mobile_listitem,
-        }
-
-    def render_mobile_listitem(self, obj, i):
-        return obj
-
-    def configure_mobile_grid(self, grid):
-        pass
-
-    def make_mobile_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
-        """
-        Make a new (configured) rows grid instance for mobile.
-        """
-        instance = kwargs.pop('instance', self.get_instance())
-
-        if factory is None:
-            factory = self.get_mobile_row_grid_factory()
-        if key is None:
-            key = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()])
-        if data is None:
-            data = self.get_mobile_row_data(instance)
-        if columns is None:
-            columns = self.get_mobile_row_grid_columns()
-
-        kwargs.setdefault('request', self.request)
-        kwargs.setdefault('mobile', True)
-        kwargs = self.make_mobile_row_grid_kwargs(**kwargs)
-        grid = factory(key, data, columns, **kwargs)
-        self.configure_mobile_row_grid(grid)
-        grid.load_settings()
-        return grid
-
-    def get_mobile_row_grid_columns(self):
-        if hasattr(self, 'mobile_row_grid_columns'):
-            return self.mobile_row_grid_columns
-        # TODO
-        return ['listitem']
-
-    def make_mobile_row_grid_kwargs(self, **kwargs):
-        """
-        Must return a dictionary of kwargs to be passed to the factory when
-        creating new mobile *row* grid instances.
-        """
-        defaults = {
-            'model_class': self.model_row_class,
-            # TODO
-            'pageable': self.pageable,
-            'sortable': False,
-            'filterable': self.mobile_rows_filterable,
-            'renderers': self.make_mobile_row_grid_renderers(),
-            'url': lambda obj: self.get_row_action_url('view', obj, mobile=True),
-        }
-        # TODO: this seems wrong..
-        if self.mobile_rows_filterable:
-            defaults['filters'] = self.make_mobile_row_filters()
-        defaults.update(kwargs)
-        return defaults
-
-    def make_mobile_row_grid_renderers(self):
-        return {
-            'listitem': self.render_mobile_row_listitem,
-        }
-
-    def configure_mobile_row_grid(self, grid):
-        pass
-
-    def make_mobile_filters(self):
-        """
-        Returns a set of filters for the mobile grid, if applicable.
-        """
-
-    def make_mobile_row_filters(self):
-        """
-        Returns a set of filters for the mobile row grid, if applicable.
-        """
-
-    def render_mobile_row_listitem(self, obj, i):
-        return obj
-
     def create(self, form=None, template='create'):
         """
         View for creating a new model record.
         """
         self.creating = True
         if form is None:
-            form = self.make_form(self.get_model_class())
+            form = self.make_create_form()
         if self.request.method == 'POST':
             if self.validate_form(form):
                 # let save_create_form() return alternate object if necessary
@@ -760,21 +724,8 @@ class MasterView(View):
             context['dform'] = form.make_deform_form()
         return self.render_to_response(template, context)
 
-    def mobile_create(self):
-        """
-        Mobile view for creating a new primary object
-        """
-        self.mobile = True
-        self.creating = True
-        form = self.make_mobile_form(self.get_model_class())
-        if self.request.method == 'POST':
-            if self.validate_mobile_form(form):
-                # let save_create_form() return alternate object if necessary
-                obj = self.save_mobile_create_form(form)
-                self.after_create(obj)
-                self.flash_after_create(obj)
-                return self.redirect_after_create(obj, mobile=True)
-        return self.render_to_response('create', {'form': form}, mobile=True)
+    def make_create_form(self):
+        return self.make_form()
 
     def save_create_form(self, form):
         uploads = self.normalize_uploads(form)
@@ -788,27 +739,42 @@ class MasterView(View):
         return obj
 
     def normalize_uploads(self, form, skip=None):
+        app = self.get_rattail_app()
         uploads = {}
+
+        def normalize(filedict):
+            tempdir = app.make_temp_dir()
+            filepath = os.path.join(tempdir, filedict['filename'])
+            tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid'])
+            tmpdata = tmpinfo['fp'].read()
+            with open(filepath, 'wb') as f:
+                f.write(tmpdata)
+            return {'tempdir': tempdir,
+                    'temp_path': filepath}
+
         for node in form.schema:
-            if isinstance(node.typ, deform.FileData):
-                if skip and node.name in skip:
-                    continue
-                # TODO: does form ever *not* have 'validated' attr here?
-                if hasattr(form, 'validated'):
-                    filedict = form.validated.get(node.name)
+            if skip and node.name in skip:
+                continue
+
+            value = form.validated.get(node.name)
+            if not value:
+                continue
+
+            if isinstance(value, dfwidget.filedict):
+                uploads[node.name] = normalize(value)
+
+            elif not isinstance(value, dict):
+
+                try:
+                    values = iter(value)
+                except TypeError:
+                    pass
                 else:
-                    filedict = self.form_deserialized.get(node.name)
-                if filedict:
-                    tempdir = tempfile.mkdtemp()
-                    filepath = os.path.join(tempdir, filedict['filename'])
-                    tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid'])
-                    tmpdata = tmpinfo['fp'].read()
-                    with open(filepath, 'wb') as f:
-                        f.write(tmpdata)
-                    uploads[node.name] = {
-                        'tempdir': tempdir,
-                        'temp_path': filepath,
-                    }
+                    for value in values:
+                        if isinstance(value, dfwidget.filedict):
+                            uploads.setdefault(node.name, []).append(
+                                normalize(value))
+
         return uploads
 
     def process_uploads(self, obj, form, uploads):
@@ -818,14 +784,13 @@ class MasterView(View):
                                delete=False, schema=None, importer_host_title=None):
 
         handler = handler_factory(self.rattail_config)
-        use_buefy = self.get_use_buefy()
 
         if not schema:
             schema = forms.SimpleFileImport().bind(request=self.request)
-        form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy)
+        form = forms.Form(schema=schema, request=self.request)
         form.save_label = "Upload"
         form.cancel_url = self.get_index_url()
-        if form.validate(newstyle=True):
+        if form.validate():
 
             uploads = self.normalize_uploads(form)
             filepath = uploads['filename']['temp_path']
@@ -847,12 +812,37 @@ class MasterView(View):
             'importer_host_title': importer_host_title,
         })
 
+    def render_truncated_value(self, obj, field):
+        """
+        Simple renderer which truncates the (string) value to 100 chars.
+        """
+        value = getattr(obj, field)
+        if value is None:
+            return ""
+        value = str(value)
+        if len(value) > 100:
+            value = value[:100] + '...'
+        return value
+
     def render_id_str(self, obj, field):
         """
         Render the ``id_str`` attribute value for the given object.
         """
         return obj.id_str
 
+    def render_as_is(self, obj, field):
+        return getattr(obj, field)
+
+    def render_url(self, obj, field):
+        url = getattr(obj, field)
+        if url:
+            return tags.link_to(url, url, target='_blank')
+
+    def render_html(self, obj, field):
+        html = getattr(obj, field)
+        if html:
+            return HTML.literal(html)
+
     def render_default_phone(self, obj, field):
         """
         Render the "default" (first) phone number for the given contact.
@@ -867,31 +857,101 @@ class MasterView(View):
         if obj.emails:
             return obj.emails[0].address
 
-    def render_product_key_value(self, obj):
+    # TODO: deprecate / remove this
+    def render_product_key_value(self, obj, field=None):
         """
         Render the "canonical" product key value for the given object.
+
+        nb. the ``field`` kwarg is ignored if present
         """
         product_key = self.rattail_config.product_key()
         if product_key == 'upc':
             return obj.upc.pretty() if obj.upc else ''
         return getattr(obj, product_key)
 
+    def render_upc(self, obj, field):
+        """
+        Render a :class:`~rattail:rattail.gpc.GPC` field.
+        """
+        value = getattr(obj, field)
+        if value:
+            app = self.rattail_config.get_app()
+            return app.render_gpc(value)
+
+    def render_store(self, obj, field):
+        store = getattr(obj, field)
+        if store:
+            text = "({}) {}".format(store.id, store.name)
+            url = self.request.route_url('stores.view', uuid=store.uuid)
+            return tags.link_to(text, url)
+
+    def render_tax(self, obj, field):
+        tax = getattr(obj, field)
+        if not tax:
+            return
+        text = str(tax)
+        url = self.request.route_url('taxes.view', uuid=tax.uuid)
+        return tags.link_to(text, url)
+
+    def render_tender(self, obj, field):
+        tender = getattr(obj, field)
+        if not tender:
+            return
+        text = str(tender)
+        url = self.request.route_url('tenders.view', uuid=tender.uuid)
+        return tags.link_to(text, url)
+
+    def valid_employee_uuid(self, node, value):
+        if value:
+            model = self.app.model
+            employee = self.Session.get(model.Employee, value)
+            if not employee:
+                node.raise_invalid("Employee not found")
+
     def render_product(self, obj, field):
         product = getattr(obj, field)
         if not product:
             return ""
-        text = six.text_type(product)
+        text = str(product)
         url = self.request.route_url('products.view', uuid=product.uuid)
         return tags.link_to(text, url)
 
+    def render_pending_product(self, obj, field):
+        pending = getattr(obj, field)
+        if not pending:
+            return
+        text = str(pending)
+        url = self.request.route_url('pending_products.view', uuid=pending.uuid)
+        return tags.link_to(text, url,
+                            class_='has-background-warning')
+
     def render_vendor(self, obj, field):
         vendor = getattr(obj, field)
         if not vendor:
             return ""
-        text = "({}) {}".format(vendor.id, vendor.name)
+        short = vendor.id or vendor.abbreviation
+        if short:
+            text = "({}) {}".format(short, vendor.name)
+        else:
+            text = str(vendor)
         url = self.request.route_url('vendors.view', uuid=vendor.uuid)
         return tags.link_to(text, url)
 
+    def valid_vendor_uuid(self, node, value):
+        if value:
+            model = self.app.model
+            vendor = self.Session.get(model.Vendor, value)
+            if not vendor:
+                node.raise_invalid("Vendor not found")
+
+    def render_tax(self, obj, field):
+        tax = getattr(obj, field)
+        if not tax:
+            return
+        text = str(tax)
+        url = self.request.route_url('taxes.view', uuid=tax.uuid)
+        return tags.link_to(text, url)
+
     def render_department(self, obj, field):
         department = getattr(obj, field)
         if not department:
@@ -908,6 +968,14 @@ class MasterView(View):
         url = self.request.route_url('subdepartments.view', uuid=subdepartment.uuid)
         return tags.link_to(text, url)
 
+    def render_brand(self, obj, field):
+        brand = getattr(obj, field)
+        if not brand:
+            return
+        text = brand.name
+        url = self.request.route_url('brands.view', uuid=brand.uuid)
+        return tags.link_to(text, url)
+
     def render_category(self, obj, field):
         category = getattr(obj, field)
         if not category:
@@ -936,15 +1004,23 @@ class MasterView(View):
         person = getattr(obj, field)
         if not person:
             return ""
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
+    def render_person_profile(self, obj, field):
+        person = getattr(obj, field)
+        if not person:
+            return ""
+        text = str(person)
+        url = self.request.route_url('people.view_profile', uuid=person.uuid)
+        return tags.link_to(text, url)
+
     def render_user(self, obj, field):
         user = getattr(obj, field)
         if not user:
             return ""
-        text = six.text_type(user)
+        text = str(user)
         url = self.request.route_url('users.view', uuid=user.uuid)
         return tags.link_to(text, url)
 
@@ -960,14 +1036,62 @@ class MasterView(View):
             items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
         return HTML.tag('ul', c=items)
 
+    def render_employee(self, obj, field):
+        employee = getattr(obj, field)
+        if not employee:
+            return ""
+        text = str(employee)
+        url = self.request.route_url('employees.view', uuid=employee.uuid)
+        return tags.link_to(text, url)
+
     def render_customer(self, obj, field):
         customer = getattr(obj, field)
         if not customer:
             return ""
-        text = six.text_type(customer)
+        text = str(customer)
         url = self.request.route_url('customers.view', uuid=customer.uuid)
         return tags.link_to(text, url)
 
+    def render_member(self, obj, field):
+        member = getattr(obj, field)
+        if not member:
+            return
+        text = str(member)
+        url = self.request.route_url('members.view', uuid=member.uuid)
+        return tags.link_to(text, url)
+
+    def render_email_key(self, obj, field):
+        if hasattr(obj, field):
+            email_key = getattr(obj, field)
+        else:
+            email_key = obj[field]
+        if not email_key:
+            return
+
+        if self.request.has_perm('emailprofiles.view'):
+            url = self.request.route_url('emailprofiles.view', key=email_key)
+            return tags.link_to(email_key, url)
+
+        return email_key
+
+    def make_status_renderer(self, enum):
+        """
+        Creates and returns a function for use with rendering a
+        "status combo" field(s) for a record.  Assumes the record has
+        both ``status_code`` and ``status_text`` fields, as batches
+        do.  Renders the simple status code text, and if custom status
+        text is present, it is rendered as a tooltip.
+        """
+        def render_status(obj, field):
+            value = obj.status_code
+            if value is None:
+                return ""
+            status_code_text = enum.get(value, str(value))
+            if obj.status_text:
+                return HTML.tag('span', title=obj.status_text, c=status_code_text)
+            return status_code_text
+        return render_status
+
     def before_create_flush(self, obj, form):
         pass
 
@@ -975,19 +1099,10 @@ class MasterView(View):
         self.request.session.flash("{} has been created: {}".format(
             self.get_model_title(), self.get_instance_title(obj)))
 
-    def save_mobile_create_form(self, form):
-        self.before_create(form)
-        with self.Session.no_autoflush:
-            obj = self.objectify(form, self.form_deserialized)
-            self.before_create_flush(obj, form)
-        self.Session.add(obj)
-        self.Session.flush()
-        return obj
-
-    def redirect_after_create(self, instance, mobile=False):
+    def redirect_after_create(self, instance, **kwargs):
         if self.populatable and self.should_populate(instance):
-            return self.redirect(self.get_action_url('populate', instance, mobile=mobile))
-        return self.redirect(self.get_action_url('view', instance, mobile=mobile))
+            return self.redirect(self.get_action_url('populate', instance))
+        return self.redirect(self.get_action_url('view', instance))
 
     def should_populate(self, obj):
         return True
@@ -1020,8 +1135,9 @@ class MasterView(View):
         Thread target for populating new object with progress indicator.
         """
         # mustn't use tailbone web session here
-        session = RattailSession()
-        obj = session.query(self.model_class).get(uuid)
+        app = self.get_rattail_app()
+        session = app.make_session()
+        obj = session.get(self.model_class, uuid)
         try:
             self.populate_object(session, obj, progress=progress)
         except Exception as error:
@@ -1059,7 +1175,6 @@ class MasterView(View):
         View for viewing details of an existing model record.
         """
         self.viewing = True
-        use_buefy = self.get_use_buefy()
         if instance is None:
             instance = self.get_instance()
         form = self.make_form(instance)
@@ -1072,19 +1187,17 @@ class MasterView(View):
 
             # If user just refreshed the page with a reset instruction, issue a
             # redirect in order to clear out the query string.
-            if self.request.GET.get('reset-to-default-filters') == 'true':
-                return self.redirect(self.request.current_route_url(_query=None))
+            if self.request.GET.get('reset-view'):
+                kw = {'_query': None}
+                hash_ = self.request.GET.get('hash')
+                if hash_:
+                    kw['_anchor'] = hash_
+                return self.redirect(self.request.current_route_url(**kw))
 
             # return grid only, if partial page was requested
             if self.request.params.get('partial'):
-                if use_buefy:
-                    # render grid data only, as JSON
-                    return render_to_response('json', grid.get_buefy_data(),
-                                              request=self.request)
-                else: # just do traditional thing, render grid HTML
-                    self.request.response.content_type = str('text/html')
-                    self.request.response.text = grid.render_grid()
-                    return self.request.response
+                # render grid data only, as JSON
+                return self.json_response(grid.get_table_data())
 
         context = {
             'instance': instance,
@@ -1093,16 +1206,20 @@ class MasterView(View):
             'instance_deletable': self.deletable_instance(instance),
             'form': form,
         }
+        if self.executable:
+            context['instance_executable'] = self.executable_instance(instance)
         if hasattr(form, 'make_deform_form'):
             context['dform'] = form.make_deform_form()
 
         if self.has_rows:
-            if use_buefy:
-                context['rows_grid'] = grid
-                context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip()
-            else:
-                context['rows_grid'] = grid.render_complete(allow_save_defaults=False,
-                                                            tools=self.make_row_grid_tools(instance))
+            context['rows_grid'] = grid
+            context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip()
+
+        context['expose_versions'] = (self.has_versions
+                                      and self.request.rattail_config.versioning_enabled()
+                                      and self.has_perm('versions'))
+        if context['expose_versions']:
+            context['versions_grid'] = self.make_revisions_grid(instance, empty_data=True)
 
         return self.render_to_response('view', context)
 
@@ -1180,8 +1297,8 @@ class MasterView(View):
         self.Session.flush()
         return cloned
 
-    def redirect_after_clone(self, instance, mobile=False):
-        return self.redirect(self.get_action_url('view', instance, mobile=mobile))
+    def redirect_after_clone(self, instance, **kwargs):
+        return self.redirect(self.get_action_url('view', instance))
 
     def touch(self):
         """
@@ -1199,11 +1316,8 @@ class MasterView(View):
         """
         Perform actual "touch" logic for the given object.
         """
-        change = model.Change()
-        change.class_name = obj.__class__.__name__
-        change.instance_uuid = obj.uuid
-        change = self.Session.merge(change)
-        change.deleted = False
+        app = self.get_rattail_app()
+        app.touch_object(self.Session(), obj)
 
     def versions(self):
         """
@@ -1215,14 +1329,8 @@ class MasterView(View):
 
         # return grid only, if partial page was requested
         if self.request.params.get('partial'):
-            if use_buefy:
-                # render grid data only, as JSON
-                return render_to_response('json', grid.get_buefy_data(),
-                                          request=self.request)
-            else: # just do traditional thing, render grid HTML
-                self.request.response.content_type = str('text/html')
-                self.request.response.text = grid.render_grid()
-                return self.request.response
+            # render grid data only, as JSON
+            return self.json_response(grid.get_table_data())
 
         return self.render_to_response('versions', {
             'instance': instance,
@@ -1241,7 +1349,7 @@ class MasterView(View):
             return cls.version_grid_key
         return '{}.history'.format(cls.get_route_prefix())
 
-    def get_version_data(self, instance):
+    def get_version_data(self, instance, order_by=True):
         """
         Generate the base data set for the version grid.
         """
@@ -1249,14 +1357,19 @@ class MasterView(View):
         transaction_class = continuum.transaction_class(model_class)
         query = model_transaction_query(self.Session(), instance, model_class,
                                         child_classes=self.normalize_version_child_classes())
-        return query.order_by(transaction_class.issued_at.desc())
+        if order_by:
+            query = query.order_by(transaction_class.issued_at.desc())
+        return query
 
     def get_version_child_classes(self):
         """
         If applicable, should return a list of child classes which should be
         considered when querying for version history of an object.
         """
-        return []
+        classes = []
+        for supp in self.iter_view_supplements():
+            classes.extend(supp.get_version_child_classes())
+        return classes
 
     def normalize_version_child_classes(self):
         classes = []
@@ -1268,10 +1381,120 @@ class MasterView(View):
             classes.append(cls)
         return classes
 
+    def make_revisions_grid(self, obj, empty_data=False):
+        model = self.app.model
+        route_prefix = self.get_route_prefix()
+        row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
+                                                        uuid=obj.uuid,
+                                                        txnid=txn.id)
+
+        kwargs = {
+            'vue_tagname': 'versions-grid',
+            'ajax_data_url': self.get_action_url('revisions_data', obj),
+            'sortable': True,
+            'sort_multiple': not self.request.use_oruga,
+            'sort_defaults': ('changed', 'desc'),
+            'actions': [
+                self.make_action('view', icon='eye', url='#',
+                                 click_handler='viewRevision(props.row)'),
+                self.make_action('view_separate', url=row_url, target='_blank',
+                                 icon='external-link-alt', ),
+            ],
+        }
+
+        if empty_data:
+
+            # TODO: surely there is a better way to have empty initial
+            # data..?  but so much logic depends on a query, can't
+            # just pass empty list here
+            txn_class = continuum.transaction_class(self.get_model_class())
+            meta_class = continuum.versioning_manager.transaction_meta_cls
+            kwargs['data'] = self.Session.query(txn_class)\
+                                         .outerjoin(meta_class,
+                                                    meta_class.transaction_id == txn_class.id)\
+                                         .filter(txn_class.id == -1)
+
+        else:
+            kwargs['data'] = self.get_version_data(obj, order_by=False)
+
+        grid = self.make_version_grid(**kwargs)
+
+        grid.set_joiner('user', lambda q: q.outerjoin(model.User))
+        grid.set_sorter('user', model.User.username)
+
+        grid.set_link('remote_addr')
+
+        grid.append('id')
+        grid.set_label('id', "TXN ID")
+        grid.set_link('id')
+
+        return grid
+
+    def revisions_data(self):
+        """
+        AJAX view to fetch revision data for current instance.
+        """
+        txnid = self.request.GET.get('txnid')
+        if txnid:
+            # return single txn data
+
+            app = self.get_rattail_app()
+            obj = self.get_instance()
+            cls = self.get_model_class()
+            txn_cls = continuum.transaction_class(cls)
+            route_prefix = self.get_route_prefix()
+
+            transactions = model_transaction_query(
+                self.Session(), obj, cls,
+                child_classes=self.normalize_version_child_classes())
+
+            txn = transactions.filter(txn_cls.id == txnid).first()
+            if not txn:
+                return self.notfound()
+
+            older = transactions.filter(txn_cls.issued_at <= txn.issued_at)\
+                                .filter(txn_cls.id != txnid)\
+                                .order_by(txn_cls.issued_at.desc())\
+                                .first()
+            newer = transactions.filter(txn_cls.issued_at >= txn.issued_at)\
+                                .filter(txn_cls.id != txnid)\
+                                .order_by(txn_cls.issued_at)\
+                                .first()
+
+            version_diffs = []
+            for version in self.get_relevant_versions(txn, obj):
+                diff = self.make_version_diff(version)
+                version_diffs.append(diff.as_struct())
+
+            changed_raw = app.render_datetime(app.localtime(txn.issued_at, from_utc=True))
+            changed_ago = app.render_time_ago(app.make_utc() - txn.issued_at)
+
+            changed_by = str(txn.user or '')
+            if self.request.has_perm('users.view') and txn.user:
+                changed_by = tags.link_to(changed_by, self.request.route_url('users.view', uuid=txn.user.uuid))
+
+            return {
+                'txnid': txn.id,
+                'changed': f"{changed_raw} ({changed_ago})",
+                'changed_by': changed_by,
+                'remote_addr': txn.remote_addr,
+                'comment': txn.meta.get('comment'),
+                'versions': version_diffs,
+                'url': self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, txnid=txnid),
+                'prev_txnid': older.id if older else None,
+                'next_txnid': newer.id if newer else None,
+            }
+
+        else: # no txnid, return grid data
+            obj = self.get_instance()
+            grid = self.make_revisions_grid(obj)
+            return grid.get_table_data()
+
     def view_version(self):
         """
         View showing diff details of a particular object version.
         """
+        app = self.get_rattail_app()
         instance = self.get_instance()
         model_class = self.get_model_class()
         route_prefix = self.get_route_prefix()
@@ -1299,14 +1522,28 @@ class MasterView(View):
         if newer:
             next_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=newer.id)
 
+        version_diffs = []
+        versions = self.get_relevant_versions(transaction, instance)
+        for version in versions:
+
+            old_data = {}
+            new_data = {}
+            fields = self.fields_for_version(version)
+            for field in fields:
+                if version.previous:
+                    old_data[field] = getattr(version.previous, field)
+                new_data[field] = getattr(version, field)
+            diff = self.make_version_diff(version, old_data, new_data, fields=fields)
+            version_diffs.append(diff)
+
         return self.render_to_response('view_version', {
             'instance': instance,
             'instance_title': "{} (history)".format(instance_title),
             'instance_title_normal': instance_title,
             'instance_url': self.get_action_url('versions', instance),
             'transaction': transaction,
-            'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True),
-            'versions': self.get_relevant_versions(transaction, instance),
+            'changed': app.localtime(transaction.issued_at, from_utc=True),
+            'version_diffs': version_diffs,
             'show_prev_next': True,
             'prev_url': prev_url,
             'next_url': next_url,
@@ -1315,9 +1552,20 @@ class MasterView(View):
             'title_for_version': self.title_for_version,
             'fields_for_version': self.fields_for_version,
             'continuum': continuum,
+            'render_old_value': self.render_version_old_field_value,
+            'render_new_value': self.render_version_new_field_value,
         })
 
     def title_for_version(self, version):
+        """
+        Must return the title text for the given version.  By default
+        this will be the :term:`rattail:model title` for the version's
+        data class.
+
+        :param version: Reference to a Continuum version object.
+
+        :returns: Title text for the version, as string.
+        """
         cls = continuum.parent_class(version.__class__)
         return cls.get_model_title()
 
@@ -1344,74 +1592,11 @@ class MasterView(View):
             versions.extend(query.all())
         return versions
 
-    def mobile_view(self):
-        """
-        Mobile view for displaying a single object's details
-        """
-        self.mobile = True
-        self.viewing = True
-        instance = self.get_instance()
-        form = self.make_mobile_form(instance)
+    def render_version_old_field_value(self, version, field):
+        return repr(getattr(version.previous, field))
 
-        context = {
-            'instance': instance,
-            'instance_title': self.get_instance_title(instance),
-            'instance_editable': self.editable_instance(instance),
-            # 'instance_deletable': self.deletable_instance(instance),
-            'form': form,
-        }
-        if self.has_rows:
-            context['model_row_class'] = self.model_row_class
-            context['grid'] = self.make_mobile_row_grid(instance=instance)
-        return self.render_to_response('view', context, mobile=True)
-
-    def make_mobile_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs):
-        """
-        Creates a new mobile form for the given model class/instance.
-        """
-        if factory is None:
-            factory = self.get_mobile_form_factory()
-        if fields is None:
-            fields = self.get_mobile_form_fields()
-        if schema is None:
-            schema = self.make_mobile_form_schema()
-
-        if not self.creating:
-            kwargs['model_instance'] = instance
-        kwargs = self.make_mobile_form_kwargs(**kwargs)
-        form = factory(fields, schema, **kwargs)
-        self.configure_mobile_form(form)
-        return form
-
-    def get_mobile_form_fields(self):
-        if hasattr(self, 'mobile_form_fields'):
-            return self.mobile_form_fields
-        # TODO
-        # raise NotImplementedError
-
-    def make_mobile_form_schema(self):
-        if not self.model_class:
-            # TODO
-            raise NotImplementedError
-
-    def make_mobile_form_kwargs(self, **kwargs):
-        """
-        Return a dictionary of kwargs to be passed to the factory when creating
-        new mobile forms.
-        """
-        defaults = {
-            'request': self.request,
-            'readonly': self.viewing,
-            'model_class': getattr(self, 'model_class', None),
-            'action_url': self.request.current_route_url(_query=None),
-        }
-        if self.creating:
-            defaults['cancel_url'] = self.get_index_url(mobile=True)
-        else:
-            instance = kwargs['model_instance']
-            defaults['cancel_url'] = self.get_action_url('view', instance, mobile=True)
-        defaults.update(kwargs)
-        return defaults
+    def render_version_new_field_value(self, version, field, typ):
+        return repr(getattr(version, field))
 
     def configure_common_form(self, form):
         """
@@ -1421,6 +1606,8 @@ class MasterView(View):
         By default this removes the 'uuid' field (if present), sets any primary
         key fields to be readonly (if we have a :attr:`model_class` and are in
         edit mode), and sets labels as defined by the master class hierarchy.
+
+        TODO: this logic should be moved back into configure_form()
         """
         form.remove_field('uuid')
 
@@ -1446,62 +1633,29 @@ class MasterView(View):
                 # is the safer option and would help prevent unwanted mistakes
                 form.set_default('local_only', True)
 
-    def configure_mobile_form(self, form):
-        """
-        Configure the main "mobile" form for the view's data model.
-        """
-        self.configure_common_form(form)
-
-    def validate_mobile_form(self, form):
-        if form.validate(newstyle=True):
-            # TODO: deprecate / remove self.form_deserialized
-            self.form_deserialized = form.validated
-            return True
-        else:
-            return False
-
-    def make_mobile_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs):
-        """
-        Creates a new mobile form for the given model class/instance.
-        """
-        if factory is None:
-            factory = self.get_mobile_row_form_factory()
-        if fields is None:
-            fields = self.get_mobile_row_form_fields()
-        if schema is None:
-            schema = self.make_mobile_row_form_schema()
-
-        if not self.creating:
-            kwargs['model_instance'] = instance
-        kwargs = self.make_mobile_row_form_kwargs(**kwargs)
-        form = factory(fields, schema, **kwargs)
-        self.configure_mobile_row_form(form)
-        return form
-
-    def make_quick_row_form(self, instance=None, factory=None, fields=None, schema=None, mobile=False, **kwargs):
+    def make_quick_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs):
         """
         Creates a "quick" form for adding a new row to the given instance.
         """
         if factory is None:
-            factory = self.get_quick_row_form_factory(mobile=mobile)
+            factory = self.get_quick_row_form_factory()
         if fields is None:
-            fields = self.get_quick_row_form_fields(mobile=mobile)
+            fields = self.get_quick_row_form_fields()
         if schema is None:
-            schema = self.make_quick_row_form_schema(mobile=mobile)
+            schema = self.make_quick_row_form_schema()
 
-        kwargs['mobile'] = mobile
         kwargs = self.make_quick_row_form_kwargs(**kwargs)
         form = factory(fields, schema, **kwargs)
-        self.configure_quick_row_form(form, mobile=mobile)
+        self.configure_quick_row_form(form)
         return form
 
-    def get_quick_row_form_factory(self, mobile=False):
+    def get_quick_row_form_factory(self, **kwargs):
         return forms.Form
 
-    def get_quick_row_form_fields(self, mobile=False):
+    def get_quick_row_form_fields(self, **kwargs):
         pass
 
-    def make_quick_row_form_schema(self, mobile=False):
+    def make_quick_row_form_schema(self, **kwargs):
         schema = colander.MappingSchema()
         schema.add(colander.SchemaNode(colander.String(), name='quick_entry'))
         return schema
@@ -1515,102 +1669,12 @@ class MasterView(View):
         defaults.update(kwargs)
         return defaults
 
-    def configure_quick_row_form(self, form, mobile=False):
+    def configure_quick_row_form(self, form, **kwargs):
         pass
 
-    def get_mobile_row_form_fields(self):
-        if hasattr(self, 'mobile_row_form_fields'):
-            return self.mobile_row_form_fields
-        # TODO
-        # raise NotImplementedError
-
-    def make_mobile_row_form_schema(self):
-        if not self.model_row_class:
-            # TODO
-            raise NotImplementedError
-
-    def make_mobile_row_form_kwargs(self, **kwargs):
-        """
-        Return a dictionary of kwargs to be passed to the factory when creating
-        new mobile row forms.
-        """
-        defaults = {
-            'request': self.request,
-            'mobile': True,
-            'readonly': self.viewing,
-            'model_class': getattr(self, 'model_row_class', None),
-            'action_url': self.request.current_route_url(_query=None),
-        }
-        if self.creating:
-            defaults['cancel_url'] = self.request.get_referrer()
-        else:
-            instance = kwargs['model_instance']
-            defaults['cancel_url'] = self.get_row_action_url('view', instance, mobile=True)
-        defaults.update(kwargs)
-        return defaults
-
-    def configure_mobile_row_form(self, form):
-        """
-        Configure the mobile row form.
-        """
-        # TODO: is any of this stuff from configure_form() needed?
-        # if self.editing:
-        #     model_class = self.get_model_class(error=False)
-        #     if model_class:
-        #         mapper = orm.class_mapper(model_class)
-        #         for key in mapper.primary_key:
-        #             for field in form.fields:
-        #                 if field == key.name:
-        #                     form.set_readonly(field)
-        #                     break
-        # form.remove_field('uuid')
-
-        self.set_row_labels(form)
-
-    def validate_mobile_row_form(self, form):
-        controls = self.request.POST.items()
-        try:
-            self.form_deserialized = form.validate(controls)
-        except deform.ValidationFailure:
-            return False
-        return True
-
     def validate_quick_row_form(self, form):
-        return form.validate(newstyle=True)
+        return form.validate()
 
-    def get_mobile_row_data(self, parent):
-        query = self.get_row_data(parent)
-        return self.sort_mobile_row_data(query)
-
-    def sort_mobile_row_data(self, query):
-        return query
-
-    def mobile_row_route_url(self, route_name, **kwargs):
-        route_name = 'mobile.{}.{}_row'.format(self.get_route_prefix(), route_name)
-        return self.request.route_url(route_name, **kwargs)
-
-    def mobile_view_row(self):
-        """
-        Mobile view for row items
-        """
-        self.mobile = True
-        self.viewing = True
-        row = self.get_row_instance()
-        parent = self.get_parent(row)
-        form = self.make_mobile_row_form(row)
-        context = {
-            'row': row,
-            'parent_instance': parent,
-            'parent_title': self.get_instance_title(parent),
-            'parent_url': self.get_action_url('view', parent, mobile=True),
-            'instance': row,
-            'instance_title': self.get_row_instance_title(row),
-            'instance_editable': self.row_editable(row),
-            'parent_model_title': self.get_model_title(),
-            'form': form,
-        }
-        return self.render_to_response('view_row', context, mobile=True)
-        
     def make_default_row_grid_tools(self, obj):
         if self.rows_creatable:
             link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
@@ -1643,10 +1707,10 @@ class MasterView(View):
         """
         if session is None:
             session = self.Session()
-        kwargs.setdefault('pageable', False)
+        kwargs.setdefault('paginated', False)
         kwargs.setdefault('sortable', sort)
         grid = self.make_row_grid(session=session, **kwargs)
-        return grid.make_visible_data()
+        return grid.get_visible_data()
 
     @classmethod
     def get_row_url_prefix(cls):
@@ -1672,6 +1736,9 @@ class MasterView(View):
         """
         return True
 
+    def row_view_action_url(self, row, i):
+        return self.get_row_action_url('view', row)
+
     def row_edit_action_url(self, row, i):
         if self.row_editable(row):
             return self.get_row_action_url('edit', row)
@@ -1723,24 +1790,13 @@ class MasterView(View):
         """
         obj = self.get_instance()
         filename = self.request.GET.get('filename', None)
-        if not filename:
-            raise self.notfound()
         path = self.download_path(obj, filename)
-        response = FileResponse(path, request=self.request)
-        response.content_length = os.path.getsize(path)
+        if not path or not os.path.exists(path):
+            raise self.notfound()
+        response = self.file_response(path)
         content_type = self.download_content_type(path, filename)
         if content_type:
-            if six.PY3:
-                response.content_type = content_type
-            else:
-                response.content_type = six.binary_type(content_type)
-
-        # content-disposition
-        filename = os.path.basename(path)
-        if six.PY2:
-            filename = filename.encode('ascii', 'replace')
-        response.content_disposition = str('attachment; filename="{}"'.format(filename))
-
+            response.content_type = content_type
         return response
 
     def download_content_type(self, path, filename):
@@ -1748,6 +1804,46 @@ class MasterView(View):
         Return a content type for a file download, if known.
         """
 
+    def download_input_file_template(self):
+        """
+        View for downloading an input file template.
+        """
+        key = self.request.GET['key']
+        filespec = self.request.GET['file']
+
+        matches = [tmpl for tmpl in self.get_input_file_templates()
+                   if tmpl['key'] == key]
+        if not matches:
+            raise self.notfound()
+
+        template = matches[0]
+        templatesdir = os.path.join(self.rattail_config.datadir(),
+                                    'templates', 'input_files',
+                                    self.get_route_prefix())
+        basedir = os.path.join(templatesdir, template['key'])
+        path = os.path.join(basedir, filespec)
+        return self.file_response(path)
+
+    def download_output_file_template(self):
+        """
+        View for downloading an output file template.
+        """
+        key = self.request.GET['key']
+        filespec = self.request.GET['file']
+
+        matches = [tmpl for tmpl in self.get_output_file_templates()
+                   if tmpl['key'] == key]
+        if not matches:
+            raise self.notfound()
+
+        template = matches[0]
+        templatesdir = os.path.join(self.rattail_config.datadir(),
+                                    'templates', 'output_files',
+                                    self.get_route_prefix())
+        basedir = os.path.join(templatesdir, template['key'])
+        path = os.path.join(basedir, filespec)
+        return self.file_response(path)
+
     def edit(self):
         """
         View for editing an existing model record.
@@ -1781,68 +1877,23 @@ class MasterView(View):
             context['dform'] = form.make_deform_form()
         return self.render_to_response('edit', context)
 
-    def mobile_edit(self):
-        """
-        Mobile view for editing an existing model record.
-        """
-        self.mobile = True
-        self.editing = True
-        obj = self.get_instance()
-
-        if not self.editable_instance(obj):
-            msg = "Edit is not permitted for {}: {}".format(
-                self.get_model_title(),
-                self.get_instance_title(obj))
-            self.request.session.flash(msg, 'error')
-            return self.redirect(self.get_action_url('view', obj))
-
-        form = self.make_mobile_form(obj)
-
-        if self.request.method == 'POST':
-            if self.validate_mobile_form(form):
-
-                # note that save_form() may return alternate object
-                obj = self.save_mobile_edit_form(form)
-
-                msg = "{} has been updated: {}".format(
-                    self.get_model_title(),
-                    self.get_instance_title(obj))
-                self.request.session.flash(msg)
-                return self.redirect_after_edit(obj, mobile=True)
-
-        context = {
-            'instance': obj,
-            'instance_title': self.get_instance_title(obj),
-            'instance_deletable': self.deletable_instance(obj),
-            'instance_url': self.get_action_url('view', obj, mobile=True),
-            'form': form,
-        }
-        if hasattr(form, 'make_deform_form'):
-            context['dform'] = form.make_deform_form()
-        return self.render_to_response('edit', context, mobile=True)
-
     def save_edit_form(self, form):
-        if not self.mobile:
-            uploads = self.normalize_uploads(form)
+        uploads = self.normalize_uploads(form)
         obj = self.objectify(form)
-        if not self.mobile:
-            self.process_uploads(obj, form, uploads)
+        self.process_uploads(obj, form, uploads)
         self.after_edit(obj)
         self.Session.flush()
         return obj
 
-    def save_mobile_edit_form(self, form):
-        return self.save_edit_form(form)
-
-    def redirect_after_edit(self, instance, mobile=False):
-        return self.redirect(self.get_action_url('view', instance, mobile=mobile))
+    def redirect_after_edit(self, instance, **kwargs):
+        return self.redirect(self.get_action_url('view', instance))
 
     def delete(self):
         """
         View for deleting an existing model record.
         """
         if not self.deletable:
-            raise httpexceptions.HTTPForbidden()
+            raise self.forbidden()
 
         self.deleting = True
         instance = self.get_instance()
@@ -1854,6 +1905,7 @@ class MasterView(View):
             return self.redirect(self.get_action_url('view', instance))
 
         form = self.make_form(instance)
+        form.save_label = "DELETE Forever"
 
         # TODO: Add better validation, ideally CSRF etc.
         if self.request.method == 'POST':
@@ -1863,15 +1915,20 @@ class MasterView(View):
             if isinstance(result, httpexceptions.HTTPException):
                 return result
 
-            self.delete_instance(instance)
-            self.request.session.flash("{} has been deleted: {}".format(
-                self.get_model_title(), instance_title))
-            return self.redirect(self.get_after_delete_url(instance))
+            if self.delete_requires_progress:
+                return self.delete_instance_with_progress(instance)
+            else:
+                self.delete_instance(instance)
+                self.request.session.flash("{} has been deleted: {}".format(
+                    self.get_model_title(), instance_title))
+                return self.redirect(self.get_after_delete_url(instance))
 
         form.readonly = True
         return self.render_to_response('delete', {
             'instance': instance,
             'instance_title': instance_title,
+            'instance_editable': self.editable_instance(instance),
+            'instance_deletable': self.deletable_instance(instance),
             'form': form})
 
     def bulk_delete(self):
@@ -1891,7 +1948,8 @@ class MasterView(View):
     def bulk_delete_objects(self, session, objects, progress=None):
 
         def delete(obj, i):
-            self.delete_instance(obj)
+            if self.deletable_instance(obj):
+                self.delete_instance(obj)
             if i % 1000 == 0:
                 session.flush()
 
@@ -1899,7 +1957,7 @@ class MasterView(View):
                            message="Deleting objects")
 
     def get_bulk_delete_session(self):
-        return RattailSession()
+        return self.make_isolated_session()
 
     def bulk_delete_thread(self, objects, progress):
         """
@@ -1941,7 +1999,7 @@ class MasterView(View):
         if uuids:
             uuids = uuids.split(',')
             # TODO: probably need to allow override of fetcher callable
-            fetcher = lambda uuid: self.Session.query(self.model_class).get(uuid)
+            fetcher = lambda uuid: self.Session.get(self.model_class, uuid)
             objects = []
             for uuid in uuids:
                 obj = fetcher(uuid)
@@ -1991,21 +2049,35 @@ class MasterView(View):
             self.request.session.flash("Deleted {} {}".format(len(objects), model_title_plural))
         return self.redirect(self.get_index_url())
 
-    def oneoff_import(self, importer, host_object=None):
+    def fetch_grid_totals(self):
+        return {'totals_display': "TODO: totals go here"}
+
+    def oneoff_import(self, importer, host_object=None, local_object=None):
         """
         Basic helper method, to do a one-off import (or export, depending on
         perspective) of the "current instance" object.  Where the data "goes"
         depends on the importer you provide.
         """
-        if not host_object:
+        if host_object is None and local_object is None:
             host_object = self.get_instance()
 
-        host_data = importer.normalize_host_object(host_object)
-        if not host_data:
-            return
+        if host_object is None:
+            local_data = importer.normalize_local_object(local_object)
+            key = importer.get_key(local_data)
+            host_object = importer.get_single_host_object(key)
+            if not host_object:
+                return
+            host_data = importer.normalize_host_object(host_object)
+            if not host_data:
+                return
+
+        else:
+            host_data = importer.normalize_host_object(host_object)
+            if not host_data:
+                return
+            key = importer.get_key(host_data)
+            local_object = importer.get_local_object(key)
 
-        key = importer.get_key(host_data)
-        local_object = importer.get_local_object(key)
         if local_object:
             if importer.allow_update:
                 local_data = importer.normalize_local_object(local_object)
@@ -2015,42 +2087,80 @@ class MasterView(View):
         elif importer.allow_create:
             return importer.create_object(key, host_data)
 
+    def executable_instance(self, instance):
+        """
+        Returns boolean indicating whether or not the given instance
+        can be considered "executable".  Returns ``True`` by default;
+        override as necessary.
+        """
+        return True
+
     def execute(self):
         """
         Execute an object.
         """
         obj = self.get_instance()
         model_title = self.get_model_title()
-        if self.request.method == 'POST':
 
-            progress = self.make_execute_progress(obj)
-            kwargs = {'progress': progress}
-            thread = Thread(target=self.execute_thread, args=(obj.uuid, self.request.user.uuid), kwargs=kwargs)
-            thread.start()
+        # caller must explicitly request websocket behavior; otherwise
+        # we will assume traditional behavior for progress
+        ws = False
+        if ((self.request.is_xhr or self.request.content_type == 'application/json')
+            and self.request.json_body.get('ws')):
+            ws = True
 
-            return self.render_progress(progress, {
-                'instance': obj,
-                'initial_msg': self.execute_progress_initial_msg,
-                'cancel_url': self.get_action_url('view', obj),
-                'cancel_msg': "{} execution was canceled".format(model_title),
-            }, template=self.execute_progress_template)
+        # make our progress tracker
+        progress = self.make_execute_progress(obj, ws=ws)
 
-        self.request.session.flash("Sorry, you must POST to execute a {}.".format(model_title), 'error')
-        return self.redirect(self.get_action_url('view', obj))
+        # start execution in a separate thread
+        kwargs = {'progress': progress}
+        key = [self.request.matchdict[k]
+               for k in self.get_model_key(as_tuple=True)]
+        thread = Thread(target=self.execute_thread,
+                        args=(key, self.request.user.uuid),
+                        kwargs=kwargs)
+        thread.start()
 
-    def make_execute_progress(self, obj):
-        key = '{}.execute'.format(self.get_grid_key())
-        return self.make_progress(key)
+        # we're done here if using websockets
+        if ws:
+            return self.json_response({'ok': True})
 
-    def execute_thread(self, uuid, user_uuid, progress=None, **kwargs):
+        # traditional behavior sends user to dedicated progress page
+        return self.render_progress(progress, {
+            'instance': obj,
+            'initial_msg': self.execute_progress_initial_msg,
+            'can_cancel': self.execute_can_cancel,
+            'cancel_url': self.get_action_url('view', obj),
+            'cancel_msg': "{} execution was canceled".format(model_title),
+        }, template=self.execute_progress_template)
+
+    def make_execute_progress(self, obj, ws=False):
+        if ws:
+            key = '{}.{}.execution_progress'.format(self.get_route_prefix(), obj.uuid)
+        else:
+            key = '{}.execute'.format(self.get_grid_key())
+        return self.make_progress(key, ws=ws)
+
+    def get_instance_for_key(self, key, session):
+        model_key = self.get_model_key(as_tuple=True)
+        if len(model_key) == 1 and model_key[0] == 'uuid':
+            uuid = key[0]
+            return session.get(self.model_class, uuid)
+        raise NotImplementedError
+
+    def execute_thread(self, key, user_uuid, progress=None, **kwargs):
         """
         Thread target for executing an object.
         """
-        session = RattailSession()
-        obj = session.query(self.model_class).get(uuid)
-        user = session.query(model.User).get(user_uuid)
+        app = self.get_rattail_app()
+        model = self.app.model
+        session = app.make_session()
+        obj = self.get_instance_for_key(key, session)
+        user = session.get(model.User, user_uuid)
         try:
-            self.execute_instance(obj, user, progress=progress, **kwargs)
+            success_msg = self.execute_instance(obj, user,
+                                                progress=progress,
+                                                **kwargs)
 
         # If anything goes wrong, rollback and log the error etc.
         except Exception as error:
@@ -2066,13 +2176,21 @@ class MasterView(View):
         # If no error, check result flag (false means user canceled).
         else:
             session.commit()
-            session.refresh(obj)
+            try:
+                needs_refresh = obj in session
+            except:
+                pass
+            else:
+                if needs_refresh:
+                    session.refresh(obj)
             success_url = self.get_execute_success_url(obj)
             session.close()
             if progress:
                 progress.session.load()
                 progress.session['complete'] = True
                 progress.session['success_url'] = success_url
+                if success_msg:
+                    progress.session['success_msg'] = success_msg
                 progress.session.save()
 
     def execute_error_message(self, error):
@@ -2082,56 +2200,166 @@ class MasterView(View):
     def get_execute_success_url(self, obj, **kwargs):
         return self.get_action_url('view', obj, **kwargs)
 
+    def progress_thread(self, sock, success_url, progress):
+        """
+        This method is meant to be used as a thread target.  Its job is to read
+        progress data from ``connection`` and update the session progress
+        accordingly.  When a final "process complete" indication is read, the
+        socket will be closed and the thread will end.
+        """
+        while True:
+            try:
+                self.process_progress(sock, progress)
+            except EverythingComplete:
+                break
+
+        # close server socket
+        sock.close()
+
+        # finalize session progress
+        progress.session.load()
+        progress.session['complete'] = True
+        if callable(success_url):
+            success_url = success_url()
+        progress.session['success_url'] = success_url
+        progress.session.save()
+
+    def process_progress(self, sock, progress):
+        """
+        This method will accept a client connection on the given socket, and
+        then update the given progress object according to data written by the
+        client.
+        """
+        connection, client_address = sock.accept()
+        active_progress = None
+
+        # TODO: make this configurable?
+        suffix = "\n\n.".encode('utf_8')
+        data = b''
+
+        # listen for progress info, update session progress as needed
+        while True:
+
+            # accumulate data bytestring until we see the suffix
+            byte = connection.recv(1)
+            data += byte
+            if data.endswith(suffix):
+
+                # strip suffix, interpret data as JSON
+                data = data[:-len(suffix)]
+                data = data.decode('utf_8')
+                data = json.loads(data)
+
+                if data.get('everything_complete'):
+                    if active_progress:
+                        active_progress.finish()
+                    raise EverythingComplete
+
+                elif data.get('process_complete'):
+                    active_progress.finish()
+                    active_progress = None
+                    break
+
+                elif 'value' in data:
+                    if not active_progress:
+                        active_progress = progress(data['message'], data['maximum'])
+                    active_progress.update(data['value'])
+
+                # reset data buffer
+                data = b''
+
+        # close client connection
+        connection.close()
+
     def get_merge_fields(self):
         if hasattr(self, 'merge_fields'):
             return self.merge_fields
+
+        if self.merge_handler:
+            fields = self.merge_handler.get_merge_preview_fields()
+            return [field['name'] for field in fields]
+
         mapper = orm.class_mapper(self.get_model_class())
         return mapper.columns.keys()
 
     def get_merge_coalesce_fields(self):
         if hasattr(self, 'merge_coalesce_fields'):
             return self.merge_coalesce_fields
+
+        if self.merge_handler:
+            fields = self.merge_handler.get_merge_preview_fields()
+            return [field['name'] for field in fields
+                    if field.get('coalesce')]
+
         return []
 
     def get_merge_additive_fields(self):
         if hasattr(self, 'merge_additive_fields'):
             return self.merge_additive_fields
+
+        if self.merge_handler:
+            fields = self.merge_handler.get_merge_preview_fields()
+            return [field['name'] for field in fields
+                    if field.get('additive')]
+
         return []
 
+    def get_merge_objects(self):
+        """
+        Must return 2 objects, obtained somehow from the request,
+        which are to be (potentially) merged.
+
+        :returns: 2-tuple of ``(object_to_remove, object_to_keep)``,
+           or ``None``.
+        """
+        uuids = self.request.POST.get('uuids', '').split(',')
+        if len(uuids) == 2:
+            cls = self.get_model_class()
+            object_to_remove = self.Session.get(cls, uuids[0])
+            object_to_keep = self.Session.get(cls, uuids[1])
+            if object_to_remove and object_to_keep:
+                return object_to_remove, object_to_keep
+
     def merge(self):
         """
         Preview and execute a merge of two records.
         """
         object_to_remove = object_to_keep = None
         if self.request.method == 'POST':
-            uuids = self.request.POST.get('uuids', '').split(',')
-            if len(uuids) == 2:
-                object_to_remove = self.Session.query(self.get_model_class()).get(uuids[0])
-                object_to_keep = self.Session.query(self.get_model_class()).get(uuids[1])
-
+            objects = self.get_merge_objects()
+            if objects:
+                object_to_remove, object_to_keep = objects
                 if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes':
-                    msg = six.text_type(object_to_remove)
+                    msg = str(object_to_remove)
                     try:
                         self.validate_merge(object_to_remove, object_to_keep)
                     except Exception as error:
                         self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error')
                     else:
-                        self.merge_objects(object_to_remove, object_to_keep)
-                        self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
-                        return self.redirect(self.get_action_url('view', object_to_keep))
+                        try:
+                            self.merge_objects(object_to_remove, object_to_keep)
+                            self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep))
+                            return self.redirect(self.get_action_url('view', object_to_keep))
+                        except Exception as error:
+                            error = simple_error(error)
+                            self.request.session.flash(f"merge failed: {error}", 'error')
 
         if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep:
             return self.redirect(self.get_index_url())
 
         remove = self.get_merge_data(object_to_remove)
         keep = self.get_merge_data(object_to_keep)
-        return self.render_to_response('merge', {'object_to_remove': object_to_remove,
-                                                 'object_to_keep': object_to_keep,
-                                                 'view_url': lambda obj: self.get_action_url('view', obj),
-                                                 'merge_fields': self.get_merge_fields(),
-                                                 'remove_data': remove,
-                                                 'keep_data': keep,
-                                                 'resulting_data': self.get_merge_resulting_data(remove, keep)})
+        return self.render_to_response('merge', {
+            'object_to_remove': object_to_remove,
+            'object_to_keep': object_to_keep,
+            'removing_uuid': self.get_uuid_for_grid_row(object_to_remove),
+            'keeping_uuid': self.get_uuid_for_grid_row(object_to_keep),
+            'view_url': lambda obj: self.get_action_url('view', obj),
+            'merge_fields': self.get_merge_fields(),
+            'remove_data': remove,
+            'keep_data': keep,
+            'resulting_data': self.get_merge_resulting_data(remove, keep),
+        })
 
     def validate_merge(self, removing, keeping):
         """
@@ -2139,9 +2367,17 @@ class MasterView(View):
         the requested merge is valid, in your context.  If it is not - for *any
         reason* - you should raise an exception; the type does not matter.
         """
+        if self.merge_handler:
+            reason = self.merge_handler.why_not_merge(removing, keeping)
+            if reason:
+                raise Exception(reason)
 
     def get_merge_data(self, obj):
-        raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__))
+        if self.merge_handler:
+            return self.merge_handler.get_merge_preview_data(obj)
+
+        return dict([(f, getattr(obj, f, None))
+                     for f in self.get_merge_fields()])
 
     def get_merge_resulting_data(self, remove, keep):
         result = dict(keep)
@@ -2162,7 +2398,13 @@ class MasterView(View):
         Merge the two given objects.  You should probably override this;
         default behavior is merely to delete the 'removing' object.
         """
-        self.Session.delete(removing)
+        if self.merge_handler:
+            self.merge_handler.perform_merge(removing, keeping,
+                                             user=self.request.user)
+
+        else:
+            # nb. default "merge" does not update kept object!
+            self.Session.delete(removing)
 
     ##############################
     # Core Stuff
@@ -2199,14 +2441,34 @@ class MasterView(View):
     @classmethod
     def get_model_key(cls, as_tuple=False):
         """
-        Returns the primary key(s) for the model class.  Note that this will
-        return a *string* value unless a tuple is requested.  If the model has
-        a composite key then the string result would be a comma-delimited list
-        of names, e.g. ``foo_id,bar_id``.
+        Returns the primary model key(s) for the master view.
+
+        Internally, model keys are a sequence of one or more keys.
+        Most typically it's just one, so e.g. ``('uuid',)``, but
+        composite keys are possible too, e.g. ``('parent_id',
+        'child_id')``.
+
+        Despite that, this method will return a *string*
+        representation of the keys, unless ``as_tuple=True`` in which
+        case it returns a tuple.  For example::
+
+           # for model keys: ('uuid',)
+
+           cls.get_model_key()                  # => 'uuid'
+           cls.get_model_key(as_tuple=True)     # => ('uuid',)
+
+           # for model keys: ('parent_id', 'child_id')
+
+           cls.get_model_key()                  # => 'parent_id,child_id'
+           cls.get_model_key(as_tuple=True)     # => ('parent_id', 'child_id')
+
+        :param as_tuple: Whether to return a tuple instead of string.
+
+        :returns: Either a string or tuple of model keys.
         """
         if hasattr(cls, 'model_key'):
             keys = cls.model_key
-            if isinstance(keys, six.string_types):
+            if isinstance(keys, str):
                 keys = [keys]
         else:
             keys = get_primary_keys(cls.get_model_class())
@@ -2252,8 +2514,10 @@ class MasterView(View):
         the master view class.  This is the plural, lower-cased name of the
         model class by default, e.g. 'products'.
         """
+        if hasattr(cls, 'route_prefix'):
+            return cls.route_prefix
         model_name = cls.get_normalized_model_name()
-        return getattr(cls, 'route_prefix', '{0}s'.format(model_name))
+        return '{}s'.format(model_name)
 
     @classmethod
     def get_url_prefix(cls):
@@ -2280,15 +2544,16 @@ class MasterView(View):
         """
         return getattr(cls, 'permission_prefix', cls.get_route_prefix())
 
-    def get_index_url(self, mobile=False, **kwargs):
+    def get_index_url(self, **kwargs):
         """
         Returns the master view's index URL.
         """
-        route = self.get_route_prefix()
-        if mobile:
-            route = 'mobile.{}'.format(route)
-        return self.request.route_url(route, **kwargs)
+        if self.listable:
+            route = self.get_route_prefix()
+            return self.request.route_url(route, **kwargs)
 
+    # TODO: this should not be class method, if possible
+    # (pretty sure overriding as instance method works fine)
     @classmethod
     def get_index_title(cls):
         """
@@ -2296,15 +2561,23 @@ class MasterView(View):
         """
         return getattr(cls, 'index_title', cls.get_model_title_plural())
 
-    def get_action_url(self, action, instance, mobile=False, **kwargs):
+    @classmethod
+    def get_config_title(cls):
+        """
+        Returns the view's "config title".
+        """
+        if hasattr(cls, 'config_title'):
+            return cls.config_title
+
+        return cls.get_model_title_plural()
+
+    def get_action_url(self, action, instance, **kwargs):
         """
         Generate a URL for the given action on the given instance
         """
         kw = self.get_action_route_kwargs(instance)
         kw.update(kwargs)
         route_prefix = self.get_route_prefix()
-        if mobile:
-            route_prefix = 'mobile.{}'.format(route_prefix)
         return self.request.route_url('{}.{}'.format(route_prefix, action), **kw)
 
     def get_help_url(self):
@@ -2319,12 +2592,116 @@ class MasterView(View):
         so if you like you can return a different help URL depending on which
         type of CRUD view is in effect, etc.
         """
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
+        route_prefix = self.get_route_prefix()
+
+        info = session.query(model.TailbonePageHelp)\
+                      .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
+                      .first()
+        if info and info.help_url:
+            return info.help_url
+
         if self.help_url:
             return self.help_url
 
+        if self.default_help_url:
+            return self.default_help_url
+
         return global_help_url(self.rattail_config)
 
-    def render_to_response(self, template, data, mobile=False):
+    def get_help_markdown(self):
+        """
+        Return the markdown help text for current page, if defined.
+        """
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
+        route_prefix = self.get_route_prefix()
+
+        info = session.query(model.TailbonePageHelp)\
+                      .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
+                      .first()
+        if info and info.markdown_text:
+            return info.markdown_text
+
+    def can_edit_help(self):
+        if self.has_perm('edit_help'):
+            return True
+        if self.request.has_perm('common.edit_help'):
+            return True
+        return False
+
+    def edit_help(self):
+        if not self.can_edit_help():
+            raise self.forbidden()
+
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
+        route_prefix = self.get_route_prefix()
+        schema = colander.Schema()
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='help_url',
+                                       missing=None))
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='markdown_text',
+                                       missing=None))
+
+        factory = self.get_form_factory()
+        form = factory(schema=schema, request=self.request)
+        if not form.validate():
+            return {'error': "Form did not validate"}
+
+        info = session.query(model.TailbonePageHelp)\
+                      .filter(model.TailbonePageHelp.route_prefix == route_prefix)\
+                      .first()
+        if not info:
+            info = model.TailbonePageHelp(route_prefix=route_prefix)
+            session.add(info)
+
+        info.help_url = form.validated['help_url']
+        info.markdown_text = form.validated['markdown_text']
+        return {'ok': True}
+
+    def edit_field_help(self):
+        if not self.can_edit_help():
+            raise self.forbidden()
+
+        # nb. self.Session may differ, so use tailbone.db.Session
+        session = Session()
+        model = self.app.model
+        route_prefix = self.get_route_prefix()
+        schema = colander.Schema()
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='field_name'))
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='markdown_text',
+                                       missing=None))
+
+        factory = self.get_form_factory()
+        form = factory(schema=schema, request=self.request)
+        if not form.validate():
+            return {'error': "Form did not validate"}
+
+        info = session.query(model.TailboneFieldInfo)\
+                      .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\
+                      .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\
+                      .first()
+        if not info:
+            info = model.TailboneFieldInfo(route_prefix=route_prefix,
+                                           field_name=form.validated['field_name'])
+            session.add(info)
+
+        info.markdown_text = form.validated['markdown_text']
+        return {'ok': True}
+
+    def render_to_response(self, template, data, **kwargs):
         """
         Return a response with the given template rendered with the given data.
         Note that ``template`` must only be a "key" (e.g. 'index' or 'view').
@@ -2334,60 +2711,64 @@ class MasterView(View):
         """
         context = {
             'master': self,
-            'use_buefy': self.get_use_buefy(),
-            'mobile': mobile,
             'model_title': self.get_model_title(),
             'model_title_plural': self.get_model_title_plural(),
             'route_prefix': self.get_route_prefix(),
             'permission_prefix': self.get_permission_prefix(),
             'index_title': self.get_index_title(),
-            'index_url': self.get_index_url(mobile=mobile),
+            'index_url': self.get_index_url(),
+            'config_title': self.get_config_title(),
             'action_url': self.get_action_url,
             'grid_index': self.grid_index,
             'help_url': self.get_help_url(),
+            'help_markdown': self.get_help_markdown(),
+            'can_edit_help': self.can_edit_help(),
             'quickie': None,
         }
 
-        if self.expose_quickie_search:
+        context['customer_key_field'] = self.get_customer_key_field()
+        context['customer_key_label'] = self.get_customer_key_label()
+
+        context['member_key_field'] = self.get_member_key_field()
+        context['member_key_label'] = self.get_member_key_label()
+
+        context['product_key_field'] = self.get_product_key_field()
+        context['product_key_label'] = self.get_product_key_label()
+
+        if self.should_expose_quickie_search():
             context['quickie'] = self.get_quickie_context()
 
         if self.grid_index:
             context['grid_count'] = self.grid_count
 
         if self.has_rows:
+            context['rows_title'] = self.get_rows_title()
             context['row_permission_prefix'] = self.get_row_permission_prefix()
             context['row_model_title'] = self.get_row_model_title()
             context['row_model_title_plural'] = self.get_row_model_title_plural()
             context['row_action_url'] = self.get_row_action_url
 
-            if mobile and self.viewing and self.mobile_rows_quickable:
-
-                # quick row does *not* mimic keyboard wedge by default, but can
-                context['quick_row_keyboard_wedge'] = False
-
-                # quick row does *not* use autocomplete by default, but can
-                context['quick_row_autocomplete'] = False
-                context['quick_row_autocomplete_url'] = '#'
-
         context.update(data)
         context.update(self.template_kwargs(**context))
-        if hasattr(self, 'template_kwargs_{}'.format(template)):
-            context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context))
-        if mobile and hasattr(self, 'mobile_template_kwargs_{}'.format(template)):
-            context.update(getattr(self, 'mobile_template_kwargs_{}'.format(template))(**context))
+
+        method_name = f'template_kwargs_{template}'
+        if hasattr(self, method_name):
+            context.update(getattr(self, method_name)(**context))
+        for supp in self.iter_view_supplements():
+            if hasattr(supp, 'template_kwargs'):
+                context.update(getattr(supp, 'template_kwargs')(**context))
+            if hasattr(supp, method_name):
+                context.update(getattr(supp, method_name)(**context))
 
         # First try the template path most specific to the view.
-        if mobile:
-            mako_path = '/mobile{}/{}.mako'.format(self.get_template_prefix(), template)
-        else:
-            mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template)
+        mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template)
         try:
             return render_to_response(mako_path, context, request=self.request)
 
         except IOError:
 
             # Failing that, try one or more fallback templates.
-            for fallback in self.get_fallback_templates(template, mobile=mobile):
+            for fallback in self.get_fallback_templates(template):
                 try:
                     return render_to_response(fallback, context, request=self.request)
                 except IOError:
@@ -2434,17 +2815,25 @@ class MasterView(View):
             return render('{}/{}.mako'.format(self.get_template_prefix(), template),
                           context, request=self.request)
 
-    def get_fallback_templates(self, template, mobile=False):
-        if mobile:
-            return ['/mobile/master/{}.mako'.format(template)]
+    def get_fallback_templates(self, template, **kwargs):
         return ['/master/{}.mako'.format(template)]
 
+    def get_default_engine_dbkey(self):
+        """
+        Returns the "default" engine dbkey.
+        """
+        return self.rattail_config.get(
+            'tailbone',
+            'engines.{}.pretend_default'.format(self.engine_type_key),
+            default='default')
+
     def get_current_engine_dbkey(self):
         """
         Returns the "current" engine's dbkey, for the current user.
         """
+        default = self.get_default_engine_dbkey()
         return self.request.session.get('tailbone.engines.{}.current'.format(self.engine_type_key),
-                                        'default')
+                                        default)
 
     def template_kwargs(self, **kwargs):
         """
@@ -2454,19 +2843,370 @@ class MasterView(View):
         kwargs['expose_db_picker'] = False
         if self.supports_multiple_engines:
 
-            # view declares support for multiple engines, but we only want to
-            # show the picker if we have more than one engine configured
-            engines = self.get_db_engines()
-            if len(engines) > 1:
+            # DB picker is only shown for permissioned users
+            if self.request.has_perm('common.change_db_engine'):
 
-                # user session determines "current" db engine *of this type*
-                # (note that many master views may declare the same type, and
-                # would therefore share the "current" engine)
-                selected = self.get_current_engine_dbkey()
-                kwargs['expose_db_picker'] = True
-                kwargs['db_picker_options'] = [tags.Option(k) for k in engines]
-                kwargs['db_picker_selected'] = selected
+                # view declares support for multiple engines, but we only want to
+                # show the picker if we have more than one engine configured
+                engines = self.get_db_engines()
+                if len(engines) > 1:
 
+                    # user session determines "current" db engine *of this type*
+                    # (note that many master views may declare the same type, and
+                    # would therefore share the "current" engine)
+                    selected = self.get_current_engine_dbkey()
+                    kwargs['expose_db_picker'] = True
+                    kwargs['db_picker_options'] = [tags.Option(k, value=k) for k in engines]
+                    kwargs['db_picker_selected'] = selected
+
+        # context menu
+        obj = kwargs.get('instance')
+        items = self.get_context_menu_items(obj)
+        for supp in self.iter_view_supplements():
+            items.extend(supp.get_context_menu_items(obj) or [])
+        kwargs['context_menu_list_items'] = items
+
+        # add info for downloadable input file templates, if any
+        if self.has_input_file_templates:
+            templates = self.normalize_input_file_templates()
+            kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
+                                                          for tmpl in templates])
+
+        # add info for downloadable output file templates, if any
+        if self.has_output_file_templates:
+            templates = self.normalize_output_file_templates()
+            kwargs['output_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
+                                                           for tmpl in templates])
+
+        return kwargs
+
+    def get_input_file_templates(self):
+        return []
+
+    def normalize_input_file_templates(self, templates=None,
+                                       include_file_options=False):
+        if templates is None:
+            templates = self.get_input_file_templates()
+
+        route_prefix = self.get_route_prefix()
+
+        if include_file_options:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'input_files',
+                                        route_prefix)
+
+        for template in templates:
+
+            if 'config_section' not in template:
+                template['config_section'] = self.input_file_template_config_section
+            section = template['config_section']
+
+            if 'config_prefix' not in template:
+                template['config_prefix'] = '{}.{}'.format(
+                    self.input_file_template_config_prefix,
+                    template['key'])
+            prefix = template['config_prefix']
+
+            for key in ('mode', 'file', 'url'):
+
+                if 'option_{}'.format(key) not in template:
+                    template['option_{}'.format(key)] = '{}.{}'.format(prefix, key)
+
+                if 'setting_{}'.format(key) not in template:
+                    template['setting_{}'.format(key)] = '{}.{}'.format(
+                        section,
+                        template['option_{}'.format(key)])
+
+                if key not in template:
+                    value = self.rattail_config.get(
+                        section,
+                        template['option_{}'.format(key)])
+                    if value is not None:
+                        template[key] = value
+
+            template.setdefault('mode', 'default')
+            template.setdefault('file', None)
+            template.setdefault('url', template['default_url'])
+
+            if include_file_options:
+                options = []
+                basedir = os.path.join(templatesdir, template['key'])
+                if os.path.exists(basedir):
+                    for name in sorted(os.listdir(basedir)):
+                        if len(name) == 4 and name.isdigit():
+                            files = os.listdir(os.path.join(basedir, name))
+                            if len(files) == 1:
+                                options.append(os.path.join(name, files[0]))
+                template['file_options'] = options
+                template['file_options_dir'] = basedir
+
+            if template['mode'] == 'external':
+                template['effective_url'] = template['url']
+            elif template['mode'] == 'hosted':
+                template['effective_url'] = self.request.route_url(
+                    '{}.download_input_file_template'.format(route_prefix),
+                    _query={'key': template['key'],
+                            'file': template['file']})
+            else:
+                template['effective_url'] = template['default_url']
+
+        return templates
+
+    def get_output_file_templates(self):
+        return []
+
+    def normalize_output_file_templates(self, templates=None,
+                                        include_file_options=False):
+        if templates is None:
+            templates = self.get_output_file_templates()
+
+        route_prefix = self.get_route_prefix()
+
+        if include_file_options:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'output_files',
+                                        route_prefix)
+
+        for template in templates:
+
+            if 'config_section' not in template:
+                if hasattr(self, 'output_file_template_config_section'):
+                    template['config_section'] = self.output_file_template_config_section
+                else:
+                    template['config_section'] = route_prefix
+            section = template['config_section']
+
+            if 'config_prefix' not in template:
+                template['config_prefix'] = '{}.{}'.format(
+                    self.output_file_template_config_prefix,
+                    template['key'])
+            prefix = template['config_prefix']
+
+            for key in ('mode', 'file', 'url'):
+
+                if 'option_{}'.format(key) not in template:
+                    template['option_{}'.format(key)] = '{}.{}'.format(prefix, key)
+
+                if 'setting_{}'.format(key) not in template:
+                    template['setting_{}'.format(key)] = '{}.{}'.format(
+                        section,
+                        template['option_{}'.format(key)])
+
+                if key not in template:
+                    value = self.rattail_config.get(
+                        section,
+                        template['option_{}'.format(key)])
+                    if value is not None:
+                        template[key] = value
+
+            template.setdefault('mode', 'default')
+            template.setdefault('file', None)
+            template.setdefault('url', template['default_url'])
+
+            if include_file_options:
+                options = []
+                basedir = os.path.join(templatesdir, template['key'])
+                if os.path.exists(basedir):
+                    for name in sorted(os.listdir(basedir)):
+                        if len(name) == 4 and name.isdigit():
+                            files = os.listdir(os.path.join(basedir, name))
+                            if len(files) == 1:
+                                options.append(os.path.join(name, files[0]))
+                template['file_options'] = options
+                template['file_options_dir'] = basedir
+
+            if template['mode'] == 'external':
+                template['effective_url'] = template['url']
+            elif template['mode'] == 'hosted':
+                template['effective_url'] = self.request.route_url(
+                    '{}.download_output_file_template'.format(route_prefix),
+                    _query={'key': template['key'],
+                            'file': template['file']})
+            else:
+                template['effective_url'] = template['default_url']
+
+        return templates
+
+    def template_kwargs_index(self, **kwargs):
+        """
+        Method stub, so subclass can always invoke super() for it.
+        """
+        return kwargs
+
+    def template_kwargs_create(self, **kwargs):
+        """
+        Method stub, so subclass can always invoke super() for it.
+        """
+        return kwargs
+
+    def template_kwargs_clone(self, **kwargs):
+        """
+        Method stub, so subclass can always invoke super() for it.
+        """
+        return kwargs
+
+    def template_kwargs_view(self, **kwargs):
+        """
+        Method stub, so subclass can always invoke super() for it.
+        """
+        obj = kwargs['instance']
+        kwargs['xref_buttons'] = self.get_xref_buttons(obj)
+        kwargs['xref_links'] = self.get_xref_links(obj)
+        return kwargs
+
+    def get_context_menu_items(self, obj=None):
+        items = []
+        route_prefix = self.get_route_prefix()
+
+        if self.listing:
+
+            if self.results_downloadable_csv and self.has_perm('results_csv'):
+                url = self.request.route_url(f'{route_prefix}.results_csv')
+                items.append(tags.link_to("Download results as CSV", url))
+
+            if self.results_downloadable_xlsx and self.has_perm('results_xlsx'):
+                url = self.request.route_url(f'{route_prefix}.results_xlsx')
+                items.append(tags.link_to("Download results as XLSX", url))
+
+            if self.has_input_file_templates and self.has_perm('create'):
+                templates = self.normalize_input_file_templates()
+                for template in templates:
+                    items.append(tags.link_to(f"Download {template['label']} Template",
+                                              template['effective_url']))
+
+            if self.has_output_file_templates and self.has_perm('configure'):
+                templates = self.normalize_output_file_templates()
+                for template in templates:
+                    items.append(tags.link_to(f"Download {template['label']} Template",
+                                              template['effective_url']))
+
+        # if self.viewing:
+
+        #     # # TODO: either make this configurable, or just lose it.
+        #     # # nobody seems to ever find it useful in practice.
+        #     # url = self.get_action_url('view', instance)
+        #     # items.append(tags.link_to(f"Permalink for this {model_title}", url))
+
+        return items
+
+    def get_xref_buttons(self, obj):
+        buttons = []
+        for supp in self.iter_view_supplements():
+            buttons.extend(supp.get_xref_buttons(obj) or [])
+        buttons = self.normalize_xref_buttons(buttons)
+        return buttons
+
+    def normalize_xref_buttons(self, buttons):
+        normal = []
+        for button in buttons:
+
+            # build a button if only given the data
+            if isinstance(button, dict):
+                button = self.make_xref_button(**button)
+
+            normal.append(button)
+        return normal
+
+    def make_button(self, label,
+                    type=None, is_primary=False,
+                    url=None, target=None, is_external=False,
+                    icon_left=None,
+                    **kwargs):
+        """
+        Make and return a HTML ``<b-button>`` literal.
+        """
+        btn_kw = kwargs
+        btn_kw.setdefault('c', label)
+        btn_kw.setdefault('icon_pack', 'fas')
+
+        if type:
+            btn_kw['type'] = type
+        elif is_primary:
+            btn_kw['type'] = 'is-primary'
+
+        if icon_left:
+            btn_kw['icon_left'] = icon_left
+        elif is_external:
+            btn_kw['icon_left'] = 'external-link-alt'
+        elif url:
+            btn_kw['icon_left'] = 'eye'
+
+        if url:
+            btn_kw['href'] = url
+
+            if target:
+                btn_kw['target'] = target
+            elif is_external:
+                btn_kw['target'] = '_blank'
+
+        button = HTML.tag('b-button', **btn_kw)
+
+        if url:
+            # nb. unfortunately HTML.tag() calls its first arg 'tag' and
+            # so we can't pass a kwarg with that name...so instead we
+            # patch that into place manually
+            button = str(button)
+            button = button.replace('<b-button ',
+                                    '<b-button tag="a"')
+            button = HTML.literal(button)
+
+        return button
+
+    def make_xref_button(self, **kwargs):
+        """
+        Make and return a HTML ``<b-button>`` literal, for display in
+        the cross-reference helper panel.
+
+        :param url: URL for the link.
+        :param text: Label for the button.
+        :param internal: Boolean indicating if the link is internal to
+           the site.  This is false by default, meaning the link is
+           assumed to be external, which affects the icon and causes
+           button click to open link in a new tab.
+        """
+        # TODO: this should call make_button()
+
+        # nb. unfortunately HTML.tag() calls its first arg 'tag' and
+        # so we can't pass a kwarg with that name...so instead we
+        # patch that into place manually
+        btn_kw = dict(type='is-primary',
+                      href=kwargs['url'],
+                      icon_pack='fas',
+                      c=kwargs['text'])
+        if kwargs.get('internal'):
+            btn_kw['icon_left'] = 'eye'
+        else:
+            btn_kw['icon_left'] = 'external-link-alt'
+            btn_kw['target'] = '_blank'
+        button = HTML.tag('b-button', **btn_kw)
+        button = str(button)
+        button = button.replace('<b-button ',
+                                '<b-button tag="a" ')
+        button = HTML.literal(button)
+        return button
+
+    def get_xref_links(self, obj):
+        links = []
+        for supp in self.iter_view_supplements():
+            links.extend(supp.get_xref_links(obj) or [])
+        return links
+
+    def template_kwargs_edit(self, **kwargs):
+        """
+        Method stub, so subclass can always invoke super() for it.
+        """
+        return kwargs
+
+    def template_kwargs_delete(self, **kwargs):
+        """
+        Method stub, so subclass can always invoke super() for it.
+        """
+        return kwargs
+
+    def template_kwargs_view_row(self, **kwargs):
+        """
+        Method stub, so subclass can always invoke super() for it.
+        """
         return kwargs
 
     def get_db_engines(self):
@@ -2505,6 +3245,11 @@ class MasterView(View):
         return key
 
     def get_grid_actions(self):
+        """ """
+        warnings.warn("get_grid_actions() method is deprecated; "
+                      "please use get_main_actions() or get_more_actions() instead",
+                      DeprecationWarning, stacklevel=2)
+
         main, more = self.get_main_actions(), self.get_more_actions()
         if len(more) == 1:
             main, more = main + more, []
@@ -2546,10 +3291,11 @@ class MasterView(View):
         return actions
 
     def make_grid_action_view(self):
-        use_buefy = self.get_use_buefy()
-        url = self.get_view_index_url if self.use_index_links else None
-        icon = 'eye' if use_buefy else 'zoomin'
-        return self.make_action('view', icon=icon, url=url)
+        return self.make_action('view', icon='eye', url=self.default_view_url())
+
+    def default_view_url(self):
+        if self.use_index_links:
+            return self.get_view_index_url
 
     def get_view_index_url(self, row, i):
         route = '{}.view_index'.format(self.get_route_prefix())
@@ -2572,25 +3318,35 @@ class MasterView(View):
         return actions
 
     def make_grid_action_edit(self):
-        use_buefy = self.get_use_buefy()
-        icon = 'edit' if use_buefy else 'pencil'
-        return self.make_action('edit', icon=icon, url=self.default_edit_url)
+        return self.make_action('edit', icon='edit', url=self.default_edit_url)
 
     def make_grid_action_clone(self):
         return self.make_action('clone', icon='object-ungroup',
                                 url=self.default_clone_url)
 
     def make_grid_action_delete(self):
-        use_buefy = self.get_use_buefy()
-        kwargs = {}
-        if use_buefy and self.delete_confirm == 'simple':
+        kwargs = {'link_class': 'has-text-danger'}
+        if self.delete_confirm == 'simple':
             kwargs['click_handler'] = 'deleteObject'
         return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs)
 
-    def default_edit_url(self, row, i=None):
-        if self.editable_instance(row):
+    def default_edit_url(self, obj, i=None):
+        """
+        Return the default "edit" URL for the given object, if
+        applicable.  This first checks :meth:`editable_instance()` for
+        the object, and will only return a URL if the object is deemed
+        editable.
+
+        :param obj: A top-level record/object, of the type normally
+           handled by this master view.
+
+        :param i: Optional row index within a grid.
+
+        :returns: The "edit object" URL as string, or ``None``.
+        """
+        if self.editable_instance(obj):
             return self.request.route_url('{}.edit'.format(self.get_route_prefix()),
-                                          **self.get_action_route_kwargs(row))
+                                          **self.get_action_route_kwargs(obj))
 
     def default_clone_url(self, row, i=None):
         return self.request.route_url('{}.clone'.format(self.get_route_prefix()),
@@ -2601,27 +3357,76 @@ class MasterView(View):
             return self.request.route_url('{}.delete'.format(self.get_route_prefix()),
                                           **self.get_action_route_kwargs(row))
 
-    def make_action(self, key, url=None, **kwargs):
+    def make_action(self, key, url=None, factory=None, **kwargs):
         """
-        Make a new :class:`GridAction` instance for the current grid.
+        Make and return a new :class:`~tailbone.grids.core.GridAction`
+        instance.
+
+        This can be called to make actions for any grid, not just the
+        one from :meth:`index()`.
         """
         if url is None:
             route = '{}.{}'.format(self.get_route_prefix(), key)
             url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r))
-        return grids.GridAction(key, url=url, **kwargs)
+        if not factory:
+            factory = grids.GridAction
+        return factory(self.request, key, url=url, **kwargs)
 
-    def get_action_route_kwargs(self, row):
+    def get_action_route_kwargs(self, obj):
         """
-        Hopefully generic kwarg generator for basic action routes.
+        Get a dict of route kwargs for the given object.
+
+        This is called from various other "convenience" URL
+        generators, e.g. :meth:`default_edit_url()`.
+
+        It inspects the given object, as well as the "model key" (as
+        returned by :meth:`get_model_key()`), and returns a dict of
+        appropriate route kwargs for the object.
+
+        Most typically, the model key is just ``uuid`` and so this
+        would effectively return ``{'uuid': obj.uuid}``.
+
+        But composite model keys are supported too, so if the model
+        key is ``(parent_id, child_id)`` this might instead return
+        ``{'parent_id': obj.parent_id, 'child_id': obj.child_id}``.
+
+        Such kwargs would then be fed into ``route_url()`` as needed,
+        for example to get a "view product URL"::
+
+           kw = self.get_action_route_kwargs(product)
+           url = self.request.route_url('products.view', **kw)
+
+        :param obj: A top-level record/object, of the type normally
+           handled by this master view.
+
+        :returns: A dict of route kwargs for the object.
         """
+        keys = self.get_model_key(as_tuple=True)
+        if keys:
+            try:
+                return dict([(key, obj[key])
+                             for key in keys])
+            except TypeError:
+                return dict([(key, getattr(obj, key))
+                             for key in keys])
+
+        # TODO: sanity check, is the above all we need..?
+        log.warning("yes we still do the code below sometimes")
+
         try:
-            mapper = orm.object_mapper(row)
+            mapper = orm.object_mapper(obj)
         except orm.exc.UnmappedInstanceError:
-            return {self.model_key: row[self.model_key]}
+            try:
+                if isinstance(self.model_key, str):
+                    return {self.model_key: obj[self.model_key]}
+                return dict([(key, obj[key])
+                             for key in self.model_key])
+            except TypeError:
+                return {self.model_key: getattr(obj, self.model_key)}
         else:
-            pkeys = get_primary_keys(row)
+            pkeys = get_primary_keys(obj)
             keys = list(pkeys)
-            values = [getattr(row, k) for k in keys]
+            values = [getattr(obj, k) for k in keys]
             return dict(zip(keys, values))
 
     def get_data(self, session=None):
@@ -2654,11 +3459,15 @@ class MasterView(View):
             if not self.has_perm('view_global'):
                 query = query.filter(model_class.local_only == True)
 
+        for supp in self.iter_view_supplements():
+            query = supp.get_grid_query(query)
+
         return query
 
     def get_effective_query(self, session=None, **kwargs):
         return self.get_effective_data(session=session, **kwargs)
 
+    # TODO: should rename to checkable?
     def checkbox(self, instance):
         """
         Returns a boolean indicating whether ot not a checkbox should be
@@ -2675,61 +3484,466 @@ class MasterView(View):
         """
         return False
 
+    def download_results_path(self, user_uuid, filename=None,
+                              typ='results', makedirs=False):
+        """
+        Returns an absolute path for the "results" data file, specific to the
+        given user UUID.
+        """
+        route_prefix = self.get_route_prefix()
+        path = os.path.join(self.rattail_config.datadir(), 'downloads',
+                            typ, route_prefix,
+                            user_uuid[:2], user_uuid[2:])
+        if makedirs and not os.path.exists(path):
+            os.makedirs(path)
+
+        if filename:
+            path = os.path.join(path, filename)
+        return path
+
+    def download_results_filename(self, fmt):
+        """
+        Must return an appropriate "download results" filename for the given
+        format.  E.g. ``'products.csv'``
+        """
+        route_prefix = self.get_route_prefix()
+        if fmt == 'csv':
+            return '{}.csv'.format(route_prefix)
+        if fmt == 'xlsx':
+            return '{}.xlsx'.format(route_prefix)
+
+    def download_results_supported_formats(self):
+        # TODO: default formats should be configurable?
+        return OrderedDict([
+            ('xlsx', "Excel (XLSX)"),
+            ('csv', "CSV"),
+        ])
+
+    def download_results_default_format(self):
+        # TODO: default format should be configurable
+        return 'xlsx'
+
+    def download_results(self):
+        """
+        View for saving current (filtered) data results into a file, and
+        downloading that file.
+        """
+        route_prefix = self.get_route_prefix()
+        user_uuid = self.request.user.uuid
+
+        # POST means generate a new results file for download
+        if self.request.method == 'POST':
+
+            # make sure a valid format was requested
+            supported = self.download_results_supported_formats()
+            if not supported:
+                self.request.session.flash("There are no supported download formats!",
+                                           'error')
+                return self.redirect(self.get_index_url())
+            fmt = self.request.POST.get('fmt')
+            if not fmt:
+                fmt = self.download_results_default_format() or list(supported)[0]
+            if fmt not in supported:
+                self.request.session.flash("Unsupported download format: {}".format(fmt),
+                                           'error')
+                return self.redirect(self.get_index_url())
+
+            # parse field list if one was given
+            fields = self.request.POST.get('fields')
+            if fields:
+                fields = fields.split(',')
+
+            # start thread to actually do work / report progress
+            key = '{}.download_results'.format(route_prefix)
+            progress = self.make_progress(key)
+            results = self.get_effective_data()
+            thread = Thread(target=self.download_results_thread,
+                            args=(results, fmt, fields, user_uuid, progress))
+            thread.start()
+
+            # show user the progress page
+            return self.render_progress(progress, {
+                'cancel_url': self.get_index_url(),
+                'cancel_msg': "Download was canceled.",
+            })
+
+        # not POST, so just download a file (if specified)
+        filename = self.request.GET.get('filename')
+        if not filename:
+            return self.redirect(self.get_index_url())
+        path = self.download_results_path(user_uuid, filename)
+        return self.file_response(path)
+
+    def download_results_thread(self, results, fmt, fields, user_uuid, progress):
+        """
+        Thread target, which invokes :meth:`download_results_generate()` to
+        officially generate the data file which is then to be downloaded.
+        """
+        route_prefix = self.get_route_prefix()
+        session = self.make_isolated_session()
+        try:
+
+            # create folder(s) for output; make sure file doesn't exist
+            filename = self.download_results_filename(fmt)
+            path = self.download_results_path(user_uuid, filename, makedirs=True)
+            if os.path.exists(path):
+                os.remove(path)
+
+            # generate file for download
+            results = results.with_session(session)
+            self.download_results_setup(fields, progress=progress)
+            self.download_results_generate(session, results, path, fmt, fields,
+                                           progress=progress)
+
+            session.commit()
+
+        except Exception as error:
+            msg = "failed to generate results file for download!"
+            log.warning(msg, exc_info=True)
+            session.rollback()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = "{}: {}".format(
+                    msg, simple_error(error))
+                progress.session.save()
+            return
+
+        finally:
+            session.close()
+
+        if progress:
+            progress.session.load()
+            progress.session['complete'] = True
+            progress.session['success_url'] = self.get_index_url()
+            progress.session['extra_session_bits'] = {
+                '{}.results.generated'.format(route_prefix): path,
+            }
+            progress.session.save()
+
+    def download_results_setup(self, fields, progress=None):
+        """
+        Perform any up-front caching or other setup required, just prior to
+        generating a new results data file for download.
+        """
+
+    def download_results_generate(self, session, results, path, fmt, fields, progress=None):
+        """
+        This method is responsible for actually generating the data file for a
+        "download results" operation, according to the given params.
+        """
+        if fmt == 'csv':
+
+            csv_file = open(path, 'wt', encoding='utf_8')
+            writer = csv.DictWriter(csv_file, fields)
+            writer.writeheader()
+
+            def write(obj, i):
+                data = self.download_results_normalize(obj, fields, fmt=fmt)
+                csvrow = self.download_results_coerce_csv(data, fields)
+                writer.writerow(csvrow)
+
+            self.progress_loop(write, results.all(), progress,
+                               message="Writing data to CSV file")
+            csv_file.close()
+
+        elif fmt == 'xlsx':
+
+            writer = ExcelWriter(path, fields,
+                                 sheet_title=self.get_model_title_plural())
+            writer.write_header()
+
+            xlrows = []
+            def write(obj, i):
+                data = self.download_results_normalize(obj, fields, fmt=fmt)
+                row = self.download_results_coerce_xlsx(data, fields)
+                xlrow = [row[field] for field in fields]
+                xlrows.append(xlrow)
+
+            self.progress_loop(write, results.all(), progress,
+                               message="Collecting data for Excel")
+
+            def finalize(x, i):
+                writer.write_rows(xlrows)
+                writer.auto_freeze()
+                writer.auto_filter()
+                writer.auto_resize()
+                writer.save()
+
+            self.progress_loop(finalize, [1], progress,
+                               message="Writing Excel file to disk")
+
+    def download_results_fields_available(self, **kwargs):
+        """
+        Return the list of fields which are *available* to be written to
+        download file.  Default field list will be constructed from the
+        underlying table columns.
+        """
+        fields = []
+        mapper = orm.class_mapper(self.model_class)
+        for prop in mapper.iterate_properties:
+            if isinstance(prop, orm.ColumnProperty):
+                fields.append(prop.key)
+        return fields
+
+    def download_results_fields_default(self, fields, **kwargs):
+        """
+        Return the default list of fields to be written to download file.
+        Unless you override, all "available" fields will be included by
+        default.
+        """
+        return fields
+
+    def download_results_normalize(self, obj, fields, **kwargs):
+        """
+        Normalize the given object into a data dict, for use when writing to
+        the results file for download.
+        """
+        app = self.get_rattail_app()
+        data = {}
+        for field in fields:
+            value = getattr(obj, field, None)
+
+            # make timestamps zone-aware
+            if isinstance(value, datetime.datetime):
+                value = app.localtime(value, from_utc=not self.has_local_times)
+
+            data[field] = value
+
+        return data
+
+    def download_results_coerce_csv(self, data, fields, **kwargs):
+        """
+        Coerce the given data dict record, to a "row" dict suitable for use
+        when writing directly to CSV file.  Each value in the dict should be a
+        string type.
+        """
+        csvrow = dict(data)
+        for field in fields:
+            value = csvrow.get(field)
+
+            if value is None:
+                value = ''
+            else:
+                value = str(value)
+
+            csvrow[field] = value
+
+        return csvrow
+
+    def download_results_coerce_xlsx(self, data, fields, **kwargs):
+        """
+        Coerce the given data dict record, to a "row" dict suitable for use
+        when writing directly to XLSX file.
+        """
+        app = self.get_rattail_app()
+        data = dict(data)
+        for key in data:
+            value = data[key]
+
+            # make timestamps local, "zone-naive"
+            if isinstance(value, datetime.datetime):
+                value = app.localtime(value, tzinfo=False)
+
+            data[key] = value
+
+        return data
+
     def results_csv(self):
         """
-        Download current list results as CSV
+        Download current list results as CSV.
         """
         results = self.get_effective_data()
-        fields = self.get_csv_fields()
-        data = six.StringIO()
-        writer = UnicodeDictWriter(data, fields)
-        writer.writeheader()
-        for obj in results:
-            writer.writerow(self.get_csv_row(obj, fields))
-        response = self.request.response
-        if six.PY3:
-            response.text = data.getvalue()
-            response.content_type = 'text/csv'
-            response.content_disposition = 'attachment; filename={}.csv'.format(self.get_grid_key())
-        else:
-            response.body = data.getvalue()
-            response.content_type = b'text/csv'
-            response.content_disposition = b'attachment; filename={}.csv'.format(self.get_grid_key())
-        data.close()
-        response.content_length = len(response.body)
-        return response
+
+        # start thread to actually do work / generate progress data
+        route_prefix = self.get_route_prefix()
+        key = '{}.results_csv'.format(route_prefix)
+        progress = self.make_progress(key)
+        thread = Thread(target=self.results_csv_thread,
+                        args=(results, self.request.user.uuid, progress))
+        thread.start()
+
+        # send user to progress page
+        return self.render_progress(progress, {
+            'cancel_url': self.get_index_url(),
+            'cancel_msg': "CSV download was canceled.",
+        })
+
+    def results_csv_session(self):
+        return self.make_isolated_session()
+
+    def results_csv_thread(self, results, user_uuid, progress):
+        """
+        Thread target, responsible for actually generating the CSV file which
+        is to be presented for download.
+        """
+        route_prefix = self.get_route_prefix()
+        session = self.results_csv_session()
+        try:
+
+            # create folder(s) for output; make sure file doesn't exist
+            path = os.path.join(self.rattail_config.datadir(), 'downloads',
+                                'results-csv', route_prefix,
+                                user_uuid[:2], user_uuid[2:])
+            if not os.path.exists(path):
+                os.makedirs(path)
+            path = os.path.join(path, '{}.csv'.format(route_prefix))
+            if os.path.exists(path):
+                os.remove(path)
+
+            results = results.with_session(session).all()
+            fields = self.get_csv_fields()
+
+            csv_file = open(path, 'wt', encoding='utf_8')
+            writer = csv.DictWriter(csv_file, fields)
+            writer.writeheader()
+
+            def write(obj, i):
+                writer.writerow(self.get_csv_row(obj, fields))
+
+            self.progress_loop(write, results, progress,
+                               message="Collecting data for CSV")
+            csv_file.close()
+            session.commit()
+
+        except Exception as error:
+            msg = "generating CSV file for download failed!"
+            log.warning(msg, exc_info=True)
+            session.rollback()
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = "{}: {}".format(
+                    msg, simple_error(error))
+                progress.session.save()
+            return
+
+        finally:
+            session.close()
+
+        if progress:
+            progress.session.load()
+            progress.session['complete'] = True
+            progress.session['success_url'] = self.get_index_url()
+            progress.session['extra_session_bits'] = {
+                '{}.results_csv.generated'.format(route_prefix): True,
+            }
+            progress.session.save()
+
+    def results_csv_download(self):
+        route_prefix = self.get_route_prefix()
+        user_uuid = self.request.user.uuid
+        path = os.path.join(self.rattail_config.datadir(), 'downloads',
+                            'results-csv', route_prefix,
+                            user_uuid[:2], user_uuid[2:],
+                            '{}.csv'.format(route_prefix))
+        return self.file_response(path)
 
     def results_xlsx(self):
         """
         Download current list results as XLSX.
         """
         results = self.get_effective_data()
-        fields = self.get_xlsx_fields()
-        path = temp_path(suffix='.xlsx')
+
+        # start thread to actually do work / generate progress data
+        route_prefix = self.get_route_prefix()
+        key = '{}.results_xlsx'.format(route_prefix)
+        progress = self.make_progress(key)
+        thread = Thread(target=self.results_xlsx_thread,
+                        args=(results, self.request.user.uuid, progress))
+        thread.start()
+
+        # send user to progress page
+        return self.render_progress(progress, {
+            'cancel_url': self.get_index_url(),
+            'cancel_msg': "XLSX download was canceled.",
+        })
+
+    def results_xlsx_session(self):
+        return self.make_isolated_session()
+
+    def results_write_xlsx(self, path, fields, results, session, progress=None):
         writer = ExcelWriter(path, fields, sheet_title=self.get_model_title_plural())
         writer.write_header()
 
         rows = []
-        for obj in results:
+        def write(obj, i):
             data = self.get_xlsx_row(obj, fields)
             row = [data[field] for field in fields]
             rows.append(row)
 
-        writer.write_rows(rows)
-        writer.auto_freeze()
-        writer.auto_filter()
-        writer.auto_resize()
-        writer.save()
+        self.progress_loop(write, results, progress,
+                           message="Collecting data for Excel")
 
-        response = self.request.response
-        with open(path, 'rb') as f:
-            response.body = f.read()
-        os.remove(path)
+        def finalize(x, i):
+            writer.write_rows(rows)
+            writer.auto_freeze()
+            writer.auto_filter()
+            writer.auto_resize()
+            writer.save()
 
-        response.content_length = len(response.body)
-        response.content_type = str('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
-        response.content_disposition = str('attachment; filename={}.xlsx').format(self.get_grid_key())
-        return response
+        self.progress_loop(finalize, [1], progress,
+                           message="Writing Excel file to disk")
+
+    def results_xlsx_thread(self, results, user_uuid, progress):
+        """
+        Thread target, responsible for actually generating the Excel file which
+        is to be presented for download.
+        """
+        route_prefix = self.get_route_prefix()
+        session = self.results_xlsx_session()
+        try:
+
+            # create folder(s) for output; make sure file doesn't exist
+            path = os.path.join(self.rattail_config.datadir(), 'downloads',
+                                'results-xlsx', route_prefix,
+                                user_uuid[:2], user_uuid[2:])
+            if not os.path.exists(path):
+                os.makedirs(path)
+            path = os.path.join(path, '{}.xlsx'.format(route_prefix))
+            if os.path.exists(path):
+                os.remove(path)
+
+            results = results.with_session(session).all()
+            fields = self.get_xlsx_fields()
+
+            # write output file
+            self.results_write_xlsx(path, fields, results, session, progress=progress)
+
+        except Exception as error:
+            msg = "generating XLSX file for download failed!"
+            log.warning(msg, exc_info=True)
+            session.rollback()
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = "{}: {}".format(
+                    msg, simple_error(error))
+                progress.session.save()
+            return
+
+        session.commit()
+        session.close()
+
+        if progress:
+            progress.session.load()
+            progress.session['complete'] = True
+            progress.session['success_url'] = self.get_index_url()
+            progress.session['extra_session_bits'] = {
+                '{}.results_xlsx.generated'.format(route_prefix): True,
+            }
+            progress.session.save()
+
+    def results_xlsx_download(self):
+        route_prefix = self.get_route_prefix()
+        user_uuid = self.request.user.uuid
+        path = os.path.join(self.rattail_config.datadir(), 'downloads',
+                            'results-xlsx', route_prefix,
+                            user_uuid[:2], user_uuid[2:],
+                            '{}.xlsx'.format(route_prefix))
+        return self.file_response(path)
 
     def get_xlsx_fields(self):
         """
@@ -2751,14 +3965,303 @@ class MasterView(View):
             row[field] = getattr(obj, field, None)
         return row
 
+    def download_results_rows_supported_formats(self):
+        # TODO: default formats should be configurable?
+        return OrderedDict([
+            ('xlsx', "Excel (XLSX)"),
+            ('csv', "CSV"),
+        ])
+
+    def download_results_rows_default_format(self):
+        # TODO: default format should be configurable
+        return 'xlsx'
+
+    def download_results_rows(self):
+        """
+        View for saving *rows* of current (filtered) data results into a file,
+        and downloading that file.
+        """
+        route_prefix = self.get_route_prefix()
+        user_uuid = self.request.user.uuid
+
+        # POST means generate a new results file for download
+        if self.request.method == 'POST':
+
+            # make sure a valid format was requested
+            supported = self.download_results_rows_supported_formats()
+            if not supported:
+                self.request.session.flash("There are no supported download formats!",
+                                           'error')
+                return self.redirect(self.get_index_url())
+            fmt = self.request.POST.get('fmt')
+            if not fmt:
+                fmt = self.download_results_rows_default_format() or list(supported)[0]
+            if fmt not in supported:
+                self.request.session.flash("Unsupported download format: {}".format(fmt),
+                                           'error')
+                return self.redirect(self.get_index_url())
+
+            # parse field list if one was given
+            fields = self.request.POST.get('fields')
+            if fields:
+                fields = fields.split(',')
+            if not fields:
+                if fmt == 'csv':
+                    fields = self.get_row_csv_fields()
+                elif fmt == 'xlsx':
+                    fields = self.get_row_xlsx_fields()
+                else:
+                    self.request.session.flash("No fields were specified", 'error')
+                    return self.redirect(self.get_index_url())
+
+            # start thread to actually do work / report progress
+            key = '{}.download_results_rows'.format(route_prefix)
+            progress = self.make_progress(key)
+            results = self.get_effective_data()
+            thread = Thread(target=self.download_results_rows_thread,
+                            args=(results, fmt, fields, user_uuid, progress))
+            thread.start()
+
+            # show user the progress page
+            return self.render_progress(progress, {
+                'cancel_url': self.get_index_url(),
+                'cancel_msg': "Download was canceled.",
+            })
+
+        # not POST, so just download a file (if specified)
+        filename = self.request.GET.get('filename')
+        if not filename:
+            return self.redirect(self.get_index_url())
+        path = self.download_results_rows_path(user_uuid, filename)
+        return self.file_response(path)
+
+    def download_results_rows_filename(self, fmt):
+        """
+        Must return an appropriate "download results" filename for the given
+        format.  E.g. ``'products.csv'``
+        """
+        route_prefix = self.get_route_prefix()
+        if fmt == 'csv':
+            return '{}.rows.csv'.format(route_prefix)
+        if fmt == 'xlsx':
+            return '{}.rows.xlsx'.format(route_prefix)
+
+    def download_results_rows_path(self, user_uuid, filename=None,
+                              typ='results', makedirs=False):
+        """
+        Returns an absolute path for the "results" data file, specific to the
+        given user UUID.
+        """
+        route_prefix = self.get_route_prefix()
+        path = os.path.join(self.rattail_config.datadir(), 'downloads',
+                            typ, route_prefix,
+                            user_uuid[:2], user_uuid[2:])
+        if makedirs and not os.path.exists(path):
+            os.makedirs(path)
+
+        if filename:
+            path = os.path.join(path, filename)
+        return path
+
+    def download_results_rows_fields_available(self, **kwargs):
+        """
+        Return the list of fields which are *available* to be written to
+        download file.  Default field list will be constructed from the
+        underlying table columns.
+        """
+        fields = []
+        mapper = orm.class_mapper(self.model_class)
+        for prop in mapper.iterate_properties:
+            if isinstance(prop, orm.ColumnProperty):
+                fields.append(prop.key)
+        return fields
+
+    def download_results_rows_fields_default(self, fields, **kwargs):
+        """
+        Return the default list of fields to be written to download file.
+        Unless you override, all "available" fields will be included by
+        default.
+        """
+        return fields
+
+    def download_results_rows_thread(self, results, fmt, fields, user_uuid, progress):
+        """
+        Thread target, which invokes :meth:`download_results_generate()` to
+        officially generate the data file which is then to be downloaded.
+        """
+        route_prefix = self.get_route_prefix()
+        session = self.make_isolated_session()
+        try:
+
+            # create folder(s) for output; make sure file doesn't exist
+            filename = self.download_results_rows_filename(fmt)
+            path = self.download_results_rows_path(user_uuid, filename, makedirs=True)
+            if os.path.exists(path):
+                os.remove(path)
+
+            # generate file for download
+            results = results.with_session(session).all()
+            self.download_results_rows_setup(fields, progress=progress)
+            self.download_results_rows_generate(session, results, path, fmt, fields,
+                                                progress=progress)
+
+            session.commit()
+
+        except Exception as error:
+            msg = "failed to generate results file for download!"
+            log.warning(msg, exc_info=True)
+            session.rollback()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = "{}: {}".format(
+                    msg, simple_error(error))
+                progress.session.save()
+            return
+
+        finally:
+            session.close()
+
+        if progress:
+            progress.session.load()
+            progress.session['complete'] = True
+            progress.session['success_url'] = self.get_index_url()
+            progress.session['extra_session_bits'] = {
+                '{}.results_rows.generated'.format(route_prefix): path,
+            }
+            progress.session.save()
+
+    def download_results_rows_setup(self, fields, progress=None):
+        """
+        Perform any up-front caching or other setup required, just prior to
+        generating a new results data file for download.
+        """
+
+    def download_results_rows_generate(self, session, results, path, fmt, fields, progress=None):
+        """
+        This method is responsible for actually generating the data file for a
+        "download rows for results" operation, according to the given params.
+        """
+        # we really are concerned with "rows of results" here, so let's just
+        # replace the 'results' list with a list of rows
+        original_results = results
+        results = []
+
+        def collect(obj, i):
+            results.extend(self.get_row_data(obj).all())
+
+        self.progress_loop(collect, original_results, progress,
+                           message="Collecting data for {}".format(self.get_row_model_title_plural()))
+
+        if fmt == 'csv':
+
+            csv_file = open(path, 'wt', encoding='utf_8')
+            writer = csv.DictWriter(csv_file, fields)
+            writer.writeheader()
+
+            def write(obj, i):
+                data = self.download_results_rows_normalize(obj, fields, fmt=fmt)
+                csvrow = self.download_results_rows_coerce_csv(data, fields)
+                writer.writerow(csvrow)
+
+            self.progress_loop(write, results, progress,
+                               message="Writing data to CSV file")
+            csv_file.close()
+
+        elif fmt == 'xlsx':
+
+            writer = ExcelWriter(path, fields,
+                                 sheet_title=self.get_row_model_title_plural())
+            writer.write_header()
+
+            xlrows = []
+            def write(obj, i):
+                data = self.download_results_rows_normalize(obj, fields, fmt=fmt)
+                row = self.download_results_rows_coerce_xlsx(data, fields)
+                xlrow = [row[field] for field in fields]
+                xlrows.append(xlrow)
+
+            self.progress_loop(write, results, progress,
+                               message="Collecting data for Excel")
+
+            def finalize(x, i):
+                writer.write_rows(xlrows)
+                writer.auto_freeze()
+                writer.auto_filter()
+                writer.auto_resize()
+                writer.save()
+
+            self.progress_loop(finalize, [1], progress,
+                               message="Writing Excel file to disk")
+
+    def download_results_rows_normalize(self, row, fields, **kwargs):
+        """
+        Normalize the given row object into a data dict, for use when writing
+        to the results file for download.
+        """
+        app = self.get_rattail_app()
+        data = {}
+        for field in fields:
+            value = getattr(row, field, None)
+
+            # make timestamps zone-aware
+            if isinstance(value, datetime.datetime):
+                value = app.localtime(value, from_utc=not self.has_local_times)
+
+            data[field] = value
+
+        return data
+
+    def download_results_rows_coerce_csv(self, data, fields, **kwargs):
+        """
+        Coerce the given data dict record, to a "row" dict suitable for use
+        when writing directly to CSV file.  Each value in the dict should be a
+        string type.
+        """
+        csvrow = dict(data)
+        for field in fields:
+            value = csvrow.get(field)
+
+            if value is None:
+                value = ''
+            else:
+                value = str(value)
+
+            csvrow[field] = value
+
+        return csvrow
+
+    def download_results_rows_coerce_xlsx(self, data, fields, **kwargs):
+        """
+        Coerce the given data dict record, to a "row" dict suitable for use
+        when writing directly to XLSX file.
+        """
+        app = self.get_rattail_app()
+        data = dict(data)
+        for key in data:
+            value = data[key]
+
+            # convert GPC to pretty string
+            if isinstance(value, GPC):
+                value = value.pretty()
+
+            # make timestamps local, "zone-naive"
+            elif isinstance(value, datetime.datetime):
+                value = app.localtime(value, tzinfo=False)
+
+            data[key] = value
+
+        return data
+
     def row_results_xlsx(self):
         """
         Download current *row* results as XLSX.
         """
+        app = self.get_rattail_app()
         obj = self.get_instance()
         results = self.get_effective_row_data(sort=True)
         fields = self.get_row_xlsx_fields()
-        path = temp_path(suffix='.xlsx')
+        path = app.make_temp_file(suffix='.xlsx')
         writer = ExcelWriter(path, fields, sheet_title=self.get_row_model_title_plural())
         writer.write_header()
 
@@ -2796,21 +4299,22 @@ class MasterView(View):
         """
         Return a dict for use when writing the row's data to XLSX download.
         """
+        app = self.get_rattail_app()
         xlrow = {}
         for field in fields:
             value = getattr(row, field, None)
 
             if isinstance(value, GPC):
-                value = six.text_type(value)
+                value = str(value)
 
             elif isinstance(value, datetime.datetime):
                 # datetime values we provide to Excel must *not* have time zone info,
                 # but we should make sure they're in "local" time zone effectively.
                 # note however, this assumes a "naive" time value is in UTC zone!
                 if value.tzinfo:
-                    value = localtime(self.rattail_config, value, tzinfo=False)
+                    value = app.localtime(value, tzinfo=False)
                 else:
-                    value = localtime(self.rattail_config, value, from_utc=True, tzinfo=False)
+                    value = app.localtime(value, from_utc=True, tzinfo=False)
 
             xlrow[field] = value
         return xlrow
@@ -2824,21 +4328,16 @@ class MasterView(View):
         """
         obj = self.get_instance()
         fields = self.get_row_csv_fields()
-        data = six.StringIO()
+        data = io.StringIO()
         writer = UnicodeDictWriter(data, fields)
         writer.writeheader()
         for row in self.get_effective_row_data(sort=True):
             writer.writerow(self.get_row_csv_row(row, fields))
         response = self.request.response
         filename = self.get_row_results_csv_filename(obj)
-        if six.PY3:
-            response.text = data.getvalue()
-            response.content_type = 'text/csv'
-            response.content_disposition = 'attachment; filename={}'.format(filename)
-        else:
-            response.body = data.getvalue()
-            response.content_type = b'text/csv'
-            response.content_disposition = b'attachment; filename={}'.format(filename)
+        response.text = data.getvalue()
+        response.content_type = 'text/csv'
+        response.content_disposition = 'attachment; filename={}'.format(filename)
         data.close()
         response.content_length = len(response.body)
         return response
@@ -2862,37 +4361,45 @@ class MasterView(View):
         """
         Return the list of row fields to be written to CSV download.
         """
-        fields = []
-        mapper = orm.class_mapper(self.model_row_class)
-        for prop in mapper.iterate_properties:
-            if isinstance(prop, orm.ColumnProperty):
-                fields.append(prop.key)
+        try:
+            mapper = orm.class_mapper(self.model_row_class)
+        except:
+            fields = self.get_row_form_fields()
+            if not fields:
+                fields = self.get_row_grid_columns()
+        else:
+            fields = []
+            for prop in mapper.iterate_properties:
+                if isinstance(prop, orm.ColumnProperty):
+                    fields.append(prop.key)
         return fields
 
     def get_csv_row(self, obj, fields):
         """
         Return a dict for use when writing the row's data to CSV download.
         """
+        app = self.get_rattail_app()
         csvrow = {}
         for field in fields:
             value = getattr(obj, field, None)
             if isinstance(value, datetime.datetime):
                 # TODO: this assumes value is *always* naive UTC
-                value = localtime(self.rattail_config, value, from_utc=True)
-            csvrow[field] = '' if value is None else six.text_type(value)
+                value = app.localtime(value, from_utc=True)
+            csvrow[field] = '' if value is None else str(value)
         return csvrow
 
     def get_row_csv_row(self, row, fields):
         """
         Return a dict for use when writing the row's data to CSV download.
         """
+        app = self.get_rattail_app()
         csvrow = {}
         for field in fields:
             value = getattr(row, field, None)
             if isinstance(value, datetime.datetime):
                 # TODO: this assumes value is *always* naive UTC
-                value = localtime(self.rattail_config, value, from_utc=True)
-            csvrow[field] = '' if value is None else six.text_type(value)
+                value = app.localtime(value, from_utc=True)
+            csvrow[field] = '' if value is None else str(value)
         return csvrow
 
     ##############################
@@ -2905,28 +4412,23 @@ class MasterView(View):
         doing a database lookup.  If the instance cannot be found, raises 404.
         """
         model_keys = self.get_model_key(as_tuple=True)
+        query = self.Session.query(self.get_model_class())
 
-        # if just one primary key, simple get() will work
-        if len(model_keys) == 1:
-            model_key = model_keys[0]
+        def filtr(query, model_key):
             key = self.request.matchdict[model_key]
+            if self.key_is_integer(model_key):
+                key = int(key)
+            query = query.filter(getattr(self.model_class, model_key) == key)
+            return query
 
-            obj = self.Session.query(self.get_model_class()).get(key)
-            if not obj:
-                raise self.notfound()
-
-        else: # composite key; fetch accordingly
-            # TODO: should perhaps use filter() instead of get() here?
-            query = self.Session.query(self.get_model_class())
-            for i, model_key in enumerate(model_keys):
-                key = self.request.matchdict[model_key]
-                if self.key_is_integer(model_key):
-                    key = int(key)
-                query = query.filter(getattr(self.model_class, model_key) == key)
-            try:
-                obj = query.one()
-            except orm.exc.NoResultFound:
-                raise self.notfound()
+        # filter query by composite key.  we use filter() instead of a simple
+        # get() here in case view uses a "pseudo-PK"
+        for i, model_key in enumerate(model_keys):
+            query = filtr(query, model_key)
+        try:
+            obj = query.one()
+        except orm.exc.NoResultFound:
+            raise self.notfound()
 
         # pretend global object doesn't exist, unless access allowed
         if self.secure_global_objects:
@@ -2952,7 +4454,7 @@ class MasterView(View):
         """
         Return a "pretty" title for the instance, to be used in the page title etc.
         """
-        return six.text_type(instance)
+        return str(instance)
 
     @classmethod
     def get_form_factory(cls):
@@ -2962,14 +4464,6 @@ class MasterView(View):
         """
         return getattr(cls, 'form_factory', forms.Form)
 
-    @classmethod
-    def get_mobile_form_factory(cls):
-        """
-        Returns the factory or class which is to be used when creating new
-        mobile forms.
-        """
-        return getattr(cls, 'mobile_form_factory', forms.Form)
-
     @classmethod
     def get_row_form_factory(cls):
         """
@@ -2978,14 +4472,6 @@ class MasterView(View):
         """
         return getattr(cls, 'row_form_factory', forms.Form)
 
-    @classmethod
-    def get_mobile_row_form_factory(cls):
-        """
-        Returns the factory or class which is to be used when creating new
-        mobile row forms.
-        """
-        return getattr(cls, 'mobile_row_form_factory', forms.Form)
-
     def download_path(self, obj, filename):
         """
         Should return absolute path on disk, for the given object and filename.
@@ -2994,7 +4480,10 @@ class MasterView(View):
         raise NotImplementedError
 
     def render_downloadable_file(self, obj, field):
-        filename = getattr(obj, field)
+        if hasattr(obj, field):
+            filename = getattr(obj, field)
+        else:
+            filename = obj[field]
         if not filename:
             return ""
         path = self.download_path(obj, filename)
@@ -3012,16 +4501,17 @@ class MasterView(View):
             return tags.link_to(content, url)
         return content
 
-    def readable_size(self, path):
+    def readable_size(self, path, size=None):
         # TODO: this was shamelessly copied from FormAlchemy ...
-        length = self.get_size(path)
-        if length == 0:
+        if size is None:
+            size = self.get_size(path)
+        if size == 0:
             return '0 KB'
-        if length <= 1024:
+        if size <= 1024:
             return '1 KB'
-        if length > 1048576:
-            return '%0.02f MB' % (length / 1048576.0)
-        return '%0.02f KB' % (length / 1024.0)
+        if size > 1048576:
+            return '%0.02f MB' % (size / 1048576.0)
+        return '%0.02f KB' % (size / 1024.0)
 
     def get_size(self, path):
         try:
@@ -3070,29 +4560,56 @@ class MasterView(View):
         Return a dictionary of kwargs to be passed to the factory when creating
         new form instances.
         """
+        route_prefix = self.get_route_prefix()
         defaults = {
             'request': self.request,
             'readonly': self.viewing,
             'model_class': getattr(self, 'model_class', None),
-            'action_url': self.request.current_route_url(_query=None),
-            'use_buefy': self.get_use_buefy(),
+            'action_url': self.request.path_url,
+            'assume_local_times': self.has_local_times,
+            'route_prefix': route_prefix,
+            'can_edit_help': self.can_edit_help(),
         }
+
+        if defaults['can_edit_help']:
+            defaults['edit_help_url'] = self.request.route_url(
+                '{}.edit_field_help'.format(route_prefix))
+
         if self.creating:
             kwargs.setdefault('cancel_url', self.get_index_url())
         else:
             instance = kwargs['model_instance']
             kwargs.setdefault('cancel_url', self.get_action_url('view', instance))
+
         defaults.update(kwargs)
         return defaults
 
+    def iter_view_supplements(self):
+        """
+        Iterate over all registered supplements for this master view.
+        """
+        supplements = self.request.registry.settings.get('tailbone_view_supplements', [])
+        route_prefix = self.get_route_prefix()
+        if supplements and route_prefix in supplements:
+            for cls in supplements[route_prefix]:
+                supp = cls(self)
+                yield supp
+
     def configure_form(self, form):
         """
         Configure the main "desktop" form for the view's data model.
         """
         self.configure_common_form(form)
 
+        self.configure_field_customer_key(form)
+        self.configure_field_member_key(form)
+        self.configure_field_product_key(form)
+
+        for supp in self.iter_view_supplements():
+            supp.configure_form(form)
+
     def validate_form(self, form):
-        if form.validate(newstyle=True):
+        if form.validate():
             self.form_deserialized = form.validated
             return True
         return False
@@ -3118,9 +4635,13 @@ class MasterView(View):
             if not self.has_perm('view_global'):
                 obj.local_only = True
 
+        for supp in self.iter_view_supplements():
+            obj = supp.objectify(obj, form, data)
+
         return obj
 
     def objectify_contact(self, contact, data):
+        app = self.get_rattail_app()
 
         if 'default_email' in data:
             address = data['default_email']
@@ -3134,7 +4655,7 @@ class MasterView(View):
                 contact.add_email_address(address)
 
         if 'default_phone' in data:
-            number = data['default_phone']
+            number = app.format_phone_number(data['default_phone'])
             if contact.phones:
                 if number:
                     phone = contact.phones[0]
@@ -3184,11 +4705,16 @@ class MasterView(View):
         Event hook, called just after a new instance is saved.
         """
 
-    def editable_instance(self, instance):
+    def editable_instance(self, obj):
         """
-        Returns boolean indicating whether or not the given instance can be
-        considered "editable".  Returns ``True`` by default; override as
-        necessary.
+        Returns boolean indicating whether or not the given object
+        should be considered "editable".  Returns ``True`` by default;
+        override as necessary.
+
+        :param obj: A top-level record/object, of the type normally
+           handled by this master view.
+
+        :returns: ``True`` if object is editable, else ``False``.
         """
         return True
 
@@ -3234,6 +4760,35 @@ class MasterView(View):
             return self.after_delete_url
         return self.get_index_url()
 
+    ##############################
+    # Autocomplete Stuff
+    ##############################
+
+    def autocomplete(self):
+        """
+        View which accepts a single ``term`` param, and returns a list
+        of autocomplete results to match.
+        """
+        app = self.get_rattail_app()
+        key = self.get_autocompleter_key()
+        # url may include key, for more specific autocompleter
+        if 'key' in self.request.matchdict:
+            key = '{}.{}'.format(key, self.request.matchdict['key'])
+        autocompleter = app.get_autocompleter(key)
+
+        term = self.request.params.get('term', '')
+        return autocompleter.autocomplete(self.Session(), term)
+
+    def get_autocompleter_key(self):
+        """
+        Must return the "key" to be used when locating the
+        Autocompleter object, for use with autocomplete view.
+        """
+        if hasattr(self, 'autocompleter_key'):
+            if self.autocompleter_key:
+                return self.autocompleter_key
+        return self.get_route_prefix()
+
     ##############################
     # Associated Rows Stuff
     ##############################
@@ -3279,49 +4834,8 @@ class MasterView(View):
     def after_create_row(self, row_object):
         pass
 
-    def redirect_after_create_row(self, row, mobile=False):
-        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
-
-    def mobile_create_row(self):
-        """
-        Mobile view for creating a new row object
-        """
-        self.mobile = True
-        self.creating = True
-        parent = self.get_instance()
-        instance_url = self.get_action_url('view', parent, mobile=True)
-        form = self.make_mobile_row_form(self.model_row_class, cancel_url=instance_url)
-        if self.request.method == 'POST':
-            if self.validate_mobile_row_form(form):
-                self.before_create_row(form)
-                # let save() return alternate object if necessary
-                obj = self.save_create_row_form(form)
-                self.after_create_row(obj)
-                return self.redirect_after_create_row(obj, mobile=True)
-        return self.render_to_response('create_row', {
-            'instance_title': self.get_instance_title(parent),
-            'instance_url': instance_url,
-            'parent_object': parent,
-            'form': form,
-        }, mobile=True)
-
-    def mobile_quick_row(self):
-        """
-        Mobile view for "quick" location or creation of a row object
-        """
-        parent = self.get_instance()
-        parent_url = self.get_action_url('view', parent, mobile=True)
-        form = self.make_quick_row_form(self.model_row_class, mobile=True, cancel_url=parent_url)
-        if self.request.method == 'POST':
-            if self.validate_quick_row_form(form):
-                row = self.save_quick_row_form(form)
-                if not row:
-                    self.request.session.flash("Could not locate/create row for entry: "
-                                               "{}".format(form.validated['quick_entry']),
-                                               'error')
-                    return self.redirect(parent_url)
-                return self.redirect_after_quick_row(row, mobile=True)
-        return self.redirect(parent_url)
+    def redirect_after_create_row(self, row, **kwargs):
+        return self.redirect(self.get_row_action_url('view', row))
 
     def save_quick_row_form(self, form):
         raise NotImplementedError("You must define `{}:{}.save_quick_row_form()` "
@@ -3329,8 +4843,8 @@ class MasterView(View):
                                       self.__class__.__module__,
                                       self.__class__.__name__))
 
-    def redirect_after_quick_row(self, row, mobile=False):
-        return self.redirect(self.get_row_action_url('edit', row, mobile=mobile))
+    def redirect_after_quick_row(self, row, **kwargs):
+        return self.redirect(self.get_row_action_url('edit', row))
 
     def view_row(self):
         """
@@ -3397,6 +4911,7 @@ class MasterView(View):
         return self.render_to_response('edit_row', {
             'instance': row,
             'row_parent': parent,
+            'parent_model_title': self.get_model_title(),
             'parent_title': self.get_instance_title(parent),
             'parent_url': self.get_action_url('view', parent),
             'parent_instance': parent,
@@ -3406,34 +4921,6 @@ class MasterView(View):
             'dform': form.make_deform_form(),
         })
 
-    def mobile_edit_row(self):
-        """
-        Mobile view for editing a row object
-        """
-        self.mobile = True
-        self.editing = True
-        row = self.get_row_instance()
-        instance_url = self.get_row_action_url('view', row, mobile=True)
-        form = self.make_mobile_row_form(row)
-
-        if self.request.method == 'POST':
-            if self.validate_mobile_row_form(form):
-                self.save_edit_row_form(form)
-                return self.redirect_after_edit_row(row, mobile=True)
-
-        parent = self.get_parent(row)
-        return self.render_to_response('edit_row', {
-            'row': row,
-            'instance': row,
-            'parent_instance': parent,
-            'instance_title': self.get_row_instance_title(row),
-            'instance_url': instance_url,
-            'instance_deletable': self.row_deletable(row),
-            'parent_title': self.get_instance_title(parent),
-            'parent_url': self.get_action_url('view', parent, mobile=True),
-            'form': form},
-        mobile=True)
-
     def save_edit_row_form(self, form):
         obj = self.objectify(form, self.form_deserialized)
         self.after_edit_row(obj)
@@ -3448,8 +4935,8 @@ class MasterView(View):
         Event hook, called just after an existing row object is saved.
         """
 
-    def redirect_after_edit_row(self, row, mobile=False):
-        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
+    def redirect_after_edit_row(self, row, **kwargs):
+        return self.redirect(self.get_row_action_url('view', row))
 
     def row_deletable(self, row):
         """
@@ -3457,6 +4944,8 @@ class MasterView(View):
         considered "deletable".  Returns ``True`` by default; override as
         necessary.
         """
+        if not self.rows_deletable:
+            return False
         return True
 
     def delete_row_object(self, row):
@@ -3476,21 +4965,29 @@ class MasterView(View):
         self.delete_row_object(row)
         return self.redirect(self.get_action_url('view', self.get_parent(row)))
 
-    def mobile_delete_row(self):
+    def bulk_delete_rows(self):
         """
-        Mobile view which can "delete" a sub-row from the parent.
+        Delete all row objects matching the current row grid query.
         """
-        if self.request.method == 'POST':
-            parent = self.get_instance()
-            row = self.get_row_instance()
-            if self.get_parent(row) is not parent:
-                raise RuntimeError("Can only delete rows which belong to current object")
+        obj = self.get_instance()
+        rows = self.get_effective_row_data(sort=False).all()
 
-            self.delete_row_object(row)
-            return self.redirect(self.get_action_url('view', parent, mobile=True))
+        # TODO: this should use a separate thread with progress
+        self.delete_row_objects(rows)
+        self.Session.refresh(obj)
 
-        self.session.flash("Must POST to delete a row", 'error')
-        return self.redirect(self.request.get_referrer(mobile=True))
+        return self.redirect(self.get_action_url('view', obj))
+
+    def delete_row_objects(self, rows):
+        """
+        Perform the actual deletion of given row objects.
+        """
+        deleted = 0
+        for row in rows:
+            if self.row_deletable(row):
+                self.delete_row_object(row)
+                deleted += 1
+        return deleted
 
     def get_parent(self, row):
         raise NotImplementedError
@@ -3502,7 +4999,7 @@ class MasterView(View):
         # TODO: is this right..?
         # key = self.request.matchdict[self.get_model_key()]
         key = self.request.matchdict['row_uuid']
-        instance = self.Session.query(self.model_row_class).get(key)
+        instance = self.Session.get(self.model_row_class, key)
         if not instance:
             raise self.notfound()
         return instance
@@ -3546,7 +5043,6 @@ class MasterView(View):
             'readonly': self.viewing,
             'model_class': getattr(self, 'model_row_class', None),
             'action_url': self.request.current_route_url(_query=None),
-            'use_buefy': self.get_use_buefy(),
         }
         if self.creating:
             kwargs.setdefault('cancel_url', self.request.get_referrer())
@@ -3575,19 +5071,94 @@ class MasterView(View):
 
         self.set_row_labels(form)
 
+        self.configure_field_customer_key(form)
+        self.configure_field_member_key(form)
+        self.configure_field_product_key(form)
+
     def validate_row_form(self, form):
-        if form.validate(newstyle=True):
+        if form.validate():
             self.form_deserialized = form.validated
             return True
         return False
 
-    def get_row_action_url(self, action, row, mobile=False):
+    def get_customer_key_field(self):
+        app = self.get_rattail_app()
+        key = app.get_customer_key_field()
+        return self.customer_key_fields.get(key, key)
+
+    def get_customer_key_label(self):
+        app = self.get_rattail_app()
+        field = self.get_customer_key_field()
+        return app.get_customer_key_label(field=field)
+
+    def configure_column_customer_key(self, g):
+        if '_customer_key_' in g.columns:
+            field = self.get_customer_key_field()
+            g.replace('_customer_key_', field)
+            g.set_label(field, self.get_customer_key_label())
+            g.set_link(field)
+
+    def configure_field_customer_key(self, f):
+        if '_customer_key_' in f:
+            field = self.get_customer_key_field()
+            f.replace('_customer_key_', field)
+            f.set_label(field, self.get_customer_key_label())
+
+    def get_member_key_field(self):
+        app = self.get_rattail_app()
+        key = app.get_member_key_field()
+        return self.member_key_fields.get(key, key)
+
+    def get_member_key_label(self):
+        app = self.get_rattail_app()
+        field = self.get_member_key_field()
+        return app.get_member_key_label(field=field)
+
+    def configure_column_member_key(self, g):
+        if '_member_key_' in g.columns:
+            field = self.get_member_key_field()
+            g.replace('_member_key_', field)
+            g.set_label(field, self.get_member_key_label())
+            g.set_link(field)
+
+    def configure_field_member_key(self, f):
+        if '_member_key_' in f:
+            field = self.get_member_key_field()
+            f.replace('_member_key_', field)
+            f.set_label(field, self.get_member_key_label())
+
+    def get_product_key_field(self):
+        app = self.get_rattail_app()
+        key = app.get_product_key_field()
+        return self.product_key_fields.get(key, key)
+
+    def get_product_key_label(self):
+        app = self.get_rattail_app()
+        field = self.get_product_key_field()
+        return app.get_product_key_label(field=field)
+
+    def configure_column_product_key(self, g):
+        if '_product_key_' in g.columns:
+            field = self.get_product_key_field()
+            g.replace('_product_key_', field)
+            g.set_label(field, self.get_product_key_label())
+            g.set_link(field)
+            if field == 'upc':
+                g.set_renderer(field, self.render_upc)
+
+    def configure_field_product_key(self, f):
+        if '_product_key_' in f:
+            field = self.get_product_key_field()
+            f.replace('_product_key_', field)
+            f.set_label(field, self.get_product_key_label())
+            if field == 'upc':
+                f.set_renderer(field, self.render_upc)
+
+    def get_row_action_url(self, action, row, **kwargs):
         """
         Generate a URL for the given action on the given row.
         """
         route_name = '{}.{}_row'.format(self.get_route_prefix(), action)
-        if mobile:
-            route_name = 'mobile.{}'.format(route_name)
         return self.request.route_url(route_name, **self.get_row_action_route_kwargs(row))
 
     def get_row_action_route_kwargs(self, row):
@@ -3604,8 +5175,416 @@ class MasterView(View):
     def make_diff(self, old_data, new_data, **kwargs):
         return diffs.Diff(old_data, new_data, **kwargs)
 
+    def get_version_diff_factory(self, **kwargs):
+        """
+        Must return the factory to be used when creating version diff
+        objects.
+
+        By default this returns the
+        :class:`tailbone.diffs.VersionDiff` class, unless
+        :attr:`version_diff_factory` is set, in which case that is
+        returned as-is.
+
+        :returns: A factory which can produce
+           :class:`~tailbone.diffs.VersionDiff` objects.
+        """
+        if hasattr(self, 'version_diff_factory'):
+            return self.version_diff_factory
+        return diffs.VersionDiff
+
+    def get_version_diff_enums(self, version):
+        """
+        This can optionally return a dict of field enums, to be passed
+        to the version diff factory.  This method is called as part of
+        :meth:`make_version_diff()`.
+        """
+
+    def make_version_diff(self, version, *args, **kwargs):
+        """
+        Make a version diff object, using the factory returned by
+        :meth:`get_version_diff_factory()`.
+
+        :param version: Reference to a Continuum version object.
+
+        :param title: If specified, must be as a kwarg.  Optional
+           override for the version title text.  If not specified,
+           :meth:`title_for_version()` is called for the title.
+
+        :param \*args: Additional args to pass to the factory.
+
+        :param \*\*kwargs: Additional kwargs to pass to the factory.
+
+        :returns: A :class:`~tailbone.diffs.VersionDiff` object.
+        """
+        if 'title' not in kwargs:
+            kwargs['title'] = self.title_for_version(version)
+
+        if 'enums' not in kwargs:
+            kwargs['enums'] = self.get_version_diff_enums(version)
+
+        factory = self.get_version_diff_factory()
+        return factory(version, *args, **kwargs)
+
     ##############################
-    # Config Stuff
+    # Configuration Views
+    ##############################
+
+    def configure(self):
+        """
+        Generic view for configuring some aspect of the software.
+        """
+        self.configuring = True
+        app = self.get_rattail_app()
+        if self.request.method == 'POST':
+            if self.request.POST.get('remove_settings'):
+                self.configure_remove_settings()
+                self.request.session.flash("All settings for {} have been "
+                                           "removed.".format(self.get_config_title()),
+                                           'warning')
+                return self.redirect(self.request.current_route_url())
+            else:
+                data = self.request.POST
+
+                # collect any uploaded files
+                uploads = {}
+                for key, value in data.items():
+                    if isinstance(value, cgi_FieldStorage):
+                        tempdir = app.make_temp_dir()
+                        filename = os.path.basename(value.filename)
+                        filepath = os.path.join(tempdir, filename)
+                        with open(filepath, 'wb') as f:
+                            f.write(value.file.read())
+                        uploads[key] = {
+                            'filedir': tempdir,
+                            'filename': filename,
+                            'filepath': filepath,
+                        }
+
+                # process any uploads first
+                if uploads:
+                    self.configure_process_uploads(uploads, data)
+
+                # then gather/save settings
+                settings = self.configure_gather_settings(data)
+                self.configure_remove_settings()
+                self.configure_save_settings(settings)
+                self.configure_flash_settings_saved()
+                return self.redirect(self.request.current_route_url())
+
+        context = self.configure_get_context()
+        return self.render_to_response('configure', context)
+
+    def template_kwargs_configure(self, **kwargs):
+        kwargs['system_user'] = getpass.getuser()
+        return kwargs
+
+    def configure_flash_settings_saved(self):
+        self.request.session.flash("Settings have been saved.")
+
+    def configure_process_uploads(self, uploads, data):
+        if self.has_input_file_templates:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'input_files',
+                                        self.get_route_prefix())
+
+            def get_next_filedir(basedir):
+                nextid = 1
+                while True:
+                    path = os.path.join(basedir, '{:04d}'.format(nextid))
+                    if not os.path.exists(path):
+                        # this should fail if there happens to be a race
+                        # condition and someone else got to this id first
+                        os.mkdir(path)
+                        return path
+                    nextid += 1
+
+            for template in self.normalize_input_file_templates():
+                key = '{}.upload'.format(template['setting_file'])
+                if key in uploads:
+                    assert self.request.POST[template['setting_mode']] == 'hosted'
+                    assert not self.request.POST[template['setting_file']]
+                    info = uploads[key]
+                    basedir = os.path.join(templatesdir, template['key'])
+                    if not os.path.exists(basedir):
+                        os.makedirs(basedir)
+                    filedir = get_next_filedir(basedir)
+                    filepath = os.path.join(filedir, info['filename'])
+                    shutil.copyfile(info['filepath'], filepath)
+                    shutil.rmtree(info['filedir'])
+                    numdir = os.path.basename(filedir)
+                    data[template['setting_file']] = os.path.join(numdir,
+                                                                  info['filename'])
+
+        if self.has_output_file_templates:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'output_files',
+                                        self.get_route_prefix())
+
+            def get_next_filedir(basedir):
+                nextid = 1
+                while True:
+                    path = os.path.join(basedir, '{:04d}'.format(nextid))
+                    if not os.path.exists(path):
+                        # this should fail if there happens to be a race
+                        # condition and someone else got to this id first
+                        os.mkdir(path)
+                        return path
+                    nextid += 1
+
+            for template in self.normalize_output_file_templates():
+                key = '{}.upload'.format(template['setting_file'])
+                if key in uploads:
+                    assert self.request.POST[template['setting_mode']] == 'hosted'
+                    assert not self.request.POST[template['setting_file']]
+                    info = uploads[key]
+                    basedir = os.path.join(templatesdir, template['key'])
+                    if not os.path.exists(basedir):
+                        os.makedirs(basedir)
+                    filedir = get_next_filedir(basedir)
+                    filepath = os.path.join(filedir, info['filename'])
+                    shutil.copyfile(info['filepath'], filepath)
+                    shutil.rmtree(info['filedir'])
+                    numdir = os.path.basename(filedir)
+                    data[template['setting_file']] = os.path.join(numdir,
+                                                                  info['filename'])
+
+    def configure_get_simple_settings(self):
+        """
+        If you have some "simple" settings, each of which basically
+        just needs to be rendered as a separate field, then you can
+        declare them via this method.
+
+        You should return a list of settings; each setting should be
+        represented as a dict with various pieces of info, e.g.::
+
+           {
+               'section': 'rattail.batch',
+               'option': 'purchase.allow_cases',
+               'name': 'rattail.batch.purchase.allow_cases',
+               'type': bool,
+               'value': config.getbool('rattail.batch',
+                                       'purchase.allow_cases'),
+               'save_if_empty': False,
+           }
+
+        Note that some of the above is optional, in particular it
+        works like this:
+
+        If you pass ``section`` and ``option`` then you do not need to
+        pass ``name`` since that can be deduced.  Also in this case
+        you need not pass ``value`` as the normal view logic can fetch
+        the value automatically.  Note that when fetching, it honors
+        ``type`` which, if you do not specify, would be ``str`` by
+        default.
+
+        However if you pass ``name`` then you need not pass
+        ``section`` or ``option``, but you must pass ``value`` since
+        that cannot be automatically fetched in this case.
+
+        :returns: List of simple setting info dicts, as described
+           above.
+        """
+
+    def configure_get_name_for_simple_setting(self, simple):
+        if 'name' in simple:
+            return simple['name']
+        return '{}.{}'.format(simple['section'],
+                              simple['option'])
+
+    def configure_get_context(self, simple_settings=None,
+                              input_file_templates=True,
+                              output_file_templates=True):
+        """
+        Returns the full context dict, for rendering the configure
+        page template.
+
+        Default context will include the "simple" settings, as well as
+        any "input file template" settings.
+
+        You may need to override this method, to add additional
+        "custom" settings.
+
+        :param simple_settings: Optional list of simple settings, if
+           already initialized.
+
+        :returns: Context dict for the page template.
+        """
+        context = {}
+        if simple_settings is None:
+            simple_settings = self.configure_get_simple_settings()
+        if simple_settings:
+
+            config = self.rattail_config
+            settings = {}
+            for simple in simple_settings:
+
+                name = self.configure_get_name_for_simple_setting(simple)
+
+                if 'value' in simple:
+                    value = simple['value']
+                elif simple.get('type') is bool:
+                    value = config.getbool(simple['section'],
+                                           simple['option'],
+                                           default=simple.get('default', False))
+                else:
+                    value = config.get(simple['section'],
+                                       simple['option'])
+
+                settings[name] = value
+
+            context['simple_settings'] = settings
+
+        # add settings for downloadable input file templates, if any
+        if input_file_templates and self.has_input_file_templates:
+            settings = {}
+            file_options = {}
+            file_option_dirs = {}
+            for template in self.normalize_input_file_templates(
+                    include_file_options=True):
+                settings[template['setting_mode']] = template['mode']
+                settings[template['setting_file']] = template['file'] or ''
+                settings[template['setting_url']] = template['url']
+                file_options[template['key']] = template['file_options']
+                file_option_dirs[template['key']] = template['file_options_dir']
+            context['input_file_template_settings'] = settings
+            context['input_file_options'] = file_options
+            context['input_file_option_dirs'] = file_option_dirs
+
+        # add settings for output file templates, if any
+        if output_file_templates and self.has_output_file_templates:
+            settings = {}
+            file_options = {}
+            file_option_dirs = {}
+            for template in self.normalize_output_file_templates(
+                    include_file_options=True):
+                settings[template['setting_mode']] = template['mode']
+                settings[template['setting_file']] = template['file'] or ''
+                settings[template['setting_url']] = template['url']
+                file_options[template['key']] = template['file_options']
+                file_option_dirs[template['key']] = template['file_options_dir']
+            context['output_file_template_settings'] = settings
+            context['output_file_options'] = file_options
+            context['output_file_option_dirs'] = file_option_dirs
+
+        return context
+
+    def configure_gather_settings(self, data, simple_settings=None,
+                                  input_file_templates=True,
+                                  output_file_templates=True):
+        settings = []
+
+        # maybe collect "simple" settings
+        if simple_settings is None:
+            simple_settings = self.configure_get_simple_settings()
+        if simple_settings:
+
+            for simple in simple_settings:
+                name = self.configure_get_name_for_simple_setting(simple)
+                value = data.get(name)
+
+                if simple.get('type') is bool:
+                    value = str(bool(value)).lower()
+                elif simple.get('type') is int:
+                    value = str(int(value or '0'))
+                elif value is None:
+                    value = ''
+                else:
+                    value = str(value)
+
+                # only want to save this setting if we received a
+                # value, or if empty values are okay to save
+                if value or simple.get('save_if_empty'):
+                    settings.append({'name': name,
+                                     'value': value})
+
+        # maybe also collect input file template settings
+        if input_file_templates and self.has_input_file_templates:
+            for template in self.normalize_input_file_templates():
+
+                # mode
+                settings.append({'name': template['setting_mode'],
+                                 'value': data.get(template['setting_mode'])})
+
+                # file
+                value = data.get(template['setting_file'])
+                if value:
+                    # nb. avoid saving if empty, so can remain "null"
+                    settings.append({'name': template['setting_file'],
+                                     'value': value})
+
+                # url
+                settings.append({'name': template['setting_url'],
+                                 'value': data.get(template['setting_url'])})
+
+        # maybe also collect output file template settings
+        if output_file_templates and self.has_output_file_templates:
+            for template in self.normalize_output_file_templates():
+
+                # mode
+                settings.append({'name': template['setting_mode'],
+                                 'value': data.get(template['setting_mode'])})
+
+                # file
+                value = data.get(template['setting_file'])
+                if value:
+                    # nb. avoid saving if empty, so can remain "null"
+                    settings.append({'name': template['setting_file'],
+                                     'value': value})
+
+                # url
+                settings.append({'name': template['setting_url'],
+                                 'value': data.get(template['setting_url'])})
+
+        return settings
+
+    def configure_remove_settings(self, simple_settings=None,
+                                  input_file_templates=True,
+                                  output_file_templates=True):
+        app = self.get_rattail_app()
+        model = self.app.model
+        names = []
+
+        if simple_settings is None:
+            simple_settings = self.configure_get_simple_settings()
+        if simple_settings:
+            names.extend([self.configure_get_name_for_simple_setting(simple)
+                          for simple in simple_settings])
+
+        if input_file_templates and self.has_input_file_templates:
+            for template in self.normalize_input_file_templates():
+                names.extend([
+                    template['setting_mode'],
+                    template['setting_file'],
+                    template['setting_url'],
+                ])
+
+        if output_file_templates and self.has_output_file_templates:
+            for template in self.normalize_output_file_templates():
+                names.extend([
+                    template['setting_mode'],
+                    template['setting_file'],
+                    template['setting_url'],
+                ])
+
+        if names:
+            # nb. using thread-local session here; we do not use
+            # self.Session b/c it may not point to Rattail
+            session = Session()
+            for name in names:
+                app.delete_setting(session, name)
+
+    def configure_save_settings(self, settings):
+        app = self.get_rattail_app()
+
+        # nb. using thread-local session here; we do not use
+        # self.Session b/c it may not point to Rattail
+        session = Session()
+        for setting in settings:
+            app.save_setting(session, setting['name'], setting['value'],
+                             force_create=True)
+
+    ##############################
+    # Pyramid View Config
     ##############################
 
     @classmethod
@@ -3648,37 +5627,117 @@ class MasterView(View):
         model_key = cls.get_model_key()
         model_title = cls.get_model_title()
         model_title_plural = cls.get_model_title_plural()
+        config_title = cls.get_config_title()
         if cls.has_rows:
             row_model_title = cls.get_row_model_title()
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
+            row_model_title_plural = cls.get_row_model_title_plural()
 
         config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
 
+        # on windows/chrome we are seeing some caching when e.g.  user
+        # applies some filters, then views a record, then clicks back
+        # button, filters no longer are applied. so by default we
+        # instruct browser to never cache certain pages which contain
+        # a grid.  at this point only /index and /view
+        # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments
+        prevent_cache = rattail_config.getbool('tailbone',
+                                               'prevent_cache_for_index_views',
+                                               default=True)
+
+        # edit help info
+        cls._defaults_edit_help(config)
+
         # list/search
         if cls.listable:
+
+            # master views which represent a typical model class, and
+            # allow for an index view, are registered specially so the
+            # admin may browse the full list of such views
+            modclass = cls.get_model_class(error=False)
+            if modclass:
+                config.add_tailbone_model_view(modclass.__name__,
+                                               model_title_plural,
+                                               route_prefix,
+                                               permission_prefix)
+
+            # but regardless we register the index view, for similar reasons
+            config.add_tailbone_index_page(route_prefix, model_title_plural,
+                                           '{}.list'.format(permission_prefix))
+
+            # index view
             config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix),
                                            "List / search {}".format(model_title_plural))
             config.add_route(route_prefix, '{}/'.format(url_prefix))
+            kwargs = {'http_cache': 0} if prevent_cache else {}
             config.add_view(cls, attr='index', route_name=route_prefix,
-                            permission='{}.list'.format(permission_prefix))
-            if legacy_mobile and cls.supports_mobile:
-                config.add_route('mobile.{}'.format(route_prefix), '/mobile{}/'.format(url_prefix))
-                config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(route_prefix),
-                                permission='{}.list'.format(permission_prefix))
+                            permission='{}.list'.format(permission_prefix),
+                            **kwargs)
 
+            # download results
+            # this is the "new" more flexible approach, but we only want to
+            # enable it if the class declares it, *and* does *not* declare the
+            # older style(s).  that way each class must explicitly choose
+            # *only* the new style in order to use it
+            if cls.results_downloadable and not (
+                    cls.results_downloadable_csv or cls.results_downloadable_xlsx):
+                config.add_tailbone_permission(permission_prefix, '{}.download_results'.format(permission_prefix),
+                                               "Download search results for {}".format(model_title_plural))
+                config.add_route('{}.download_results'.format(route_prefix), '{}/download-results'.format(url_prefix))
+                config.add_view(cls, attr='download_results', route_name='{}.download_results'.format(route_prefix),
+                                permission='{}.download_results'.format(permission_prefix))
+
+            # download results as CSV (deprecated)
             if cls.results_downloadable_csv:
                 config.add_tailbone_permission(permission_prefix, '{}.results_csv'.format(permission_prefix),
                                                "Download {} as CSV".format(model_title_plural))
                 config.add_route('{}.results_csv'.format(route_prefix), '{}/csv'.format(url_prefix))
                 config.add_view(cls, attr='results_csv', route_name='{}.results_csv'.format(route_prefix),
                                 permission='{}.results_csv'.format(permission_prefix))
+                config.add_route('{}.results_csv_download'.format(route_prefix), '{}/csv/download'.format(url_prefix))
+                config.add_view(cls, attr='results_csv_download', route_name='{}.results_csv_download'.format(route_prefix),
+                                permission='{}.results_csv'.format(permission_prefix))
 
+            # download results as XLSX (deprecated)
             if cls.results_downloadable_xlsx:
                 config.add_tailbone_permission(permission_prefix, '{}.results_xlsx'.format(permission_prefix),
                                                "Download {} as XLSX".format(model_title_plural))
                 config.add_route('{}.results_xlsx'.format(route_prefix), '{}/xlsx'.format(url_prefix))
                 config.add_view(cls, attr='results_xlsx', route_name='{}.results_xlsx'.format(route_prefix),
                                 permission='{}.results_xlsx'.format(permission_prefix))
+                config.add_route('{}.results_xlsx_download'.format(route_prefix), '{}/xlsx/download'.format(url_prefix))
+                config.add_view(cls, attr='results_xlsx_download', route_name='{}.results_xlsx_download'.format(route_prefix),
+                                permission='{}.results_xlsx'.format(permission_prefix))
+
+            # download rows for results
+            if cls.has_rows and cls.results_rows_downloadable:
+                config.add_tailbone_permission(permission_prefix, '{}.download_results_rows'.format(permission_prefix),
+                                               "Download *rows* for {} search results".format(model_title))
+                config.add_route('{}.download_results_rows'.format(route_prefix), '{}/download-rows-for-results'.format(url_prefix))
+                config.add_view(cls, attr='download_results_rows', route_name='{}.download_results_rows'.format(route_prefix),
+                                permission='{}.download_results_rows'.format(permission_prefix))
+
+            # fetch total hours
+            if cls.supports_grid_totals:
+                config.add_route(f'{route_prefix}.fetch_grid_totals',
+                                 f'{url_prefix}/fetch-grid-totals')
+                config.add_view(cls, attr='fetch_grid_totals',
+                                route_name=f'{route_prefix}.fetch_grid_totals',
+                                permission=f'{permission_prefix}.list',
+                                renderer='json')
+
+        # configure
+        if cls.configurable:
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.configure'.format(permission_prefix),
+                                           label="Configure {}".format(config_title))
+            config.add_route('{}.configure'.format(route_prefix),
+                             cls.get_config_url())
+            config.add_view(cls, attr='configure',
+                            route_name='{}.configure'.format(route_prefix),
+                            permission='{}.configure'.format(permission_prefix))
+            config.add_tailbone_config_page('{}.configure'.format(route_prefix),
+                                            config_title,
+                                            '{}.configure'.format(permission_prefix))
 
         # quickie (search)
         if cls.supports_quickie_search:
@@ -3689,18 +5748,32 @@ class MasterView(View):
             config.add_view(cls, attr='quickie', route_name='{}.quickie'.format(route_prefix),
                             permission='{}.quickie'.format(permission_prefix))
 
+        # autocomplete
+        if cls.supports_autocomplete:
+
+            # default
+            config.add_route('{}.autocomplete'.format(route_prefix),
+                             '{}/autocomplete'.format(url_prefix))
+            config.add_view(cls, attr='autocomplete',
+                            route_name='{}.autocomplete'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.list'.format(permission_prefix))
+
+            # special
+            config.add_route('{}.autocomplete_special'.format(route_prefix),
+                             '{}/autocomplete/{{key}}'.format(url_prefix))
+            config.add_view(cls, attr='autocomplete',
+                            route_name='{}.autocomplete_special'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.list'.format(permission_prefix))
+
         # create
-        if cls.creatable or (legacy_mobile and cls.mobile_creatable):
+        if cls.creatable:
             config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix),
                                            "Create new {}".format(model_title))
-        if cls.creatable:
             config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix))
             config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix),
                             permission='{}.create'.format(permission_prefix))
-        if legacy_mobile and cls.mobile_creatable:
-            config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix))
-            config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix),
-                            permission='{}.create'.format(permission_prefix))
 
         # populate new object
         if cls.populatable:
@@ -3747,41 +5820,26 @@ class MasterView(View):
             config.add_tailbone_permission(permission_prefix, '{}.merge'.format(permission_prefix),
                                            "Merge 2 {}".format(model_title_plural))
 
+        # download input file template
+        if cls.has_input_file_templates and cls.creatable:
+            config.add_route('{}.download_input_file_template'.format(route_prefix),
+                             '{}/download-input-file-template'.format(url_prefix))
+            config.add_view(cls, attr='download_input_file_template',
+                            route_name='{}.download_input_file_template'.format(route_prefix),
+                            permission='{}.create'.format(permission_prefix))
+
+        # download output file template
+        if cls.has_output_file_templates and cls.configurable:
+            config.add_route(f'{route_prefix}.download_output_file_template',
+                             f'{url_prefix}/download-output-file-template')
+            config.add_view(cls, attr='download_output_file_template',
+                            route_name=f'{route_prefix}.download_output_file_template',
+                            # TODO: this is different from input file, should change?
+                            permission=f'{permission_prefix}.configure')
+
         # view
         if cls.viewable:
-            config.add_tailbone_permission(permission_prefix, '{}.view'.format(permission_prefix),
-                                           "View details for {}".format(model_title))
-            if cls.has_pk_fields:
-                config.add_tailbone_permission(permission_prefix, '{}.view_pk_fields'.format(permission_prefix),
-                                               "View all PK-type fields for {}".format(model_title_plural))
-            if cls.secure_global_objects:
-                config.add_tailbone_permission(permission_prefix, '{}.view_global'.format(permission_prefix),
-                                               "View *global* {}".format(model_title_plural))
-
-            # view by grid index
-            config.add_route('{}.view_index'.format(route_prefix), '{}/view'.format(url_prefix))
-            config.add_view(cls, attr='view_index', route_name='{}.view_index'.format(route_prefix),
-                            permission='{}.view'.format(permission_prefix))
-
-            # view by record key
-            config.add_route('{}.view'.format(route_prefix), instance_url_prefix)
-            config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix),
-                            permission='{}.view'.format(permission_prefix))
-            if legacy_mobile and cls.supports_mobile:
-                config.add_route('mobile.{}.view'.format(route_prefix), '/mobile{}'.format(instance_url_prefix))
-                config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix),
-                                permission='{}.view'.format(permission_prefix))
-
-            # version history
-            if cls.has_versions and rattail_config and rattail_config.versioning_enabled():
-                config.add_tailbone_permission(permission_prefix, '{}.versions'.format(permission_prefix),
-                                               "View version history for {}".format(model_title))
-                config.add_route('{}.versions'.format(route_prefix), '{}/versions/'.format(instance_url_prefix))
-                config.add_view(cls, attr='versions', route_name='{}.versions'.format(route_prefix),
-                                permission='{}.versions'.format(permission_prefix))
-                config.add_route('{}.version'.format(route_prefix), '{}/versions/{{txnid}}'.format(instance_url_prefix))
-                config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix),
-                                permission='{}.versions'.format(permission_prefix))
+            cls._defaults_view(config)
 
         # image
         if cls.has_image:
@@ -3807,7 +5865,12 @@ class MasterView(View):
         if cls.touchable:
             config.add_tailbone_permission(permission_prefix, '{}.touch'.format(permission_prefix),
                                            "\"Touch\" a {} to trigger datasync for it".format(model_title))
-            config.add_route('{}.touch'.format(route_prefix), '{}/touch'.format(instance_url_prefix))
+            config.add_route('{}.touch'.format(route_prefix),
+                             '{}/touch'.format(instance_url_prefix),
+                             # TODO: should add this restriction after the old
+                             # jquery theme is no longer in use
+                             #request_method='POST'
+            )
             config.add_view(cls, attr='touch', route_name='{}.touch'.format(route_prefix),
                             permission='{}.touch'.format(permission_prefix))
 
@@ -3820,30 +5883,22 @@ class MasterView(View):
                                            "Download associated data for {}".format(model_title))
 
         # edit
-        if cls.editable or (legacy_mobile and cls.mobile_editable):
+        if cls.editable:
             config.add_tailbone_permission(permission_prefix, '{}.edit'.format(permission_prefix),
                                            "Edit {}".format(model_title))
-        if cls.editable:
             config.add_route('{}.edit'.format(route_prefix), '{}/edit'.format(instance_url_prefix))
             config.add_view(cls, attr='edit', route_name='{}.edit'.format(route_prefix),
                             permission='{}.edit'.format(permission_prefix))
-        if legacy_mobile and cls.mobile_editable:
-            config.add_route('mobile.{}.edit'.format(route_prefix), '/mobile{}/edit'.format(instance_url_prefix))
-            config.add_view(cls, attr='mobile_edit', route_name='mobile.{}.edit'.format(route_prefix),
-                            permission='{}.edit'.format(permission_prefix))
 
         # execute
-        if cls.executable or (legacy_mobile and cls.mobile_executable):
+        if cls.executable:
             config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix),
                                            "Execute {}".format(model_title))
-        if cls.executable:
-            config.add_route('{}.execute'.format(route_prefix), '{}/execute'.format(instance_url_prefix))
+            config.add_route('{}.execute'.format(route_prefix),
+                             '{}/execute'.format(instance_url_prefix),
+                             request_method='POST')
             config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix),
                             permission='{}.execute'.format(permission_prefix))
-        if legacy_mobile and cls.mobile_executable:
-            config.add_route('mobile.{}.execute'.format(route_prefix), '/mobile{}/execute'.format(instance_url_prefix))
-            config.add_view(cls, attr='mobile_execute', route_name='mobile.{}.execute'.format(route_prefix),
-                            permission='{}.execute'.format(permission_prefix))
 
         # delete
         if cls.deletable:
@@ -3878,57 +5933,279 @@ class MasterView(View):
 
         # create row
         if cls.has_rows:
-            if cls.rows_creatable or (legacy_mobile and cls.mobile_rows_creatable):
+            if cls.rows_creatable:
                 config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix),
                                                "Create new {} rows".format(model_title))
-            if cls.rows_creatable:
                 config.add_route('{}.create_row'.format(route_prefix), '{}/new-row'.format(instance_url_prefix))
                 config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix),
                                 permission='{}.create_row'.format(permission_prefix))
-            if legacy_mobile and cls.mobile_rows_creatable:
-                config.add_route('mobile.{}.create_row'.format(route_prefix), '/mobile{}/new-row'.format(instance_url_prefix))
-                config.add_view(cls, attr='mobile_create_row', route_name='mobile.{}.create_row'.format(route_prefix),
-                                permission='{}.create_row'.format(permission_prefix))
-                if cls.mobile_rows_quickable:
-                    config.add_route('mobile.{}.quick_row'.format(route_prefix), '/mobile{}/quick-row'.format(instance_url_prefix))
-                    config.add_view(cls, attr='mobile_quick_row', route_name='mobile.{}.quick_row'.format(route_prefix),
-                                    permission='{}.create_row'.format(permission_prefix))
+
+        # bulk-delete rows
+        # nb. must be defined before view_row b/c of url similarity
+        if cls.rows_bulk_deletable:
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.delete_rows'.format(permission_prefix),
+                                           "Bulk-delete {} from {}".format(
+                                               row_model_title_plural, model_title))
+            config.add_route('{}.delete_rows'.format(route_prefix),
+                             '{}/rows/delete'.format(instance_url_prefix),
+                             # TODO: should enforce this
+                             # request_method='POST'
+            )
+            config.add_view(cls, attr='bulk_delete_rows',
+                            route_name='{}.delete_rows'.format(route_prefix),
+                            permission='{}.delete_rows'.format(permission_prefix))
 
         # view row
         if cls.has_rows:
             if cls.rows_viewable:
-                config.add_route('{}.view_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix))
+                config.add_route('{}.view_row'.format(route_prefix),
+                                 '{}/rows/{{row_uuid}}'.format(instance_url_prefix))
                 config.add_view(cls, attr='view_row', route_name='{}.view_row'.format(route_prefix),
                                 permission='{}.view'.format(permission_prefix))
-            if legacy_mobile and cls.mobile_rows_viewable:
-                config.add_route('mobile.{}.view_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}'.format(url_prefix))
-                config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view_row'.format(route_prefix),
-                                permission='{}.view'.format(permission_prefix))
 
         # edit row
         if cls.has_rows:
-            if cls.rows_editable or (legacy_mobile and cls.mobile_rows_editable):
+            if cls.rows_editable or cls.rows_editable_but_not_directly:
                 config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix),
-                                               "Edit individual {} rows".format(model_title))
+                                               "Edit individual {}".format(row_model_title_plural))
             if cls.rows_editable:
-                config.add_route('{}.edit_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix))
+                config.add_route('{}.edit_row'.format(route_prefix),
+                                 '{}/rows/{{row_uuid}}/edit'.format(instance_url_prefix))
                 config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix),
                                 permission='{}.edit_row'.format(permission_prefix))
-            if legacy_mobile and cls.mobile_rows_editable:
-                config.add_route('mobile.{}.edit_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/edit'.format(url_prefix))
-                config.add_view(cls, attr='mobile_edit_row', route_name='mobile.{}.edit_row'.format(route_prefix),
-                                permission='{}.edit_row'.format(permission_prefix))
 
         # delete row
         if cls.has_rows:
-            if cls.rows_deletable or (legacy_mobile and cls.mobile_rows_deletable):
-                config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
-                                               "Delete individual {} rows".format(model_title))
             if cls.rows_deletable:
-                config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
+                config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
+                                               "Delete individual {}".format(row_model_title_plural))
+                config.add_route('{}.delete_row'.format(route_prefix),
+                                 '{}/rows/{{row_uuid}}/delete'.format(instance_url_prefix))
                 config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix),
                                 permission='{}.delete_row'.format(permission_prefix))
-            if legacy_mobile and cls.mobile_rows_deletable:
-                config.add_route('mobile.{}.delete_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
-                config.add_view(cls, attr='mobile_delete_row', route_name='mobile.{}.delete_row'.format(route_prefix),
-                                permission='{}.delete_row'.format(permission_prefix))
+
+    @classmethod
+    def _defaults_view(cls, config, **kwargs):
+        """
+        Provide default "view" configuration, i.e. for "viewable" things.
+        """
+        rattail_config = config.registry.settings.get('rattail_config')
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
+        model_title = cls.get_model_title()
+        model_title_plural = cls.get_model_title_plural()
+
+        # on windows/chrome we are seeing some caching when e.g.  user
+        # applies some filters, then views a record, then clicks back
+        # button, filters no longer are applied. so by default we
+        # instruct browser to never cache certain pages which contain
+        # a grid.  at this point only /index and /view
+        # cf. https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#non-predicate-arguments
+        prevent_cache = rattail_config.getbool('tailbone',
+                                               'prevent_cache_for_index_views',
+                                               default=True)
+
+        # nb. if caller specifies permission prefix, it's assumed they
+        # have registered it elsewhere
+        if 'permission_prefix' in kwargs:
+            permission_prefix = kwargs['permission_prefix']
+        else:
+            permission_prefix = cls.get_permission_prefix()
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.view'.format(permission_prefix),
+                                           "View details for {}".format(model_title))
+
+        if cls.has_pk_fields:
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.view_pk_fields'.format(permission_prefix),
+                                           "View all PK-type fields for {}".format(model_title_plural))
+        if cls.secure_global_objects:
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.view_global'.format(permission_prefix),
+                                           "View *global* {}".format(model_title_plural))
+
+        # view by grid index
+        config.add_route('{}.view_index'.format(route_prefix),
+                         '{}/view'.format(url_prefix))
+        config.add_view(cls, attr='view_index',
+                        route_name='{}.view_index'.format(route_prefix),
+                        permission='{}.view'.format(permission_prefix))
+
+        # view by record key
+        config.add_route('{}.view'.format(route_prefix),
+                         instance_url_prefix)
+        kwargs = {'http_cache': 0} if prevent_cache and cls.has_rows else {}
+        config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix),
+                        permission='{}.view'.format(permission_prefix),
+                        **kwargs)
+
+        # version history
+        if cls.has_versions and rattail_config and rattail_config.versioning_enabled():
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.versions'.format(permission_prefix),
+                                           "View version history for {}".format(model_title))
+            config.add_route('{}.versions'.format(route_prefix),
+                             '{}/versions/'.format(instance_url_prefix))
+            config.add_view(cls, attr='versions',
+                            route_name='{}.versions'.format(route_prefix),
+                            permission='{}.versions'.format(permission_prefix))
+            config.add_route('{}.version'.format(route_prefix),
+                             '{}/versions/{{txnid}}'.format(instance_url_prefix))
+            config.add_view(cls, attr='view_version',
+                            route_name='{}.version'.format(route_prefix),
+                            permission='{}.versions'.format(permission_prefix))
+
+            # revisions data (AJAX)
+            config.add_route(f'{route_prefix}.revisions_data',
+                             f'{instance_url_prefix}/revisions-data',
+                             request_method='GET')
+            config.add_view(cls, attr='revisions_data',
+                            route_name=f'{route_prefix}.revisions_data',
+                            permission=f'{permission_prefix}.versions',
+                            renderer='json')
+
+
+    @classmethod
+    def _defaults_edit_help(cls, config, **kwargs):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        model_title_plural = cls.get_model_title_plural()
+
+        # nb. if caller specifies permission prefix, it's assumed they
+        # have registered it elsewhere
+        if 'permission_prefix' in kwargs:
+            permission_prefix = kwargs['permission_prefix']
+        else:
+            permission_prefix = cls.get_permission_prefix()
+            config.add_tailbone_permission(permission_prefix,
+                                           '{}.edit_help'.format(permission_prefix),
+                                           "Edit help info for {}".format(model_title_plural))
+
+        # edit page help
+        config.add_route('{}.edit_help'.format(route_prefix),
+                         '{}/edit-help'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='edit_help',
+                        route_name='{}.edit_help'.format(route_prefix),
+                        renderer='json')
+
+        # edit field help
+        config.add_route('{}.edit_field_help'.format(route_prefix),
+                         '{}/edit-field-help'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='edit_field_help',
+                        route_name='{}.edit_field_help'.format(route_prefix),
+                        renderer='json')
+
+
+class ViewSupplement:
+    """
+    Base class for view "supplements" - which are sort of like plugins
+    which can "supplement" certain aspects of the view.
+
+    Instead of subclassing a master view and "supplementing" it via
+    method overrides etc., packages can instead define one or more
+    ``ViewSupplement`` classes.  All such supplements are registered
+    so they can be located; their logic is then merged into the
+    appropriate master view at runtime.
+
+    The primary use case for this is within integration packages, such
+    as tailbone-corepos and the like.  A truly custom app might want
+    supplemental logic from multiple integration packages, in which
+    case the "subclassing" approach sort of falls apart.
+
+    :attribute:: labels
+
+       This can be a dict of extra field labels to be used by the
+       master view.  Same meaning as for
+       :attr:`tailbone.views.master.MasterView.labels`.
+    """
+    labels = {}
+
+    def __init__(self, master):
+        self.master = master
+        self.request = master.request
+        self.app = master.app
+        self.model = master.model
+        self.rattail_config = master.rattail_config
+        self.Session = master.Session
+
+    def get_rattail_app(self):
+        return self.master.get_rattail_app()
+
+    def get_grid_query(self, query):
+        """
+        Return the "base" query for the grid.  This is invoked from
+        within :meth:`tailbone.views.master.MasterView.query()`.
+
+        A typical grid query is
+        essentially:
+
+        .. code-block:: sql
+
+           SELECT * FROM mytable
+
+        But when a schema extension is in "primary" use, meaning for
+        instance one of the main grid columns displays extension data,
+        it may be helpful for the base query to join the extension
+        table, as opposed to doing a "just in time" join based on
+        sorting and/or filters:
+
+        .. code-block:: sql
+
+           SELECT * FROM mytable m
+           LEFT OUTER JOIN myextension e ON e.uuid = m.uuid
+
+        This is accomplished by subjecting the current base query to a
+        join, e.g. something like::
+
+           model = self.app.model
+           query = query.outerjoin(model.MyExtension)
+           return query
+        """
+        return query
+
+    def configure_grid(self, g):
+        """
+        Configure the grid as needed, e.g. add columns, and set
+        renderers etc. for them.
+        """
+
+    def configure_form(self, f):
+        """
+        Configure the form as needed, e.g. add fields, and set
+        renderers, default values etc. for them.
+        """
+
+    def objectify(self, obj, form, data):
+        return obj
+
+    def get_xref_buttons(self, obj):
+        return []
+
+    def get_xref_links(self, obj):
+        return []
+
+    def get_context_menu_items(self, obj):
+        return []
+
+    def get_version_child_classes(self):
+        """
+        Return a list of additional "version child classes" which are
+        to be taken into account when displaying version history for a
+        given record.
+
+        See also
+        :meth:`tailbone.views.master.MasterView.get_version_child_classes()`.
+        """
+        return []
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+
+    @classmethod
+    def _defaults(cls, config):
+        config.add_tailbone_view_supplement(cls.route_prefix, cls)
diff --git a/tailbone/views/members.py b/tailbone/views/members.py
index 070b543a..46ed7e4b 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,25 +24,28 @@
 Member Views
 """
 
-from __future__ import unicode_literals, absolute_import
+from collections import OrderedDict
 
-import six
 import sqlalchemy as sa
+import sqlalchemy_continuum as continuum
 
 from rattail.db import model
+from rattail.db.model import MembershipType, Member, MemberEquityPayment
 
 from deform import widget as dfwidget
+from webhelpers2.html import tags
 
-from tailbone import grids
+from tailbone import grids, forms
 from tailbone.views import MasterView
 
 
-class MemberView(MasterView):
+class MembershipTypeView(MasterView):
     """
-    Master view for the Member class.
+    Master view for Membership Types
     """
-    model_class = model.Member
-    is_contact = True
+    model_class = MembershipType
+    route_prefix = 'membership_types'
+    url_prefix = '/membership-types'
     has_versions = True
 
     labels = {
@@ -51,38 +54,144 @@ class MemberView(MasterView):
 
     grid_columns = [
         'number',
-        'id',
+        'name',
+    ]
+
+    has_rows = True
+    model_row_class = Member
+    rows_title = "Members"
+
+    row_grid_columns = [
+        '_member_key_',
         'person',
-        'customer',
-        'email',
-        'phone',
         'active',
         'equity_current',
+        'equity_total',
         'joined',
         'withdrew',
     ]
 
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+
+        g.set_sort_defaults('number')
+
+        g.set_link('number')
+        g.set_link('name')
+
+    def get_row_data(self, memtype):
+        """ """
+        model = self.model
+        return self.Session.query(model.Member)\
+                           .filter(model.Member.membership_type == memtype)
+
+    def get_parent(self, member):
+        return member.membership_type
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        g.filters['active'].default_active = True
+        g.filters['active'].default_verb = 'is_true'
+
+        g.set_link('person')
+
+    def row_view_action_url(self, member, i):
+        return self.request.route_url('members.view', uuid=member.uuid)
+
+
+class MemberView(MasterView):
+    """
+    Master view for the Member class.
+    """
+    model_class = Member
+    is_contact = True
+    touchable = True
+    has_versions = True
+    configurable = True
+    supports_autocomplete = True
+
+    labels = {
+        'id': "ID",
+        'person': "Account Holder",
+    }
+
+    grid_columns = [
+        '_member_key_',
+        'person',
+        'membership_type',
+        'active',
+        'equity_current',
+        'joined',
+        'withdrew',
+        'equity_total',
+    ]
+
     form_fields = [
-        'number',
-        'id',
+        '_member_key_',
         'person',
         'customer',
         'default_email',
         'default_phone',
+        'membership_type',
         'active',
+        'equity_total',
         'equity_current',
         'equity_payment_due',
         'joined',
         'withdrew',
     ]
 
-    def configure_grid(self, g):
-        super(MemberView, self).configure_grid(g)
+    has_rows = True
+    model_row_class = MemberEquityPayment
+    rows_title = "Equity Payments"
 
+    row_grid_columns = [
+        'received',
+        'amount',
+        'description',
+        'source',
+        'transaction_identifier',
+    ]
+
+    def should_expose_quickie_search(self):
+        if self.expose_quickie_search:
+            return True
+        app = self.get_rattail_app()
+        return app.get_people_handler().should_expose_quickie_search()
+
+    def get_quickie_perm(self):
+        return 'people.quickie'
+
+    def get_quickie_url(self):
+        return self.request.route_url('people.quickie')
+
+    def get_quickie_placeholder(self):
+        app = self.get_rattail_app()
+        return app.get_people_handler().get_quickie_search_placeholder()
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+        route_prefix = self.get_route_prefix()
+        model = self.model
+
+        # member key
+        field = self.get_member_key_field()
+        g.filters[field].default_active = True
+        g.filters[field].default_verb = 'equal'
+        g.set_sort_defaults(field)
+        g.set_link(field)
+
+        # person
+        g.set_link('person')
         g.set_joiner('person', lambda q: q.outerjoin(model.Person))
         g.set_sorter('person', model.Person.display_name)
         g.set_filter('person', model.Person.display_name)
 
+        # customer
+        g.set_link('customer')
         g.set_joiner('customer', lambda q: q.outerjoin(model.Customer))
         g.set_sorter('customer', model.Customer.name)
         g.set_filter('customer', model.Customer.name)
@@ -91,42 +200,92 @@ class MemberView(MasterView):
         g.filters['active'].default_verb = 'is_true'
 
         # phone
+        g.set_label('phone', "Phone Number")
         g.set_joiner('phone', lambda q: q.outerjoin(model.MemberPhoneNumber, sa.and_(
             model.MemberPhoneNumber.parent_uuid == model.Member.uuid,
             model.MemberPhoneNumber.preference == 1)))
-        g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.MemberPhoneNumber.number, d)())
+        g.set_sorter('phone', model.MemberPhoneNumber.number)
         g.set_filter('phone', model.MemberPhoneNumber.number,
                      factory=grids.filters.AlchemyPhoneNumberFilter)
-        g.set_label('phone', "Phone Number")
 
         # email
+        g.set_label('email', "Email Address")
         g.set_joiner('email', lambda q: q.outerjoin(model.MemberEmailAddress, sa.and_(
             model.MemberEmailAddress.parent_uuid == model.Member.uuid,
             model.MemberEmailAddress.preference == 1)))
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.MemberEmailAddress.address, d)())
+        g.set_sorter('email', model.MemberEmailAddress.address)
         g.set_filter('email', model.MemberEmailAddress.address)
-        g.set_label('email', "Email Address")
 
-        g.set_sort_defaults('number')
+        # membership_type
+        g.set_joiner('membership_type', lambda q: q.outerjoin(model.MembershipType))
+        g.set_sorter('membership_type', model.MembershipType.name)
+        g.set_filter('membership_type', model.MembershipType.name,
+                     label="Membership Type Name")
 
-        g.set_link('person')
-        g.set_link('customer')
+        if (self.request.has_perm('people.view_profile')
+            and self.should_link_straight_to_profile()):
+
+            # add View Raw action
+            url = lambda r, i: self.request.route_url(
+                f'{route_prefix}.view', **self.get_action_route_kwargs(r))
+            # nb. insert to slot 1, just after normal View action
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
+
+        # equity_total
+        # TODO: should make this configurable
+        # g.set_type('equity_total', 'currency')
+        g.set_renderer('equity_total', self.render_equity_total)
+        g.remove_sorter('equity_total')
+        g.remove_filter('equity_total')
+
+    def render_equity_total(self, member, field):
+        app = self.get_rattail_app()
+        equity = app.get_membership_handler().get_equity_total(member, cached=False)
+        return app.render_currency(equity)
+
+    def default_view_url(self):
+        if (self.request.has_perm('people.view_profile')
+            and self.should_link_straight_to_profile()):
+            app = self.get_rattail_app()
+
+            def url(member, i):
+                person = app.get_person(member)
+                if person:
+                    return self.request.route_url(
+                        'people.view_profile', uuid=person.uuid,
+                        _anchor='member')
+                return self.get_action_url('view', member)
+
+            return url
+
+        return super().default_view_url()
+
+    def should_link_straight_to_profile(self):
+        return self.rattail_config.getbool('rattail',
+                                           'members.straight_to_profile',
+                                           default=False)
 
     def grid_extra_class(self, member, i):
+        """ """
         if not member.active:
             return 'warning'
         if member.equity_current is False:
             return 'notice'
 
     def configure_form(self, f):
-        super(MemberView, self).configure_form(f)
+        """ """
+        super().configure_form(f)
+        model = self.model
         member = f.model_instance
 
         # date fields
         f.set_type('joined', 'date_jquery')
+        f.set_type('withdrew', 'date_jquery')
+
+        # equity fields
+        f.set_renderer('equity_total', self.render_equity_total)
         f.set_type('equity_payment_due', 'date_jquery')
         f.set_type('equity_last_paid', 'date_jquery')
-        f.set_type('withdrew', 'date_jquery')
 
         # person
         if self.creating or self.editing:
@@ -134,7 +293,7 @@ class MemberView(MasterView):
                 f.replace('person', 'person_uuid')
                 people = self.Session.query(model.Person)\
                                      .order_by(model.Person.display_name)
-                values = [(p.uuid, six.text_type(p))
+                values = [(p.uuid, str(p))
                           for p in people]
                 require = False
                 if not require:
@@ -151,7 +310,7 @@ class MemberView(MasterView):
                 f.replace('customer', 'customer_uuid')
                 customers = self.Session.query(model.Customer)\
                                           .order_by(model.Customer.name)
-                values = [(c.uuid, six.text_type(c))
+                values = [(c.uuid, str(c))
                           for c in customers]
                 require = False
                 if not require:
@@ -172,6 +331,9 @@ class MemberView(MasterView):
         if not self.creating and member.phones:
             f.set_default('default_phone', member.phones[0].number)
 
+        # membership_type
+        f.set_renderer('membership_type', self.render_membership_type)
+
         if self.creating:
             f.remove_fields(
                 'equity_total',
@@ -180,14 +342,242 @@ class MemberView(MasterView):
                 'withdrew',
             )
 
+    def render_equity_total(self, member, field):
+        app = self.get_rattail_app()
+        total = sum([payment.amount for payment in member.equity_payments])
+        return app.render_currency(total)
+
+    def template_kwargs_view(self, **kwargs):
+        """ """
+        kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
+        member = kwargs['instance']
+
+        people = OrderedDict()
+        person = app.get_person(member)
+        if person:
+            people.setdefault(person.uuid, person)
+        customer = app.get_customer(member)
+        if customer:
+            person = app.get_person(customer)
+            if person:
+                people.setdefault(person.uuid, person)
+        kwargs['show_profiles_people'] = list(people.values())
+
+        return kwargs
+
     def render_default_email(self, member, field):
+        """ """
         if member.emails:
             return member.emails[0].address
 
     def render_default_phone(self, member, field):
+        """ """
         if member.phones:
             return member.phones[0].number
 
+    def render_membership_type(self, member, field):
+        memtype = getattr(member, field)
+        if not memtype:
+            return
+        text = str(memtype)
+        url = self.request.route_url('membership_types.view', uuid=memtype.uuid)
+        return tags.link_to(text, url)
+
+    def get_row_data(self, member):
+        """ """
+        model = self.model
+        return self.Session.query(model.MemberEquityPayment)\
+                               .filter(model.MemberEquityPayment.member == member)
+
+    def get_parent(self, payment):
+        return payment.member
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        g.set_type('amount', 'currency')
+
+        g.set_sort_defaults('received', 'desc')
+
+    def row_view_action_url(self, payment, i):
+        return self.request.route_url('member_equity_payments.view',
+                                      uuid=payment.uuid)
+
+    def configure_get_simple_settings(self):
+        """ """
+        return [
+
+            # General
+            {'section': 'rattail',
+             'option': 'members.key_field'},
+            {'section': 'rattail',
+             'option': 'members.key_label'},
+            {'section': 'rattail',
+             'option': 'members.straight_to_profile',
+             'type': bool},
+
+            # Relationships
+            {'section': 'rattail',
+             'option': 'members.max_one_per_person',
+             'type': bool},
+        ]
+
+
+class MemberEquityPaymentView(MasterView):
+    """
+    Master view for the MemberEquityPayment class.
+    """
+    model_class = MemberEquityPayment
+    route_prefix = 'member_equity_payments'
+    url_prefix = '/member-equity-payments'
+    supports_grid_totals = True
+    has_versions = True
+
+    labels = {
+        'status_code': "Status",
+    }
+
+    grid_columns = [
+        'received',
+        '_member_key_',
+        'member',
+        'amount',
+        'description',
+        'source',
+        'transaction_identifier',
+        'status_code',
+    ]
+
+    form_fields = [
+        '_member_key_',
+        'member',
+        'amount',
+        'received',
+        'description',
+        'source',
+        'transaction_identifier',
+        'status_code',
+    ]
+
+    def query(self, session):
+        """ """
+        query = super().query(session)
+        model = self.model
+
+        query = query.join(model.Member)
+
+        return query
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+        model = self.model
+
+        # member_key
+        field = self.get_member_key_field()
+        attr = getattr(model.Member, field)
+        g.set_renderer(field, self.render_member_key)
+        g.set_filter(field, attr,
+                     label=self.get_member_key_label(),
+                     default_active=True,
+                     default_verb='equal')
+        g.set_sorter(field, attr)
+
+        # member (name)
+        g.set_joiner('member', lambda q: q.outerjoin(model.Person))
+        g.set_sorter('member', model.Person.display_name)
+        g.set_link('member')
+        g.set_filter('member', model.Person.display_name,
+                     label="Member Name")
+
+        g.set_type('amount', 'currency')
+
+        g.set_sort_defaults('received', 'desc')
+        g.set_link('received')
+
+        # description
+        g.set_link('description')
+
+        g.set_link('transaction_identifier')
+
+        # status_code
+        g.set_enum('status_code', model.MemberEquityPayment.STATUS)
+
+    def render_member_key(self, payment, field):
+        key = getattr(payment.member, field)
+        return key
+
+    def fetch_grid_totals(self):
+        app = self.get_rattail_app()
+        results = self.get_effective_data()
+        total = sum([payment.amount for payment in results])
+        return {'totals_display': app.render_currency(total)}
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+        model = self.model
+        payment = f.model_instance
+
+        # member_key
+        field = self.get_member_key_field()
+        f.set_renderer(field, self.render_member_key)
+        f.set_readonly(field)
+
+        # member
+        if self.creating:
+            f.replace('member', 'member_uuid')
+            member_display = ""
+            if self.request.method == 'POST':
+                if self.request.POST.get('member_uuid'):
+                    member = self.Session.get(model.Member,
+                                              self.request.POST['member_uuid'])
+                    if member:
+                        member_display = str(member)
+            elif self.editing:
+                member_display = str(payment.member or '')
+            members_url = self.request.route_url('members.autocomplete')
+            f.set_widget('member_uuid', forms.widgets.JQueryAutocompleteWidget(
+                field_display=member_display, service_url=members_url))
+            f.set_label('member_uuid', "Member")
+        else:
+            f.set_readonly('member')
+            f.set_renderer('member', self.render_member)
+
+        # amount
+        f.set_type('amount', 'currency')
+
+        # received
+        if self.creating:
+            f.set_type('received', 'date_jquery')
+        else:
+            f.set_readonly('received')
+
+        # status_code
+        f.set_enum('status_code', model.MemberEquityPayment.STATUS)
+
+    def get_version_diff_enums(self, version):
+        """ """
+        model = self.model
+        cls = continuum.parent_class(version.__class__)
+
+        if cls is model.MemberEquityPayment:
+            return {'status_code': model.MemberEquityPayment.STATUS}
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    MembershipTypeView = kwargs.get('MembershipTypeView', base['MembershipTypeView'])
+    MembershipTypeView.defaults(config)
+
+    MemberView = kwargs.get('MemberView', base['MemberView'])
+    MemberView.defaults(config)
+
+    MemberEquityPaymentView = kwargs.get('MemberEquityPaymentView', base['MemberEquityPaymentView'])
+    MemberEquityPaymentView.defaults(config)
+
 
 def includeme(config):
-    MemberView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py
new file mode 100644
index 00000000..b606e4e7
--- /dev/null
+++ b/tailbone/views/menus.py
@@ -0,0 +1,189 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Base class for Config Views
+"""
+
+import json
+
+import sqlalchemy as sa
+
+from tailbone.views import View
+from tailbone.db import Session
+
+
+class MenuConfigView(View):
+    """
+    View for configuring the main menu.
+    """
+
+    def configure(self):
+        """
+        Main entry point to menu config views.
+        """
+        if self.request.method == 'POST':
+            if self.request.POST.get('remove_settings'):
+                self.configure_remove_settings()
+                self.request.session.flash("All settings for Menus have been removed.",
+                                           'warning')
+                return self.redirect(self.request.current_route_url())
+            else:
+                data = self.request.POST
+
+                # gather/save settings
+                settings = self.configure_gather_settings(data)
+                self.configure_remove_settings()
+                self.configure_save_settings(settings)
+                self.request.session.flash("Settings have been saved.")
+                return self.redirect(self.request.current_route_url())
+
+        context = {
+            'config_title': "Menus",
+            'index_title': "App Details",
+            'index_url': self.request.route_url('appinfo'),
+        }
+
+        possible_index_options = sorted(
+            self.request.registry.settings['tailbone_index_pages'],
+            key=lambda p: p['label'])
+
+        index_options = []
+        for option in possible_index_options:
+            perm = option['permission']
+            option['perm'] = perm
+            option['url'] = self.request.route_url(option['route'])
+            index_options.append(option)
+
+        context['index_route_options'] = index_options
+        return context
+
+    def configure_gather_settings(self, data):
+        app = self.get_rattail_app()
+        web = app.get_web_handler()
+        menus = web.get_menu_handler()
+
+        settings = [{'name': 'tailbone.menu.from_settings',
+                     'value': 'true'}]
+
+        main_keys = []
+        for topitem in json.loads(data['menus']):
+            key = menus._make_menu_key(self.rattail_config, topitem['title'])
+            main_keys.append(key)
+
+            settings.extend([
+                {'name': 'tailbone.menu.menu.{}.label'.format(key),
+                 'value': topitem['title']},
+            ])
+
+            item_keys = []
+            for item in topitem['items']:
+                item_type = item.get('type', 'item')
+                if item_type == 'item':
+                    if item.get('route'):
+                        item_key = item['route']
+                    else:
+                        item_key = menus._make_menu_key(self.rattail_config, item['title'])
+                    item_keys.append(item_key)
+
+                    settings.extend([
+                        {'name': 'tailbone.menu.menu.{}.item.{}.label'.format(key, item_key),
+                         'value': item['title']},
+                    ])
+
+                    if item.get('route'):
+                        settings.extend([
+                            {'name': 'tailbone.menu.menu.{}.item.{}.route'.format(key, item_key),
+                             'value': item['route']},
+                        ])
+
+                    elif item.get('url'):
+                        settings.extend([
+                            {'name': 'tailbone.menu.menu.{}.item.{}.url'.format(key, item_key),
+                             'value': item['url']},
+                        ])
+
+                    if item.get('perm'):
+                        settings.extend([
+                            {'name': 'tailbone.menu.menu.{}.item.{}.perm'.format(key, item_key),
+                             'value': item['perm']},
+                        ])
+
+                elif item_type == 'sep':
+                    item_keys.append('SEP')
+
+            settings.extend([
+                {'name': 'tailbone.menu.menu.{}.items'.format(key),
+                 'value': ' '.join(item_keys)},
+            ])
+
+        settings.append({'name': 'tailbone.menu.menus',
+                         'value': ' '.join(main_keys)})
+        return settings
+
+    def configure_remove_settings(self):
+        model = self.model
+        Session.query(model.Setting)\
+               .filter(sa.or_(
+                   model.Setting.name == 'tailbone.menu.from_settings',
+                   model.Setting.name == 'tailbone.menu.menus',
+                   model.Setting.name.like('tailbone.menu.menu.%.label'),
+                   model.Setting.name.like('tailbone.menu.menu.%.items'),
+                   model.Setting.name.like('tailbone.menu.menu.%.item.%.label'),
+                   model.Setting.name.like('tailbone.menu.menu.%.item.%.route'),
+                   model.Setting.name.like('tailbone.menu.menu.%.item.%.perm'),
+                   model.Setting.name.like('tailbone.menu.menu.%.item.%.url')))\
+               .delete(synchronize_session=False)
+
+    def configure_save_settings(self, settings):
+        model = self.model
+        session = Session()
+        for setting in settings:
+            session.add(model.Setting(name=setting['name'],
+                                      value=setting['value']))
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+
+    @classmethod
+    def _defaults(cls, config):
+
+        # configure menus
+        config.add_route('configure_menus',
+                         '/configure-menus')
+        config.add_view(cls, attr='configure',
+                        route_name='configure_menus',
+                        permission='appinfo.configure',
+                        renderer='/configure-menus.mako')
+        config.add_tailbone_config_page('configure_menus', "Menus", 'admin')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    MenuConfigView = kwargs.get('MenuConfigView', base['MenuConfigView'])
+    MenuConfigView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py
index 5f814569..9199c025 100644
--- a/tailbone/views/messages.py
+++ b/tailbone/views/messages.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,25 +24,19 @@
 Message Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 from rattail.time import localtime
 
 import colander
 from deform import widget as dfwidget
-from pyramid import httpexceptions
 from webhelpers2.html import tags, HTML
 
-# from tailbone import forms
 from tailbone.db import Session
 from tailbone.views import MasterView
 from tailbone.util import raw_datetime
 
 
-class MessagesView(MasterView):
+class MessageView(MasterView):
     """
     Base class for message views.
     """
@@ -52,6 +46,7 @@ class MessagesView(MasterView):
     checkboxes = True
     replying = False
     reply_header_sent_format = '%a %d %b %Y at %I:%M %p'
+    listable = False
 
     grid_columns = [
         'subject',
@@ -86,15 +81,15 @@ class MessagesView(MasterView):
 
     def index(self):
         if not self.request.user:
-            raise httpexceptions.HTTPForbidden
-        return super(MessagesView, self).index()
+            raise self.forbidden()
+        return super().index()
 
     def get_instance(self):
         if not self.request.user:
-            raise httpexceptions.HTTPForbidden
-        message = super(MessagesView, self).get_instance()
+            raise self.forbidden()
+        message = super().get_instance()
         if not self.associated_with(message):
-            raise httpexceptions.HTTPForbidden
+            raise self.forbidden()
         return message
 
     def associated_with(self, message):
@@ -111,11 +106,18 @@ class MessagesView(MasterView):
                       .filter(model.MessageRecipient.recipient == self.request.user)
 
     def configure_grid(self, g):
-        
-        g.joiners['sender'] = lambda q: q.join(model.User, model.User.uuid == model.Message.sender_uuid).outerjoin(model.Person)
-        g.filters['sender'] = g.make_filter('sender', model.Person.display_name,
-                                            default_active=True, default_verb='contains')
-        g.sorters['sender'] = g.make_sorter(model.Person.display_name)
+        super().configure_grid(g)
+        model = self.model
+
+        # sender
+        g.set_joiner('sender',
+                     lambda q: q.join(model.User,
+                                      model.User.uuid == model.Message.sender_uuid)\
+                     .outerjoin(model.Person))
+        g.set_sorter('sender', model.Person.display_name)
+        g.set_filter('sender', model.Person.display_name,
+                     default_active=True,
+                     default_verb='contains')
 
         g.filters['subject'].default_active = True
         g.filters['subject'].default_verb = 'contains'
@@ -138,7 +140,7 @@ class MessagesView(MasterView):
         sender = message.sender
         if sender is self.request.user:
             return 'you'
-        return six.text_type(sender)
+        return str(sender)
 
     def render_subject_bold(self, message, field):
         if not message.subject:
@@ -163,8 +165,6 @@ class MessagesView(MasterView):
         if not recipients:
             return ""
 
-        use_buefy = self.get_use_buefy()
-
         # remove current user from displayed list, even if they're a recipient
         recips = [r for r in recipients
                   if r.recipient is not self.request.user]
@@ -181,30 +181,24 @@ class MessagesView(MasterView):
         # client-side JS allowing the user to view all if they want
         max_display = 5
         if len(recips) > max_display:
-            if use_buefy:
-                basic = HTML.tag('span', c="{}, ".format(', '.join(recips[:max_display-1])))
-                more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', **{
-                    'v-show': '!showingAllRecipients',
-                    '@click.prevent': 'showMoreRecipients()',
-                })
-                everyone = HTML.tag('span', c=', '.join(recips[max_display-1:]), **{
-                    'v-show': 'showingAllRecipients',
-                    '@click': 'hideMoreRecipients()',
-                    'class_': 'everyone',
-                })
-                return HTML.tag('div', c=[basic, more, everyone])
-            else:
-                basic = HTML.literal("{}, ".format(', '.join(recips[:max_display-1])))
-                more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', class_='more')
-                everyone = HTML.tag('span', class_='everyone', c=', '.join(recips[max_display-1:]))
-                return basic + more + everyone
+            basic = HTML.tag('span', c="{}, ".format(', '.join(recips[:max_display-1])))
+            more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', **{
+                'v-show': '!showingAllRecipients',
+                '@click.prevent': 'showMoreRecipients()',
+            })
+            everyone = HTML.tag('span', c=', '.join(recips[max_display-1:]), **{
+                'v-show': 'showingAllRecipients',
+                '@click': 'hideMoreRecipients()',
+                'class_': 'everyone',
+            })
+            return HTML.tag('div', c=[basic, more, everyone])
 
         # show the full list if there are few enough recipients for that
         return ', '.join(recips)
 
     # TODO!!
     # def make_form(self, instance, **kwargs):
-    #     form = super(MessagesView, self).make_form(instance, **kwargs)
+    #     form = super(MessageView, self).make_form(instance, **kwargs)
     #     if self.creating:
     #         form.id = 'new-message'
     #         form.cancel_url = self.request.get_referrer(default=self.request.route_url('messages.inbox'))
@@ -212,16 +206,10 @@ class MessagesView(MasterView):
     #     return form
 
     def configure_form(self, f):
-        super(MessagesView, self).configure_form(f)
-        use_buefy = self.get_use_buefy()
+        super().configure_form(f)
 
         f.submit_label = "Send Message"
 
-        if not use_buefy:
-            # we have custom logic to disable submit button
-            f.auto_disable = False
-            f.auto_disable_save = False
-
         # TODO: A fair amount of this still seems hacky...
 
         f.set_renderer('sender', self.render_sender)
@@ -234,16 +222,17 @@ class MessagesView(MasterView):
         f.set_label('recipients', "To")
 
         # subject
-        if use_buefy:
-            f.set_renderer('subject', self.render_subject_bold)
-            if self.creating:
-                f.set_widget('subject', dfwidget.TextInputWidget(
-                    placeholder="please enter a subject",
-                    autocomplete='off'))
-                f.set_required('subject')
+        f.set_renderer('subject', self.render_subject_bold)
+        if self.creating:
+            f.set_widget('subject', dfwidget.TextInputWidget(
+                placeholder="please enter a subject",
+                autocomplete='off',
+                attributes={'@keydown.native': 'subjectKeydown'}))
+            f.set_required('subject')
 
         # body
-        f.set_widget('body', dfwidget.TextAreaWidget(cols=50, rows=15))
+        f.set_widget('body', dfwidget.TextAreaWidget(
+            cols=50, rows=15, attributes={'ref': 'messageBody'}))
 
         if self.creating:
             f.remove('sender', 'sent')
@@ -252,10 +241,7 @@ class MessagesView(MasterView):
             f.insert_after('recipients', 'set_recipients')
             f.remove('recipients')
             f.set_node('set_recipients', colander.SchemaNode(colander.Set()))
-            if use_buefy:
-                f.set_widget('set_recipients', RecipientsWidgetBuefy())
-            else:
-                f.set_widget('set_recipients', RecipientsWidget())
+            f.set_widget('set_recipients', RecipientsWidget())
             f.set_label('set_recipients', "To")
 
             if self.replying:
@@ -277,11 +263,11 @@ class MessagesView(MasterView):
                     value = [r[0] for r in value]
                     if old_message.sender is not self.request.user and old_message.sender.active:
                         value.insert(0, old_message.sender_uuid)
-                    f.set_default('set_recipients', ','.join(value))
+                    f.set_default('set_recipients', value)
 
                 # Just a normal reply, to sender only.
                 elif self.filter_reply_recipient(old_message.sender):
-                    f.set_default('set_recipients', old_message.sender.uuid)
+                    f.set_default('set_recipients', [old_message.sender.uuid])
 
                 # TODO?
                 # # Set focus to message body instead of recipients, when replying.
@@ -293,14 +279,14 @@ class MessagesView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        message = super(MessagesView, self).objectify(form, data)
+        message = super().objectify(form, data)
 
         if self.creating:
             if self.request.user:
                 message.sender = self.request.user
 
             for uuid in data['set_recipients']:
-                user = self.Session.query(model.User).get(uuid)
+                user = self.Session.get(model.User, uuid)
                 if user:
                     message.add_recipient(user, status=self.enum.MESSAGE_STATUS_INBOX)
 
@@ -340,11 +326,9 @@ class MessagesView(MasterView):
                 return recipient
 
     def template_kwargs_create(self, **kwargs):
-        use_buefy = self.get_use_buefy()
 
         recips = self.get_available_recipients()
-        if use_buefy:
-            kwargs['recipient_display_map'] = recips
+        kwargs['recipient_display_map'] = recips
         recips = list(recips.items())
         recips.sort(key=self.recipient_sortkey)
         kwargs['available_recipients'] = recips
@@ -352,9 +336,8 @@ class MessagesView(MasterView):
         if self.replying:
             kwargs['original_message'] = self.get_instance()
 
-        if use_buefy:
-            kwargs['index_url'] = None
-            kwargs['index_title'] = "New Message"
+        kwargs['index_url'] = None
+        kwargs['index_title'] = "New Message"
         return kwargs
 
     def recipient_sortkey(self, recip):
@@ -410,7 +393,7 @@ class MessagesView(MasterView):
         message = self.get_instance()
         recipient = self.get_recipient(message)
         if not recipient:
-            raise httpexceptions.HTTPForbidden
+            raise self.forbidden()
 
         dest = self.request.GET.get('dest')
         if dest not in ('inbox', 'archive'):
@@ -469,8 +452,11 @@ class MessagesView(MasterView):
 
         cls._defaults(config)
 
+# TODO: deprecate / remove this
+MessagesView = MessageView
 
-class InboxView(MessagesView):
+
+class InboxView(MessageView):
     """
     Inbox message view.
     """
@@ -482,11 +468,11 @@ class InboxView(MessagesView):
         return self.request.route_url('messages.inbox')
 
     def query(self, session):
-        q = super(InboxView, self).query(session)
+        q = super().query(session)
         return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_INBOX)
 
 
-class ArchiveView(MessagesView):
+class ArchiveView(MessageView):
     """
     Archived message view.
     """
@@ -498,11 +484,11 @@ class ArchiveView(MessagesView):
         return self.request.route_url('messages.archive')
 
     def query(self, session):
-        q = super(ArchiveView, self).query(session)
+        q = super().query(session)
         return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_ARCHIVE)
 
 
-class SentView(MessagesView):
+class SentView(MessageView):
     """
     Sent messages view.
     """
@@ -519,7 +505,7 @@ class SentView(MessagesView):
                       .filter(model.Message.sender == self.request.user)
 
     def configure_grid(self, g):
-        super(SentView, self).configure_grid(g)
+        super().configure_grid(g)
         g.filters['sender'].default_active = False
         g.joiners['recipients'] = lambda q: q.join(model.MessageRecipient)\
                                              .join(model.User, model.User.uuid == model.MessageRecipient.recipient_uuid)\
@@ -528,30 +514,16 @@ class SentView(MessagesView):
                                                 default_active=True, default_verb='contains')
 
 
-class RecipientsWidget(dfwidget.TextInputWidget):
-
-    def deserialize(self, field, pstruct):
-        if pstruct is colander.null:
-            return []
-        elif not isinstance(pstruct, six.string_types):
-            raise colander.Invalid(field.schema, "Pstruct is not a string")
-        if self.strip:
-            pstruct = pstruct.strip()
-        if not pstruct:
-            return []
-        return pstruct.split(',')
-
-
-class RecipientsWidgetBuefy(dfwidget.Widget):
+class RecipientsWidget(dfwidget.Widget):
     """
-    Custom "message recipients" widget, for use with Buefy / Vue.js themes.
+    Custom "message recipients" widget, for use with Vue.js themes.
     """
-    template = 'message_recipients_buefy'
+    template = 'message_recipients'
 
     def deserialize(self, field, pstruct):
         if pstruct is colander.null:
             return colander.null
-        if not isinstance(pstruct, six.string_types):
+        if not isinstance(pstruct, str):
             raise colander.Invalid(field.schema, "Pstruct is not a string")
         if not pstruct:
             return colander.null
@@ -566,23 +538,36 @@ class RecipientsWidgetBuefy(dfwidget.Widget):
         return field.renderer(template, **values)
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
 
-    config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages")
+    config.add_tailbone_permission('messages', 'messages.list',
+                                   "List/Search Messages")
 
     # inbox
+    InboxView = kwargs.get('InboxView', base['InboxView'])
     config.add_route('messages.inbox', '/messages/inbox/')
-    config.add_view(InboxView, attr='index', route_name='messages.inbox',
+    config.add_view(InboxView, attr='index',
+                    route_name='messages.inbox',
                     permission='messages.list')
 
     # archive
+    ArchiveView = kwargs.get('ArchiveView', base['ArchiveView'])
     config.add_route('messages.archive', '/messages/archive/')
-    config.add_view(ArchiveView, attr='index', route_name='messages.archive',
+    config.add_view(ArchiveView, attr='index',
+                    route_name='messages.archive',
                     permission='messages.list')
 
     # sent
+    SentView = kwargs.get('SentView', base['SentView'])
     config.add_route('messages.sent', '/messages/sent/')
-    config.add_view(SentView, attr='index', route_name='messages.sent',
+    config.add_view(SentView, attr='index',
+                    route_name='messages.sent',
                     permission='messages.list')
 
-    MessagesView.defaults(config)
+    MessageView = kwargs.get('MessageView', base['MessageView'])
+    MessageView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index f21a88b6..405b1ca3 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,37 +24,44 @@
 Person Views
 """
 
-from __future__ import unicode_literals, absolute_import
+import datetime
+import logging
+from collections import OrderedDict
 
-import six
 import sqlalchemy as sa
 from sqlalchemy import orm
+import sqlalchemy_continuum as continuum
 
-from rattail.db import model, api
-from rattail.time import localtime
-from rattail.util import OrderedDict
+from rattail.db import api
+from rattail.db.model import Person, PersonNote, MergePeopleRequest
+from rattail.util import simple_error
 
 import colander
-from pyramid.httpexceptions import HTTPFound, HTTPNotFound
 from webhelpers2.html import HTML, tags
 
 from tailbone import forms, grids
-from tailbone.views import MasterView, AutocompleteView
+from tailbone.db import TrainwreckSession
+from tailbone.views import MasterView
+from tailbone.util import raw_datetime
 
 
-class PeopleView(MasterView):
+log = logging.getLogger(__name__)
+
+
+class PersonView(MasterView):
     """
     Master view for the Person class.
     """
-    model_class = model.Person
+    model_class = Person
     model_title_plural = "People"
     route_prefix = 'people'
     touchable = True
     has_versions = True
-    supports_mobile = True
     bulk_deletable = True
     is_contact = True
-    manage_notes_from_profile_view = False
+    supports_autocomplete = True
+    supports_quickie_search = True
+    configurable = True
 
     labels = {
         'default_phone': "Phone Number",
@@ -67,6 +74,7 @@ class PeopleView(MasterView):
         'last_name',
         'phone',
         'email',
+        'merge_requested',
     ]
 
     form_fields = [
@@ -83,35 +91,64 @@ class PeopleView(MasterView):
         'users',
     ]
 
-    mobile_form_fields = [
-        'first_name',
-        'middle_name',
-        'last_name',
-        'display_name',
-        'phone',
-        'email',
-        'address',
-        'employee',
-        'customers',
-        'users',
-    ]
+    mergeable = True
+
+    def __init__(self, request):
+        super().__init__(request)
+        app = self.get_rattail_app()
+
+        # always get a reference to the People Handler
+        self.people_handler = app.get_people_handler()
+        self.merge_handler = self.people_handler
+        # TODO: deprecate / remove this
+        self.handler = self.people_handler
+
+    def make_grid_kwargs(self, **kwargs):
+        kwargs = super().make_grid_kwargs(**kwargs)
+
+        # turn on checkboxes if user can create a merge reqeust
+        if self.mergeable and self.has_perm('request_merge'):
+            kwargs['checkboxes'] = True
+
+        return kwargs
 
     def configure_grid(self, g):
-        super(PeopleView, self).configure_grid(g)
+        super().configure_grid(g)
+        route_prefix = self.get_route_prefix()
+        model = self.model
 
-        g.joiners['email'] = lambda q: q.outerjoin(model.PersonEmailAddress, sa.and_(
-            model.PersonEmailAddress.parent_uuid == model.Person.uuid,
-            model.PersonEmailAddress.preference == 1))
-        g.joiners['phone'] = lambda q: q.outerjoin(model.PersonPhoneNumber, sa.and_(
-            model.PersonPhoneNumber.parent_uuid == model.Person.uuid,
-            model.PersonPhoneNumber.preference == 1))
+        # email
+        g.set_label('email', "Email Address")
+        g.set_joiner('email', lambda q: q.outerjoin(
+            model.PersonEmailAddress,
+            sa.and_(
+                model.PersonEmailAddress.parent_uuid == model.Person.uuid,
+                model.PersonEmailAddress.preference == 1)))
+        g.set_sorter('email', model.PersonEmailAddress.address)
+        g.set_filter('email', model.PersonEmailAddress.address)
 
-        g.filters['email'] = g.make_filter('email', model.PersonEmailAddress.address)
+        # phone
+        g.set_label('phone', "Phone Number")
+        g.set_joiner('phone', lambda q: q.outerjoin(
+            model.PersonPhoneNumber,
+            sa.and_(
+                model.PersonPhoneNumber.parent_uuid == model.Person.uuid,
+                model.PersonPhoneNumber.preference == 1)))
+        g.set_sorter('phone', model.PersonPhoneNumber.number)
         g.set_filter('phone', model.PersonPhoneNumber.number,
                      factory=grids.filters.AlchemyPhoneNumberFilter)
 
-        g.joiners['customer_id'] = lambda q: q.outerjoin(model.CustomerPerson).outerjoin(model.Customer)
-        g.filters['customer_id'] = g.make_filter('customer_id', model.Customer.id)
+        Customer_ID = orm.aliased(model.Customer)
+        CustomerPerson_ID = orm.aliased(model.CustomerPerson)
+
+        Customer_Number = orm.aliased(model.Customer)
+        CustomerPerson_Number = orm.aliased(model.CustomerPerson)
+
+        g.joiners['customer_id'] = lambda q: q.outerjoin(CustomerPerson_ID).outerjoin(Customer_ID)
+        g.filters['customer_id'] = g.make_filter('customer_id', Customer_ID.id)
+
+        g.joiners['customer_number'] = lambda q: q.outerjoin(CustomerPerson_Number).outerjoin(Customer_Number)
+        g.filters['customer_number'] = g.make_filter('customer_number', Customer_Number.number)
 
         g.filters['first_name'].default_active = True
         g.filters['first_name'].default_verb = 'contains'
@@ -119,41 +156,124 @@ class PeopleView(MasterView):
         g.filters['last_name'].default_active = True
         g.filters['last_name'].default_verb = 'contains'
 
-        g.sorters['email'] = lambda q, d: q.order_by(getattr(model.PersonEmailAddress.address, d)())
-        g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.PersonPhoneNumber.number, d)())
+        g.set_joiner('employee_status', lambda q: q.outerjoin(model.Employee))
+        g.set_filter('employee_status', model.Employee.status,
+                     value_enum=self.enum.EMPLOYEE_STATUS)
+
+        g.set_label('merge_requested', "MR")
+        g.set_renderer('merge_requested', self.render_merge_requested)
 
         g.set_sort_defaults('display_name')
 
         g.set_label('display_name', "Full Name")
-        g.set_label('phone', "Phone Number")
-        g.set_label('email', "Email Address")
         g.set_label('customer_id', "Customer ID")
 
+        if (self.has_perm('view_profile')
+            and self.should_link_straight_to_profile()):
+
+            # add View Raw action
+            url = lambda r, i: self.request.route_url(
+                f'{route_prefix}.view', **self.get_action_route_kwargs(r))
+            # nb. insert to slot 1, just after normal View action
+            g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye'))
+
         g.set_link('display_name')
         g.set_link('first_name')
         g.set_link('last_name')
 
+    def default_view_url(self):
+        if (self.has_perm('view_profile')
+            and self.should_link_straight_to_profile()):
+            return lambda p, i: self.get_action_url('view_profile', p)
+
+        return super().default_view_url()
+
+    def should_link_straight_to_profile(self):
+        return self.rattail_config.getbool('rattail',
+                                           'people.straight_to_profile',
+                                           default=False)
+
+    def render_merge_requested(self, person, field):
+        model = self.model
+        merge_request = self.Session.query(model.MergePeopleRequest)\
+                                    .filter(sa.or_(
+                                        model.MergePeopleRequest.removing_uuid == person.uuid,
+                                        model.MergePeopleRequest.keeping_uuid == person.uuid))\
+                                    .filter(model.MergePeopleRequest.merged == None)\
+                                    .first()
+        if merge_request:
+            return HTML.tag('span',
+                            class_='has-text-danger has-text-weight-bold',
+                            title="A merge has been requested for this person.",
+                            c="MR")
+
     def get_instance(self):
+        model = self.model
         # TODO: I don't recall why this fallback check for a vendor contact
         # exists here, but leaving it intact for now.
         key = self.request.matchdict['uuid']
-        instance = self.Session.query(model.Person).get(key)
+        instance = self.Session.get(model.Person, key)
         if instance:
             return instance
-        instance = self.Session.query(model.VendorContact).get(key)
+        instance = self.Session.get(model.VendorContact, key)
         if instance:
             return instance.person
-        raise HTTPNotFound
+        raise self.notfound()
+
+    def is_person_protected(self, person):
+        for user in person.users:
+            if self.user_is_protected(user):
+                return True
+        return False
 
     def editable_instance(self, person):
-        if self.rattail_config.demo():
-            return not bool(person.user and person.user.username == 'chuck')
-        return True
+        if self.request.is_root:
+            return True
+        return not self.is_person_protected(person)
 
     def deletable_instance(self, person):
-        if self.rattail_config.demo():
-            return not bool(person.user and person.user.username == 'chuck')
-        return True
+        if self.request.is_root:
+            return True
+        return not self.is_person_protected(person)
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        # preferred_first_name
+        if self.people_handler.should_use_preferred_first_name():
+            f.insert_after('first_name', 'preferred_first_name')
+
+    def objectify(self, form, data=None):
+        if data is None:
+            data = form.validated
+
+        # do normal create/update
+        person = super().objectify(form, data)
+
+        # collect data from all name fields
+        names = {}
+        if 'first_name' in form:
+            names['first'] = data['first_name']
+        if self.people_handler.should_use_preferred_first_name():
+            if 'preferred_first_name' in form:
+                names['preferred_first'] = data['preferred_first_name']
+        if 'middle_name' in form:
+            names['middle'] = data['middle_name']
+        if 'last_name' in form:
+            names['last'] = data['last_name']
+        if 'display_name' in form and 'display_name' not in form.readonly_fields:
+            names['full'] = data['display_name']
+
+        # TODO: why do we find colander.null values in data at this point?
+        # ugh, for now we must convert them
+        for key in names:
+            if names[key] is colander.null:
+                names[key] = None
+
+        # do explicit name update w/ common handler logic
+        self.handler.update_names(person, **names)
+
+        return person
 
     def delete_instance(self, person):
         """
@@ -172,7 +292,7 @@ class PeopleView(MasterView):
             customer._people.reorder()
 
         # continue with normal logic
-        super(PeopleView, self).delete_instance(person)
+        super().delete_instance(person)
 
     def touch_instance(self, person):
         """
@@ -181,8 +301,10 @@ class PeopleView(MasterView):
         In addition to "touching" the person proper, we also "touch" each
         contact info record associated with them.
         """
+        model = self.model
+
         # touch person, as per usual
-        super(PeopleView, self).touch_instance(person)
+        super().touch_instance(person)
 
         def touch(obj):
             change = model.Change()
@@ -204,7 +326,7 @@ class PeopleView(MasterView):
             touch(address)
 
     def configure_common_form(self, f):
-        super(PeopleView, self).configure_common_form(f)
+        super().configure_common_form(f)
         person = f.model_instance
 
         f.set_label('display_name', "Full Name")
@@ -260,25 +382,28 @@ class PeopleView(MasterView):
         employee = person.employee
         if not employee:
             return ""
-        text = six.text_type(employee)
+        text = str(employee)
         url = self.request.route_url('employees.view', uuid=employee.uuid)
         return tags.link_to(text, url)
 
     def render_customers(self, person, field):
-        customers = person._customers
+        app = self.get_rattail_app()
+        clientele = app.get_clientele_handler()
+
+        customers = clientele.get_customers_for_account_holder(person)
         if not customers:
-            return ""
+            return
+
         items = []
         for customer in customers:
-            customer = customer.customer
-            text = six.text_type(customer)
+            text = str(customer)
             if customer.number:
                 text = "(#{}) {}".format(customer.number, text)
             elif customer.id:
                 text = "({}) {}".format(customer.id, text)
-            route = '{}customers.view'.format('mobile.' if self.mobile else '')
-            url = self.request.route_url(route, uuid=customer.uuid)
+            url = self.request.route_url('customers.view', uuid=customer.uuid)
             items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
+
         return HTML.tag('ul', c=items)
 
     def render_members(self, person, field):
@@ -287,7 +412,7 @@ class PeopleView(MasterView):
             return ""
         items = []
         for member in members:
-            text = six.text_type(member)
+            text = str(member)
             if member.number:
                 text = "(#{}) {}".format(member.number, text)
             elif member.id:
@@ -297,7 +422,6 @@ class PeopleView(MasterView):
         return HTML.tag('ul', c=items)
 
     def render_users(self, person, field):
-        use_buefy = self.get_use_buefy()
         users = person.users
         items = []
         for user in users:
@@ -307,15 +431,13 @@ class PeopleView(MasterView):
         if items:
             return HTML.tag('ul', c=items)
         elif self.viewing and self.request.has_perm('users.create'):
-            if use_buefy:
-                return HTML.tag('b-button', type='is-primary', c="Make User",
-                                **{'@click': 'clickMakeUser()'})
-            else:
-                return HTML.tag('button', type='button', id='make-user', c="Make User")
+            return HTML.tag('b-button', type='is-primary', c="Make User",
+                            **{'@click': 'clickMakeUser()'})
         else:
             return ""
 
     def get_version_child_classes(self):
+        model = self.model
         return [
             (model.PersonPhoneNumber, 'parent_uuid'),
             (model.PersonEmailAddress, 'parent_uuid'),
@@ -325,137 +447,1182 @@ class PeopleView(MasterView):
             (model.VendorContact, 'person_uuid'),
         ]
 
+    def should_expose_quickie_search(self):
+        if self.expose_quickie_search:
+            return True
+        app = self.get_rattail_app()
+        return app.get_people_handler().should_expose_quickie_search()
+
+    def do_quickie_lookup(self, entry):
+        app = self.get_rattail_app()
+        return app.get_people_handler().quickie_lookup(entry, self.Session())
+
+    def get_quickie_placeholder(self):
+        app = self.get_rattail_app()
+        return app.get_people_handler().get_quickie_search_placeholder()
+
+    def get_quickie_result_url(self, person):
+        return self.get_action_url('view_profile', person)
+
     def view_profile(self):
         """
         View which exposes the "full profile" for a given person, i.e. all
         related customer, employee, user info etc.
         """
         self.viewing = True
+        app = self.get_rattail_app()
         person = self.get_instance()
-        employee = person.employee
+
         context = {
             'person': person,
             'instance': person,
             'instance_title': self.get_instance_title(person),
-            'today': localtime(self.rattail_config).date(),
+            'dynamic_content_title': self.get_context_content_title(person),
+            'tabchecks': self.get_context_tabchecks(person),
             'person_data': self.get_context_person(person),
-            'customers_data': self.get_context_customers(person),
-            'members_data': self.get_context_members(person),
-            'employee': employee,
-            'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid) if employee else None,
-            'employee_history': employee.get_current_history() if employee else None,
-            'employee_history_data': self.get_context_employee_history(employee),
+            'phone_type_options': self.get_phone_type_options(),
+            'email_type_options': self.get_email_type_options(),
+            'max_lengths': self.get_max_lengths(),
+            'expose_customer_people': self.customers_should_expose_people(),
+            'expose_customer_shoppers': self.customers_should_expose_shoppers(),
+            'max_one_member': app.get_membership_handler().max_one_per_person(),
+            'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(),
+            'expose_members': self.should_expose_profile_members(),
+            'expose_transactions': self.should_expose_profile_transactions(),
         }
 
-        use_buefy = self.get_use_buefy()
-        template = 'view_profile_buefy' if use_buefy else 'view_profile'
-        return self.render_to_response(template, context)
+        if context['expose_transactions']:
+            context['transactions_grid'] = self.profile_transactions_grid(person, empty=True)
+
+        if self.request.has_perm('people_profile.view_versions'):
+            context['revisions_grid'] = self.profile_revisions_grid(person)
+
+        return self.render_to_response('view_profile', context)
+
+    def should_expose_profile_members(self):
+        return self.rattail_config.get_bool('tailbone.people.profile.expose_members',
+                                            default=False)
+
+    def should_expose_profile_transactions(self):
+        return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions',
+                                            default=False)
+
+    def profile_transactions_grid(self, person, empty=False):
+        app = self.get_rattail_app()
+        trainwreck = app.get_trainwreck_handler()
+        model = trainwreck.get_model()
+        route_prefix = self.get_route_prefix()
+        if empty:
+            # TODO: surely there is a better way to have empty data..? but so
+            # much logic depends on a query, can't just pass empty list here
+            data = TrainwreckSession.query(model.Transaction)\
+                                    .filter(model.Transaction.uuid == 'bogus')
+        else:
+            data = self.profile_transactions_query(person)
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.profile.transactions.{person.uuid}',
+            data=data,
+            model_class=model.Transaction,
+            ajax_data_url=self.get_action_url('view_profile_transactions', person),
+            columns=[
+                'start_time',
+                'end_time',
+                'system',
+                'terminal_id',
+                'receipt_number',
+                'cashier_name',
+                'customer_id',
+                'customer_name',
+                'total',
+            ],
+            labels={
+                'terminal_id': "Terminal",
+                'customer_id': "Customer " + app.get_customer_key_label(),
+            },
+            filterable=True,
+            sortable=True,
+            paginated=True,
+            default_sortkey='end_time',
+            default_sortdir='desc',
+            component='transactions-grid',
+        )
+        if self.request.has_perm('trainwreck.transactions.view'):
+            url = lambda row, i: self.request.route_url('trainwreck.transactions.view',
+                                                        uuid=row.uuid)
+            g.actions.append(self.make_action('view', icon='eye', url=url))
+        g.load_settings()
+
+        g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
+        g.set_type('total', 'currency')
+
+        return g
+
+    def profile_transactions_query(self, person):
+        """
+        Method which must return the base query for the profile's POS
+        Transactions grid data.
+        """
+        customer = self.app.get_customer(person)
+
+        if customer:
+            key_field = self.app.get_customer_key_field()
+            customer_key = getattr(customer, key_field)
+            if customer_key is not None:
+                customer_key = str(customer_key)
+        else:
+            # nb. this should *not* match anything, so query returns
+            # no results..
+            customer_key = person.uuid
+
+        trainwreck = self.app.get_trainwreck_handler()
+        model = trainwreck.get_model()
+        query = TrainwreckSession.query(model.Transaction)\
+                                 .filter(model.Transaction.customer_id == customer_key)
+        return query
+
+    def profile_transactions_data(self):
+        """
+        AJAX view to return new sorted, filtered data for transactions
+        grid within profile view.
+        """
+        person = self.get_instance()
+        grid = self.profile_transactions_grid(person)
+        return grid.get_table_data()
+
+    def get_context_tabchecks(self, person):
+        app = self.get_rattail_app()
+        clientele = app.get_clientele_handler()
+        tabchecks = {}
+
+        # TODO: for efficiency, should only calculate checks for tabs
+        # actually in use by app..(how) should that be configurable?
+
+        # personal
+        tabchecks['personal'] = True
+
+        # member
+        if self.should_expose_profile_members():
+            membership = app.get_membership_handler()
+            if membership.max_one_per_person():
+                member = app.get_member(person)
+                tabchecks['member'] = bool(member and member.active)
+            else:
+                members = membership.get_members_for_account_holder(person)
+                tabchecks['member'] = any([m.active for m in members])
+
+        # customer
+        customers = clientele.get_customers_for_account_holder(person)
+        tabchecks['customer'] = bool(customers)
+
+        # shopper
+        # TODO: what a hack! surely some of this belongs in handler
+        shoppers = person.customer_shoppers
+        shoppers = [shopper for shopper in shoppers
+                    if shopper.shopper_number != 1]
+        tabchecks['shopper'] = bool(shoppers)
+
+        # employee
+        employee = app.get_employee(person)
+        tabchecks['employee'] = bool(employee and employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
+
+        # notes
+        tabchecks['notes'] = bool(person.notes)
+
+        # user
+        tabchecks['user'] = bool(person.users)
+
+        return tabchecks
+
+    def profile_changed_response(self, person):
+        """
+        Return common context result for all AJAX views which may
+        change the profile details.  This is enough to update the
+        page-wide things, and let other tabs know they should be
+        refreshed when next displayed.
+        """
+        return {
+            'person': self.get_context_person(person),
+            'tabchecks': self.get_context_tabchecks(person),
+        }
+
+    def template_kwargs_view_profile(self, **kwargs):
+        """
+        Stub method so subclass can call `super()` for it.
+        """
+        return kwargs
+
+    def get_max_lengths(self):
+        app = self.get_rattail_app()
+        model = self.model
+        lengths = {
+            'person_first_name': app.maxlen(model.Person.first_name),
+            'person_middle_name': app.maxlen(model.Person.middle_name),
+            'person_last_name': app.maxlen(model.Person.last_name),
+            'address_street': app.maxlen(model.PersonMailingAddress.street),
+            'address_street2': app.maxlen(model.PersonMailingAddress.street2),
+            'address_city': app.maxlen(model.PersonMailingAddress.city),
+            'address_state': app.maxlen(model.PersonMailingAddress.state),
+            'address_zipcode': app.maxlen(model.PersonMailingAddress.zipcode),
+        }
+        if self.people_handler.should_use_preferred_first_name():
+            lengths['person_preferred_first_name'] = app.maxlen(model.Person.preferred_first_name)
+        return lengths
+
+    def get_phone_type_options(self):
+        """
+        Returns a list of "phone type" options, for use in dropdown.
+        """
+        # TODO: should probably define this list somewhere else
+        phone_types = [
+            "Home",
+            "Mobile",
+            "Work",
+            "Other",
+            "Fax",
+        ]
+        return [{'value': typ, 'label': typ}
+                for typ in phone_types]
+
+    def get_email_type_options(self):
+        """
+        Returns a list of "email type" options, for use in dropdown.
+        """
+        # TODO: should probably define this list somewhere else
+        email_types = [
+            "Home",
+            "Work",
+            "Other",
+        ]
+        return [{'value': typ, 'label': typ}
+                for typ in email_types]
 
     def get_context_person(self, person):
-        return {
+
+        context = {
             'uuid': person.uuid,
             'first_name': person.first_name,
+            'middle_name': person.middle_name,
             'last_name': person.last_name,
             'display_name': person.display_name,
             'view_url': self.get_action_url('view', person),
             'view_profile_url': self.get_action_url('view_profile', person),
+            'phones': self.get_context_phones(person),
+            'emails': self.get_context_emails(person),
+            'dynamic_content_title': self.get_context_content_title(person),
         }
 
+        if self.people_handler.should_use_preferred_first_name():
+            context['preferred_first_name'] = person.preferred_first_name
+
+        if person.address:
+            context['address'] = self.get_context_address(person.address)
+
+        return context
+
+    def get_context_shoppers(self, shoppers):
+        data = []
+        for shopper in shoppers:
+            data.append(self.get_context_shopper(shopper))
+        return data
+
+    def get_context_shopper(self, shopper):
+        app = self.get_rattail_app()
+        customer = shopper.customer
+        person = shopper.person
+        customer_key = self.get_customer_key_field()
+        account_holder = app.get_person(customer)
+        context = {
+            'uuid': shopper.uuid,
+            'customer_uuid': customer.uuid,
+            'customer_key': getattr(customer, customer_key),
+            'customer_name': customer.name,
+            'account_holder_uuid': customer.account_holder_uuid,
+            'person_uuid': person.uuid,
+            'first_name': person.first_name,
+            'middle_name': person.middle_name,
+            'last_name': person.last_name,
+            'display_name': person.display_name,
+            'view_profile_url': self.get_action_url('view_profile', person),
+            'phones': self.get_context_phones(person),
+            'emails': self.get_context_emails(person),
+        }
+
+        if account_holder:
+            context.update({
+                'account_holder_name': account_holder.display_name,
+                'account_holder_view_profile_url': self.get_action_url(
+                    'view_profile', account_holder),
+            })
+
+        return context
+
+    def get_context_content_title(self, person):
+        return str(person)
+
     def get_context_address(self, address):
-        return {
+        context = {
             'uuid': address.uuid,
             'street': address.street,
             'street2': address.street2,
             'city': address.city,
             'state': address.state,
             'zipcode': address.zipcode,
-            'display': six.text_type(address),
+            'display': str(address),
         }
 
+        model = self.model
+        if isinstance(address, model.PersonMailingAddress):
+            person = address.person
+            context['invalid'] = self.handler.address_is_invalid(person, address)
+
+        return context
+
     def get_context_customers(self, person):
+        app = self.get_rattail_app()
+        clientele = app.get_clientele_handler()
+        expose_shoppers = self.customers_should_expose_shoppers()
+        expose_people = self.customers_should_expose_people()
+
+        customers = clientele.get_customers_for_account_holder(person)
+        key = self.get_customer_key_field()
         data = []
-        for cp in person._customers:
-            customer = cp.customer
-            data.append({
+
+        for customer in customers:
+            context = {
                 'uuid': customer.uuid,
-                'ordinal': cp.ordinal,
+                '_key': getattr(customer, key),
                 'id': customer.id,
                 'number': customer.number,
                 'name': customer.name,
                 'view_url': self.request.route_url('customers.view',
                                                    uuid=customer.uuid),
-                'people': [self.get_context_person(p)
-                           for p in customer.people],
                 'addresses': [self.get_context_address(a)
                               for a in customer.addresses],
-            })
+                'external_links': [],
+            }
+
+            if customer.account_holder:
+                context['account_holder'] = self.get_context_person(
+                    customer.account_holder)
+
+            if expose_shoppers:
+                context['shoppers'] = [self.get_context_shopper(s)
+                                       for s in customer.shoppers]
+
+            if expose_people:
+                context['people'] = [self.get_context_person(p)
+                                     for p in customer.people]
+
+            for supp in self.iter_view_supplements():
+                if hasattr(supp, 'get_context_for_customer'):
+                    context = supp.get_context_for_customer(customer, context)
+
+            data.append(context)
+
         return data
 
+    # TODO: this is duplicated in customers view module
+    def customers_should_expose_shoppers(self):
+        return self.rattail_config.getbool('rattail',
+                                           'customers.expose_shoppers',
+                                           default=True)
+
+    # TODO: this is duplicated in customers view module
+    def customers_should_expose_people(self):
+        return self.rattail_config.getbool('rattail',
+                                           'customers.expose_people',
+                                           default=True)
+
     def get_context_members(self, person):
+        app = self.get_rattail_app()
+        membership = app.get_membership_handler()
+
         data = OrderedDict()
+        members = membership.get_members_for_account_holder(person)
+        for member in members:
+            context = self.get_context_member(member)
 
-        for member in person.members:
-            data[member.uuid] = self.get_context_member(member)
+            for supp in self.iter_view_supplements():
+                if hasattr(supp, 'get_context_for_member'):
+                    context = supp.get_context_for_member(member, context)
 
-        for customer in person.customers:
-            for member in customer.members:
-                if member.uuid not in data:
-                    data[member.uuid] = self.get_context_member(member)
+            data[member.uuid] = context
 
         return list(data.values())
 
     def get_context_member(self, member):
-        profile_url = None
-        if member.person:
-            profile_url = self.request.route_url('people.view_profile',
-                                                 uuid=member.person_uuid)
+        app = self.get_rattail_app()
+        person = app.get_person(member)
 
-        return {
+        profile_url = None
+        if person:
+            profile_url = self.request.route_url('people.view_profile',
+                                                 uuid=person.uuid)
+
+        key = self.get_member_key_field()
+        equity_total = sum([payment.amount for payment in member.equity_payments])
+        data = {
             'uuid': member.uuid,
+            '_key': getattr(member, key),
             'number': member.number,
             'id': member.id,
             'active': member.active,
-            'joined': six.text_type(member.joined) if member.joined else None,
-            'withdrew': six.text_type(member.withdrew) if member.withdrew else None,
+            'joined': str(member.joined) if member.joined else None,
+            'withdrew': str(member.withdrew) if member.withdrew else None,
             'customer_uuid': member.customer_uuid,
             'customer_name': member.customer.name if member.customer else None,
             'person_uuid': member.person_uuid,
-            'display': six.text_type(member),
+            'display': str(member),
             'person_display_name': member.person.display_name if member.person else None,
             'view_url': self.request.route_url('members.view', uuid=member.uuid),
             'view_profile_url': profile_url,
+            'equity_total_display': app.render_currency(equity_total),
+            'external_links': [],
         }
 
+        membership_type = member.membership_type
+        if membership_type:
+            data.update({
+                'membership_type_uuid': membership_type.uuid,
+                'membership_type_number': membership_type.number,
+                'membership_type_name': membership_type.name,
+                'view_membership_type_url': self.request.route_url(
+                    'membership_types.view', uuid=membership_type.uuid),
+            })
+
+        return data
+
+    def get_context_employee(self, employee):
+        """
+        Return a dict of context data for the given employee.
+        """
+        app = self.get_rattail_app()
+        handler = app.get_employment_handler()
+        context = handler.get_context_employee(employee)
+        context.setdefault('external_links', [])
+
+        for supp in self.iter_view_supplements():
+            if hasattr(supp, 'get_context_for_employee'):
+                context = supp.get_context_for_employee(employee, context)
+
+        context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid)
+        return context
+
     def get_context_employee_history(self, employee):
         data = []
         if employee:
             for history in employee.sorted_history(reverse=True):
                 data.append({
                     'uuid': history.uuid,
-                    'start_date': six.text_type(history.start_date),
-                    'end_date': six.text_type(history.end_date or ''),
+                    'start_date': str(history.start_date),
+                    'end_date': str(history.end_date or ''),
                 })
         return data
 
+    def get_context_notes(self, person):
+        data = []
+        notes = sorted(person.notes, key=lambda n: n.created, reverse=True)
+        for note in notes:
+            data.append(self.get_context_note(note))
+        return data
+
+    def get_context_note(self, note):
+        app = self.get_rattail_app()
+        return {
+            'uuid': note.uuid,
+            'note_type': note.type,
+            'note_type_display': self.enum.PERSON_NOTE_TYPE.get(note.type, note.type),
+            'subject': note.subject,
+            'text': note.text,
+            'created_display': raw_datetime(self.rattail_config, note.created),
+            'created_by_display': str(note.created_by),
+        }
+
+    def get_note_type_options(self):
+        return [{'value': k, 'label': v}
+                for k, v in self.enum.PERSON_NOTE_TYPE.items()]
+
+    def get_context_users(self, person):
+        data = []
+        users = person.users
+        for user in users:
+            data.append(self.get_context_user(user))
+        return data
+
+    def get_context_user(self, user):
+        app = self.get_rattail_app()
+        return {
+            'uuid': user.uuid,
+            'username': user.username,
+            'display_name': user.display_name,
+            'email_address': app.get_contact_email_address(user),
+            'active': user.active,
+            'view_url': self.request.route_url('users.view', uuid=user.uuid),
+        }
+
+    def ensure_customer(self, person):
+        """
+        Return the `Customer` record for the given person, establishing it
+        first if necessary.
+        """
+        app = self.get_rattail_app()
+        handler = app.get_clientele_handler()
+        customer = handler.ensure_customer(person)
+        return customer
+
+    def profile_tab_personal(self):
+        """
+        Fetch personal tab data for profile view.
+        """
+        # TODO: no need to return primary person data, since that
+        # always comes back via normal profile_changed_response()
+        # ..so for now this is a no-op..
+
+        # person = self.get_instance()
+        return {
+            # 'person': self.get_context_person(person),
+        }
+
+    def profile_edit_name(self):
+        """
+        View which allows a person's name to be updated.
+        """
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        kw = {
+            'first': data['first_name'],
+            'middle': data['middle_name'],
+            'last': data['last_name'],
+        }
+
+        if self.people_handler.should_use_preferred_first_name():
+            kw['preferred_first'] = data['preferred_first_name']
+
+        self.handler.update_names(person, **kw)
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def get_context_phones(self, person):
+        data = []
+        for phone in person.phones:
+            data.append({
+                'uuid': phone.uuid,
+                'type': phone.type,
+                'number': phone.number,
+                'preferred': phone.preferred,
+                'preference': phone.preference,
+            })
+        return data
+
+    def profile_add_phone(self):
+        """
+        View which adds a new phone number for the person.
+        """
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        try:
+            phone = self.handler.add_phone(person, data['phone_number'],
+                                           type=data['phone_type'],
+                                           preferred=data['phone_preferred'])
+        except Exception as error:
+            log.warning("failed to add phone", exc_info=True)
+            return {'error': simple_error(error)}
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_update_phone(self):
+        """
+        View which updates a phone number for the person.
+        """
+        model = self.model
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid'])
+        if not phone:
+            return {'error': "Phone not found."}
+
+        kwargs = {
+            'number': data['phone_number'],
+            'type': data['phone_type'],
+        }
+        if 'phone_preferred' in data:
+            kwargs['preferred'] = data['phone_preferred']
+
+        try:
+            phone = self.handler.update_phone(person, phone, **kwargs)
+        except Exception as error:
+            log.warning("failed to update phone", exc_info=True)
+            return {'error': simple_error(error)}
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_delete_phone(self):
+        """
+        View which allows a person's phone number to be deleted.
+        """
+        model = self.model
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        # validate phone
+        phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid'])
+        if not phone:
+            return {'error': "Phone not found."}
+        if phone not in person.phones:
+            return {'error': "Phone does not belong to this person."}
+
+        # remove phone
+        person.remove_phone(phone)
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_set_preferred_phone(self):
+        """
+        View which allows a person's "preferred" phone to be set.
+        """
+        model = self.model
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        # validate phone
+        phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid'])
+        if not phone:
+            return {'error': "Phone not found."}
+        if phone not in person.phones:
+            return {'error': "Phone does not belong to this person."}
+
+        # update phone preference
+        person.set_primary_phone(phone)
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def get_context_emails(self, person):
+        data = []
+        for email in person.emails:
+            data.append({
+                'uuid': email.uuid,
+                'type': email.type,
+                'address': email.address,
+                'invalid': email.invalid,
+                'preferred': email.preferred,
+                'preference': email.preference,
+            })
+        return data
+
+    def profile_add_email(self):
+        """
+        View which adds a new email address for the person.
+        """
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        kwargs = {
+            'type': data['email_type'],
+            'invalid': False,
+        }
+        if 'email_preferred' in data:
+            kwargs['preferred'] = data['email_preferred']
+
+        try:
+            email = self.handler.add_email(person, data['email_address'], **kwargs)
+        except Exception as error:
+            log.warning("failed to add email", exc_info=True)
+            return {'error': simple_error(error)}
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_update_email(self):
+        """
+        View which updates an email address for the person.
+        """
+        model = self.model
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        email = self.Session.get(model.PersonEmailAddress, data['email_uuid'])
+        if not email:
+            return {'error': "Email not found."}
+
+        try:
+            email = self.handler.update_email(person, email,
+                                              address=data['email_address'],
+                                              type=data['email_type'],
+                                              invalid=data['email_invalid'])
+        except Exception as error:
+            log.warning("failed to add email", exc_info=True)
+            return {'error': simple_error(error)}
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_delete_email(self):
+        """
+        View which allows a person's email address to be deleted.
+        """
+        model = self.model
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        # validate email
+        email = self.Session.get(model.PersonEmailAddress, data['email_uuid'])
+        if not email:
+            return {'error': "Email not found."}
+        if email not in person.emails:
+            return {'error': "Email does not belong to this person."}
+
+        # remove email
+        person.remove_email(email)
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_set_preferred_email(self):
+        """
+        View which allows a person's "preferred" email to be set.
+        """
+        model = self.model
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        # validate email
+        email = self.Session.get(model.PersonEmailAddress, data['email_uuid'])
+        if not email:
+            return {'error': "Email not found."}
+        if email not in person.emails:
+            return {'error': "Email does not belong to this person."}
+
+        # update email preference
+        person.set_primary_email(email)
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_edit_address(self):
+        """
+        View which allows a person's mailing address to be updated.
+        """
+        person = self.get_instance()
+        data = dict(self.request.json_body)
+
+        # update person address
+        address = self.people_handler.ensure_address(person)
+        self.people_handler.update_address(person, address, **data)
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_tab_member(self):
+        """
+        Fetch member tab data for profile view.
+        """
+        app = self.get_rattail_app()
+        membership = app.get_membership_handler()
+        person = self.get_instance()
+
+        max_one_member = membership.max_one_per_person()
+
+        context = {
+            'max_one_member': max_one_member,
+        }
+
+        if max_one_member:
+            member = app.get_member(person)
+            context['member'] = {'exists': bool(member)}
+            if member:
+                context['member'].update(self.get_context_member(member))
+        else:
+            context['members'] = self.get_context_members(person)
+
+        return context
+
+    def profile_tab_customer(self):
+        """
+        Fetch customer tab data for profile view.
+        """
+        person = self.get_instance()
+        return {
+            'customers': self.get_context_customers(person),
+        }
+
+    def profile_tab_shopper(self):
+        """
+        Fetch shopper tab data for profile view.
+        """
+        person = self.get_instance()
+
+        # TODO: what a hack! surely some of this belongs in handler
+        shoppers = person.customer_shoppers
+        shoppers = [shopper for shopper in shoppers
+                    if shopper.shopper_number != 1]
+
+        return {
+            'shoppers': self.get_context_shoppers(shoppers),
+        }
+
+    def profile_tab_employee(self):
+        """
+        Fetch employee tab data for profile view.
+        """
+        app = self.get_rattail_app()
+        person = self.get_instance()
+        employee = app.get_employee(person)
+        return {
+            'employee': self.get_context_employee(employee) if employee else {},
+            'employee_history': self.get_context_employee_history(employee),
+        }
+
+    def profile_start_employee(self):
+        """
+        View which will cause the person to start being an employee.
+        """
+        person = self.get_instance()
+        app = self.get_rattail_app()
+        handler = app.get_employment_handler()
+
+        reason = handler.why_not_begin_employment(person)
+        if reason:
+            return {'error': reason}
+
+        data = self.request.json_body
+        start_date = datetime.datetime.strptime(data['start_date'], '%Y-%m-%d').date()
+        employee = handler.begin_employment(person, start_date,
+                                            employee_id=data['id'])
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_end_employee(self):
+        """
+        View which will cause the person to stop being an employee.
+        """
+        person = self.get_instance()
+        app = self.get_rattail_app()
+        handler = app.get_employment_handler()
+
+        reason = handler.why_not_end_employment(person)
+        if reason:
+            return {'error': reason}
+
+        data = dict(self.request.json_body)
+        end_date = datetime.datetime.strptime(data['end_date'], '%Y-%m-%d').date()
+        employee = handler.get_employee(person)
+        handler.end_employment(employee, end_date,
+                               revoke_access=data.get('revoke_access'))
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_edit_employee_history(self):
+        """
+        AJAX view for updating an employee history record.
+        """
+        model = self.model
+        person = self.get_instance()
+        employee = person.employee
+
+        uuid = self.request.json_body['uuid']
+        history = self.Session.get(model.EmployeeHistory, uuid)
+        if not history or history not in employee.history:
+            return {'error': "Must specify a valid Employee History record for this Person."}
+
+        # all history records have a start date, so always update that
+        start_date = self.request.json_body['start_date']
+        start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d').date()
+        history.start_date = start_date
+
+        # only update end_date if history already had one
+        if history.end_date:
+            end_date = self.request.json_body['end_date']
+            end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d').date()
+            history.end_date = end_date
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_update_employee_id(self):
+        """
+        View to update an employee's ID value.
+        """
+        app = self.get_rattail_app()
+        employment = app.get_employment_handler()
+
+        person = self.get_instance()
+        employee = employment.get_employee(person)
+
+        data = self.request.json_body
+        employee.id = data['employee_id']
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_tab_notes(self):
+        """
+        Fetch notes tab data for profile view.
+        """
+        person = self.get_instance()
+        return {
+            'notes': self.get_context_notes(person),
+            'note_types': self.get_note_type_options(),
+        }
+
+    def profile_tab_user(self):
+        """
+        Fetch user tab data for profile view.
+        """
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        person = self.get_instance()
+        context = {
+            'users': self.get_context_users(person),
+        }
+
+        if not context['users']:
+            context['suggested_username'] = auth.make_unique_username(self.Session(),
+                                                                      person=person)
+
+        return context
+
+    def profile_make_user(self):
+        """
+        Create a new user account, presumably from the profile view.
+        """
+        app = self.get_rattail_app()
+        model = self.model
+        auth = app.get_auth_handler()
+
+        person = self.get_instance()
+        if person.users:
+            return {'error': f"This person already has {len(person.users)} user accounts."}
+
+        data = self.request.json_body
+        user = auth.make_user(session=self.Session(),
+                              person=person,
+                              username=data['username'],
+                              active=data['active'])
+
+        self.Session.flush()
+        return self.profile_changed_response(person)
+
+    def profile_revisions_grid(self, person):
+        route_prefix = self.get_route_prefix()
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.profile.revisions',
+            data=[],                 # start with empty data!
+            columns=[
+                'changed',
+                'changed_by',
+                'remote_addr',
+                'comment',
+            ],
+            labels={
+                'remote_addr': "IP Address",
+            },
+            linked_columns=[
+                'changed',
+                'changed_by',
+                'comment',
+            ],
+            actions=[
+                self.make_action('view', icon='eye', url='#',
+                                 click_handler='viewRevision(props.row)'),
+            ],
+        )
+        return g
+
+    def profile_revisions_collect(self, person, versions=None):
+        model = self.model
+        versions = versions or []
+
+        # Person
+        cls = continuum.version_class(model.Person)
+        query = self.Session.query(cls)\
+                            .filter(cls.uuid == person.uuid)
+        versions.extend(query.all())
+
+        # User
+        cls = continuum.version_class(model.User)
+        query = self.Session.query(cls)\
+                            .filter(cls.person_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # Member
+        cls = continuum.version_class(model.Member)
+        query = self.Session.query(cls)\
+                            .filter(cls.person_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # Employee
+        cls = continuum.version_class(model.Employee)
+        query = self.Session.query(cls)\
+                            .filter(cls.person_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # EmployeeHistory
+        cls = continuum.version_class(model.EmployeeHistory)
+        query = self.Session.query(cls)\
+                            .join(model.Employee,
+                                  model.Employee.uuid == cls.employee_uuid)\
+                            .filter(model.Employee.person_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # PersonPhoneNumber
+        cls = continuum.version_class(model.PersonPhoneNumber)
+        query = self.Session.query(cls)\
+                            .filter(cls.parent_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # PersonEmailAddress
+        cls = continuum.version_class(model.PersonEmailAddress)
+        query = self.Session.query(cls)\
+                            .filter(cls.parent_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # PersonMailingAddress
+        cls = continuum.version_class(model.PersonMailingAddress)
+        query = self.Session.query(cls)\
+                            .filter(cls.parent_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # Customer (account_holder)
+        cls = continuum.version_class(model.Customer)
+        query = self.Session.query(cls)\
+                            .filter(cls.account_holder_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # CustomerShopper (from Customer perspective)
+        cls = continuum.version_class(model.CustomerShopper)
+        query = self.Session.query(cls)\
+                            .join(model.Customer, model.Customer.uuid == cls.customer_uuid)\
+                            .filter(model.Customer.account_holder_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # CustomerShopperHistory (from Customer perspective)
+        cls = continuum.version_class(model.CustomerShopperHistory)
+        standin = continuum.version_class(model.CustomerShopper)
+        query = self.Session.query(cls)\
+                            .join(standin, standin.uuid == cls.shopper_uuid)\
+                            .join(model.Customer, model.Customer.uuid == standin.customer_uuid)\
+                            .filter(model.Customer.account_holder_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # CustomerShopper (from Shopper perspective)
+        cls = continuum.version_class(model.CustomerShopper)
+        query = self.Session.query(cls)\
+                            .filter(cls.person_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # CustomerShopperHistory (from Shopper perspective)
+        cls = continuum.version_class(model.CustomerShopperHistory)
+        standin = continuum.version_class(model.CustomerShopper)
+        query = self.Session.query(cls)\
+                            .join(standin, standin.uuid == cls.shopper_uuid)\
+                            .filter(standin.person_uuid == person.uuid)
+        versions.extend(query.all())
+
+        # PersonNote
+        cls = continuum.version_class(model.PersonNote)
+        query = self.Session.query(cls)\
+                            .filter(cls.parent_uuid == person.uuid)
+        versions.extend(query.all())
+
+        return versions
+
+    def profile_revisions_data(self):
+        """
+        View which locates and organizes all relevant "transaction"
+        (version) history data for a given Person.  Returns JSON, for
+        use with the table element on the full profile view.
+        """
+        person = self.get_instance()
+        versions = self.profile_revisions_collect(person)
+
+        # organize final table data
+        data = []
+        all_txns = set([v.transaction for v in versions])
+        for i, txn in enumerate(
+                sorted(all_txns, key=lambda txn: txn.issued_at, reverse=True),
+                1):
+            data.append({
+                'txnid': txn.id,
+                'changed': raw_datetime(self.rattail_config, txn.issued_at),
+                'changed_by': str(txn.user or '') or None,
+                'remote_addr': txn.remote_addr,
+                'comment': txn.meta.get('comment'),
+            })
+            # also stash the sequential index for this transaction, for use later
+            txn._sequential_index = i
+
+        # also organize final transaction/versions (diff) map
+        vmap = {}
+        for version in versions:
+            fields = self.fields_for_version(version)
+
+            old_data = {}
+            new_data = {}
+            for field in fields:
+                if version.previous:
+                    old_data[field] = getattr(version.previous, field)
+                new_data[field] = getattr(version, field)
+            diff = self.make_version_diff(version, old_data, new_data, fields=fields)
+
+            if version.transaction_id not in vmap:
+                txn = version.transaction
+                prev_txnid = None
+                next_txnid = None
+                if txn._sequential_index < len(data):
+                    prev_txnid = data[txn._sequential_index]['txnid']
+                if txn._sequential_index > 1:
+                    next_txnid = data[txn._sequential_index - 2]['txnid']
+                vmap[txn.id] = {
+                    'index': txn._sequential_index,
+                    'txnid': txn.id,
+                    'prev_txnid': prev_txnid,
+                    'next_txnid': next_txnid,
+                    'changed': raw_datetime(self.rattail_config, txn.issued_at,
+                                            verbose=True),
+                    'changed_by': str(txn.user or '') or None,
+                    'remote_addr': txn.remote_addr,
+                    'comment': txn.meta.get('comment'),
+                    'versions': [],
+                }
+
+            vmap[version.transaction_id]['versions'].append(diff.as_struct())
+
+        return {'data': data, 'vmap': vmap}
+
     def make_note_form(self, mode, person):
         schema = NoteSchema().bind(session=self.Session(),
                                    person_uuid=person.uuid)
         if mode == 'create':
             del schema['uuid']
         form = forms.Form(schema=schema, request=self.request)
+        if mode != 'delete':
+            form.set_validator('note_type', colander.OneOf(self.enum.PERSON_NOTE_TYPE))
         return form
 
     def profile_add_note(self):
         person = self.get_instance()
         form = self.make_note_form('create', person)
-        if form.validate(newstyle=True):
-            note = self.create_note(person, form)
-            self.Session.flush()
-            return self.profile_add_note_success(note)
-        else:
-            return self.profile_add_note_failure(person, form)
+        if not form.validate():
+            return {'error': str(form.make_deform_form().error)}
+
+        note = self.create_note(person, form)
+        self.Session.flush()
+        return self.profile_changed_response(person)
 
     def create_note(self, person, form):
+        model = self.model
         note = model.PersonNote()
         note.type = form.validated['note_type']
         note.subject = form.validated['note_subject']
@@ -464,57 +1631,42 @@ class PeopleView(MasterView):
         person.notes.append(note)
         return note
 
-    def profile_add_note_success(self, note):
-        return self.redirect(self.get_action_url('view_profile', person))
-
-    def profile_add_note_failure(self, person, form):
-        return self.redirect(self.get_action_url('view_profile', person))
-
     def profile_edit_note(self):
         person = self.get_instance()
         form = self.make_note_form('edit', person)
-        if form.validate(newstyle=True):
-            note = self.update_note(person, form)
-            self.Session.flush()
-            return self.profile_edit_note_success(note)
-        else:
-            return self.profile_edit_note_failure(person, form)
+        if not form.validate():
+            return {'error': str(form.make_deform_form().error)}
+
+        note = self.update_note(person, form)
+        self.Session.flush()
+        return self.profile_changed_response(person)
 
     def update_note(self, person, form):
-        note = self.Session.query(model.PersonNote).get(form.validated['uuid'])
+        model = self.model
+        note = self.Session.get(model.PersonNote, form.validated['uuid'])
         note.subject = form.validated['note_subject']
         note.text = form.validated['note_text']
         return note
 
-    def profile_edit_note_success(self, note):
-        return self.redirect(self.get_action_url('view_profile', person))
-
-    def profile_edit_note_failure(self, person, form):
-        return self.redirect(self.get_action_url('view_profile', person))
-
     def profile_delete_note(self):
         person = self.get_instance()
         form = self.make_note_form('delete', person)
-        if form.validate(newstyle=True):
-            self.delete_note(person, form)
-            self.Session.flush()
-            return self.profile_delete_note_success(person)
-        else:
-            return self.profile_delete_note_failure(person, form)
+        if not form.validate():
+            return {'error': str(form.make_deform_form().error)}
+
+        self.delete_note(person, form)
+        self.Session.flush()
+        return self.profile_changed_response(person)
 
     def delete_note(self, person, form):
-        note = self.Session.query(model.PersonNote).get(form.validated['uuid'])
+        model = self.model
+        note = self.Session.get(model.PersonNote, form.validated['uuid'])
         self.Session.delete(note)
 
-    def profile_delete_note_success(self, person):
-        return self.redirect(self.get_action_url('view_profile', person))
-
-    def profile_delete_note_failure(self, person, form):
-        return self.redirect(self.get_action_url('view_profile', person))
-
     def make_user(self):
+        model = self.model
         uuid = self.request.POST['person_uuid']
-        person = self.Session.query(model.Person).get(uuid)
+        person = self.Session.get(model.Person, uuid)
         if not person:
             return self.notfound()
         if person.users:
@@ -529,6 +1681,38 @@ class PeopleView(MasterView):
         self.request.session.flash("User has been created: {}".format(user.username))
         return self.redirect(self.request.route_url('users.view', uuid=user.uuid))
 
+    def request_merge(self):
+        """
+        Create a new merge request for the given 2 people.
+        """
+        self.handler.request_merge(self.request.user,
+                                   self.request.POST['removing_uuid'],
+                                   self.request.POST['keeping_uuid'])
+        return self.redirect(self.get_index_url())
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # General
+            {'section': 'rattail',
+             'option': 'people.straight_to_profile',
+             'type': bool},
+            {'section': 'rattail',
+             'option': 'people.expose_quickie_search',
+             'type': bool},
+            {'section': 'rattail',
+             'option': 'people.handler'},
+
+
+            # Profile View
+            {'section': 'tailbone',
+             'option': 'people.profile.expose_members',
+             'type': bool},
+            {'section': 'tailbone',
+             'option': 'people.profile.expose_transactions',
+             'type': bool},
+        ]
+
     @classmethod
     def defaults(cls, config):
         cls._people_defaults(config)
@@ -539,8 +1723,10 @@ class PeopleView(MasterView):
         permission_prefix = cls.get_permission_prefix()
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
         model_key = cls.get_model_key()
         model_title = cls.get_model_title()
+        model_title_plural = cls.get_model_title_plural()
 
         # "profile" perms
         # TODO: should let view class (or config) determine which of these are available
@@ -558,32 +1744,249 @@ class PeopleView(MasterView):
         config.add_view(cls, attr='view_profile', route_name='{}.view_profile'.format(route_prefix),
                         permission='{}.view_profile'.format(permission_prefix))
 
-        # manage notes from profile view
-        if cls.manage_notes_from_profile_view:
+        # profile - refresh personal tab
+        config.add_route(f'{route_prefix}.profile_tab_personal',
+                         f'{instance_url_prefix}/profile/tab-personal',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_personal',
+                        route_name=f'{route_prefix}.profile_tab_personal',
+                        renderer='json')
 
-            # add note
-            config.add_tailbone_permission('people_profile', 'people_profile.add_note',
-                                           "Add new {} Note records".format(model_title))
-            config.add_route('{}.profile_add_note'.format(route_prefix), '{}/{{{}}}/profile/new-note'.format(url_prefix, model_key),
-                             request_method='POST')
-            config.add_view(cls, attr='profile_add_note', route_name='{}.profile_add_note'.format(route_prefix),
-                            permission='people_profile.add_note')
+        # profile - edit personal details
+        config.add_tailbone_permission('people_profile',
+                                       'people_profile.edit_person',
+                                       "Edit the Personal details")
 
-            # edit note
-            config.add_tailbone_permission('people_profile', 'people_profile.edit_note',
-                                           "Edit {} Note records".format(model_title))
-            config.add_route('{}.profile_edit_note'.format(route_prefix), '{}/{{{}}}/profile/edit-note'.format(url_prefix, model_key),
-                             request_method='POST')
-            config.add_view(cls, attr='profile_edit_note', route_name='{}.profile_edit_note'.format(route_prefix),
-                            permission='people_profile.edit_note')
+        # profile - edit name
+        config.add_route('{}.profile_edit_name'.format(route_prefix),
+                         '{}/profile/edit-name'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_edit_name',
+                        route_name='{}.profile_edit_name'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
 
-            # delete note
-            config.add_tailbone_permission('people_profile', 'people_profile.delete_note',
-                                           "Delete {} Note records".format(model_title))
-            config.add_route('{}.profile_delete_note'.format(route_prefix), '{}/{{{}}}/profile/delete-note'.format(url_prefix, model_key),
-                             request_method='POST')
-            config.add_view(cls, attr='profile_delete_note', route_name='{}.profile_delete_note'.format(route_prefix),
-                            permission='people_profile.delete_note')
+        # profile - add phone
+        config.add_route('{}.profile_add_phone'.format(route_prefix),
+                         '{}/profile/add-phone'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_add_phone',
+                        route_name='{}.profile_add_phone'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - update phone
+        config.add_route('{}.profile_update_phone'.format(route_prefix),
+                         '{}/profile/update-phone'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_update_phone',
+                        route_name='{}.profile_update_phone'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - delete phone
+        config.add_route('{}.profile_delete_phone'.format(route_prefix),
+                         '{}/profile/delete-phone'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_delete_phone',
+                        route_name='{}.profile_delete_phone'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - set preferred phone
+        config.add_route('{}.profile_set_preferred_phone'.format(route_prefix),
+                         '{}/profile/set-preferred-phone'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_set_preferred_phone',
+                        route_name='{}.profile_set_preferred_phone'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - add email
+        config.add_route('{}.profile_add_email'.format(route_prefix),
+                         '{}/profile/add-email'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_add_email',
+                        route_name='{}.profile_add_email'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - update email
+        config.add_route('{}.profile_update_email'.format(route_prefix),
+                         '{}/profile/update-email'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_update_email',
+                        route_name='{}.profile_update_email'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - delete email
+        config.add_route('{}.profile_delete_email'.format(route_prefix),
+                         '{}/profile/delete-email'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_delete_email',
+                        route_name='{}.profile_delete_email'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - set preferred email
+        config.add_route('{}.profile_set_preferred_email'.format(route_prefix),
+                         '{}/profile/set-preferred-email'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_set_preferred_email',
+                        route_name='{}.profile_set_preferred_email'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - edit address
+        config.add_route('{}.profile_edit_address'.format(route_prefix),
+                         '{}/profile/edit-address'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_edit_address',
+                        route_name='{}.profile_edit_address'.format(route_prefix),
+                        renderer='json',
+                        permission='people_profile.edit_person')
+
+        # profile - refresh member tab
+        config.add_route(f'{route_prefix}.profile_tab_member',
+                         f'{instance_url_prefix}/profile/tab-member',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_member',
+                        route_name=f'{route_prefix}.profile_tab_member',
+                        renderer='json')
+
+        # profile - refresh customer tab
+        config.add_route(f'{route_prefix}.profile_tab_customer',
+                         f'{instance_url_prefix}/profile/tab-customer',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_customer',
+                        route_name=f'{route_prefix}.profile_tab_customer',
+                        renderer='json')
+
+        # profile - refresh shopper tab
+        config.add_route(f'{route_prefix}.profile_tab_shopper',
+                         f'{instance_url_prefix}/profile/tab-shopper',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_shopper',
+                        route_name=f'{route_prefix}.profile_tab_shopper',
+                        renderer='json')
+
+        # profile - refresh employee tab
+        config.add_route(f'{route_prefix}.profile_tab_employee',
+                         f'{instance_url_prefix}/profile/tab-employee',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_employee',
+                        route_name=f'{route_prefix}.profile_tab_employee',
+                        renderer='json')
+
+        # profile - start employee
+        config.add_route('{}.profile_start_employee'.format(route_prefix), '{}/profile/start-employee'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_start_employee', route_name='{}.profile_start_employee'.format(route_prefix),
+                        permission='people_profile.toggle_employee', renderer='json')
+
+        # profile - end employee
+        config.add_route('{}.profile_end_employee'.format(route_prefix), '{}/profile/end-employee'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_end_employee', route_name='{}.profile_end_employee'.format(route_prefix),
+                        permission='people_profile.toggle_employee', renderer='json')
+
+        # profile - edit employee history
+        config.add_route('{}.profile_edit_employee_history'.format(route_prefix), '{}/profile/edit-employee-history'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_edit_employee_history', route_name='{}.profile_edit_employee_history'.format(route_prefix),
+                        permission='people_profile.edit_employee_history', renderer='json')
+
+        # profile - update employee ID
+        config.add_route('{}.profile_update_employee_id'.format(route_prefix),
+                         '{}/profile/update-employee-id'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='profile_update_employee_id',
+                        route_name='{}.profile_update_employee_id'.format(route_prefix),
+                        renderer='json',
+                        permission='employees.edit')
+
+        # profile - refresh notes tab
+        config.add_route(f'{route_prefix}.profile_tab_notes',
+                         f'{instance_url_prefix}/profile/tab-notes',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_notes',
+                        route_name=f'{route_prefix}.profile_tab_notes',
+                        renderer='json')
+
+        # profile - refresh user tab
+        config.add_route(f'{route_prefix}.profile_tab_user',
+                         f'{instance_url_prefix}/profile/tab-user',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_tab_user',
+                        route_name=f'{route_prefix}.profile_tab_user',
+                        renderer='json')
+
+        # profile - make user
+        config.add_route(f'{route_prefix}.profile_make_user',
+                         f'{instance_url_prefix}/make-user',
+                         request_method='POST')
+        config.add_view(cls, attr='profile_make_user',
+                        route_name=f'{route_prefix}.profile_make_user',
+                        permission='users.create',
+                        renderer='json')
+
+        # profile - revisions data
+        config.add_tailbone_permission('people_profile',
+                                       'people_profile.view_versions',
+                                       "View full version history for a profile")
+        config.add_route(f'{route_prefix}.view_profile_revisions',
+                         f'{instance_url_prefix}/profile/revisions',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_revisions_data',
+                        route_name=f'{route_prefix}.view_profile_revisions',
+                        permission='people_profile.view_versions',
+                        renderer='json')
+
+        # profile - add note
+        config.add_tailbone_permission('people_profile',
+                                       'people_profile.add_note',
+                                       "Add new Note records")
+        config.add_route(f'{route_prefix}.profile_add_note',
+                         f'{instance_url_prefix}/profile/new-note',
+                         request_method='POST')
+        config.add_view(cls, attr='profile_add_note',
+                        route_name=f'{route_prefix}.profile_add_note',
+                        permission='people_profile.add_note',
+                        renderer='json')
+
+        # profile - edit note
+        config.add_tailbone_permission('people_profile',
+                                       'people_profile.edit_note',
+                                       "Edit Note records")
+        config.add_route(f'{route_prefix}.profile_edit_note',
+                         f'{instance_url_prefix}/profile/edit-note',
+                         request_method='POST')
+        config.add_view(cls, attr='profile_edit_note',
+                        route_name=f'{route_prefix}.profile_edit_note',
+                        permission='people_profile.edit_note',
+                        renderer='json')
+
+        # profile - delete note
+        config.add_tailbone_permission('people_profile',
+                                       'people_profile.delete_note',
+                                       "Delete Note records")
+        config.add_route(f'{route_prefix}.profile_delete_note',
+                         f'{instance_url_prefix}/profile/delete-note',
+                         request_method='POST')
+        config.add_view(cls, attr='profile_delete_note',
+                        route_name=f'{route_prefix}.profile_delete_note',
+                        permission='people_profile.delete_note',
+                        renderer='json')
+
+        # profile - transactions data
+        config.add_route(f'{route_prefix}.view_profile_transactions',
+                         f'{instance_url_prefix}/profile/transactions',
+                         request_method='GET')
+        config.add_view(cls, attr='profile_transactions_data',
+                        route_name=f'{route_prefix}.view_profile_transactions',
+                        permission=f'{permission_prefix}.view_profile',
+                        renderer='json')
 
         # make user for person
         config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix),
@@ -591,28 +1994,21 @@ class PeopleView(MasterView):
         config.add_view(cls, attr='make_user', route_name='{}.make_user'.format(route_prefix),
                         permission='users.create')
 
-
-class PeopleAutocomplete(AutocompleteView):
-
-    mapped_class = model.Person
-    fieldname = 'display_name'
-
-
-class PeopleEmployeesAutocomplete(PeopleAutocomplete):
-    """
-    Autocomplete view for the Person model, but restricted to return only
-    results for people who are employees.
-    """
-
-    def filter_query(self, q):
-        return q.join(model.Employee)
+        # merge requests
+        if cls.mergeable:
+            config.add_tailbone_permission(permission_prefix, '{}.request_merge'.format(permission_prefix),
+                                           "Request merge for 2 {}".format(model_title_plural))
+            config.add_route('{}.request_merge'.format(route_prefix), '{}/request-merge'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='request_merge', route_name='{}.request_merge'.format(route_prefix),
+                            permission='{}.request_merge'.format(permission_prefix))
 
 
 class PersonNoteView(MasterView):
     """
     Master view for the PersonNote class.
     """
-    model_class = model.PersonNote
+    model_class = PersonNote
     route_prefix = 'person_notes'
     url_prefix = '/people/notes'
     has_versions = True
@@ -638,7 +2034,8 @@ class PersonNoteView(MasterView):
         return note.subject or "(no subject)"
 
     def configure_grid(self, g):
-        super(PersonNoteView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         # person
         g.set_joiner('person', lambda q: q.join(model.Person,
@@ -659,7 +2056,7 @@ class PersonNoteView(MasterView):
         g.set_link('created')
 
     def configure_form(self, f):
-        super(PersonNoteView, self).configure_form(f)
+        super().configure_form(f)
 
         # person
         f.set_readonly('person')
@@ -678,7 +2075,7 @@ def valid_note_uuid(node, kw):
     session = kw['session']
     person_uuid = kw['person_uuid']
     def validate(node, value):
-        note = session.query(model.PersonNote).get(value)
+        note = session.get(PersonNote, value)
         if not note:
             raise colander.Invalid(node, "Note not found")
         if note.person.uuid != person_uuid:
@@ -699,15 +2096,102 @@ class NoteSchema(colander.Schema):
     note_text = colander.SchemaNode(colander.String(), missing='')
 
 
-def includeme(config):
+class MergePeopleRequestView(MasterView):
+    """
+    Master view for the MergePeopleRequest class.
+    """
+    model_class = MergePeopleRequest
+    route_prefix = 'people_merge_requests'
+    url_prefix = '/people/merge-requests'
+    creatable = False
+    editable = False
 
-    # autocomplete
-    config.add_route('people.autocomplete', '/people/autocomplete')
-    config.add_view(PeopleAutocomplete, route_name='people.autocomplete',
-                    renderer='json', permission='people.list')
-    config.add_route('people.autocomplete.employees', '/people/autocomplete/employees')
-    config.add_view(PeopleEmployeesAutocomplete, route_name='people.autocomplete.employees',
-                    renderer='json', permission='people.list')
+    labels = {
+        'removing_uuid': "Removing",
+        'keeping_uuid': "Keeping",
+    }
 
-    PeopleView.defaults(config)
+    grid_columns = [
+        'removing_uuid',
+        'keeping_uuid',
+        'requested',
+        'requested_by',
+        'merged',
+        'merged_by',
+    ]
+
+    form_fields = [
+        'removing_uuid',
+        'keeping_uuid',
+        'requested',
+        'requested_by',
+        'merged',
+        'merged_by',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.set_renderer('removing_uuid', self.render_referenced_person_name)
+        g.set_renderer('keeping_uuid', self.render_referenced_person_name)
+
+        g.filters['merged'].default_active = True
+        g.filters['merged'].default_verb = 'is_null'
+
+        g.set_sort_defaults('requested', 'desc')
+
+        g.set_link('removing_uuid')
+        g.set_link('keeping_uuid')
+
+    def render_referenced_person_name(self, merge_request, field):
+        model = self.model
+        uuid = getattr(merge_request, field)
+        person = self.Session.get(model.Person, uuid)
+        if person:
+            return str(person)
+        return "(person not found)"
+
+    def get_instance_title(self, merge_request):
+        model = self.model
+        removing = self.Session.get(model.Person, merge_request.removing_uuid)
+        keeping = self.Session.get(model.Person, merge_request.keeping_uuid)
+        return "{} -> {}".format(
+            removing or "(not found)",
+            keeping or "(not found)")
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        f.set_renderer('removing_uuid', self.render_referenced_person)
+        f.set_renderer('keeping_uuid', self.render_referenced_person)
+
+    def render_referenced_person(self, merge_request, field):
+        model = self.model
+        uuid = getattr(merge_request, field)
+        person = self.Session.get(model.Person, uuid)
+        if person:
+            text = str(person)
+            url = self.request.route_url('people.view', uuid=person.uuid)
+            return tags.link_to(text, url)
+        return "(person not found)"
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PersonView = kwargs.get('PersonView', base['PersonView'])
+    PersonView.defaults(config)
+
+    PersonNoteView = kwargs.get('PersonNoteView', base['PersonNoteView'])
     PersonNoteView.defaults(config)
+
+    MergePeopleRequestView = kwargs.get('MergePeopleRequestView', base['MergePeopleRequestView'])
+    MergePeopleRequestView.defaults(config)
+
+
+def includeme(config):
+    wutta_config = config.registry.settings['wutta_config']
+    if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False):
+        config.include('tailbone.views.wutta.people')
+    else:
+        defaults(config)
diff --git a/tailbone/views/permissions.py b/tailbone/views/permissions.py
new file mode 100644
index 00000000..5168d544
--- /dev/null
+++ b/tailbone/views/permissions.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Raw Permission Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from sqlalchemy import orm
+
+from rattail.db import model
+
+from tailbone.views import MasterView
+
+
+class PermissionView(MasterView):
+    """
+    Master view for the permissions model.
+    """
+    model_class = model.Permission
+    model_title = "Raw Permission"
+    editable = False
+    bulk_deletable = True
+
+    grid_columns = [
+        'role',
+        'permission',
+    ]
+
+    def query(self, session):
+        model = self.model
+        query = super(PermissionView, self).query(session)
+        query = query.options(orm.joinedload(model.Permission.role))
+        return query
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PermissionView = kwargs.get('PermissionView', base['PermissionView'])
+    PermissionView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/poser/__init__.py b/tailbone/views/poser/__init__.py
new file mode 100644
index 00000000..b81580d4
--- /dev/null
+++ b/tailbone/views/poser/__init__.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Poser Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+
+def includeme(config):
+    config.include('tailbone.views.poser.reports')
+    config.include('tailbone.views.poser.views')
diff --git a/tailbone/views/poser/master.py b/tailbone/views/poser/master.py
new file mode 100644
index 00000000..1f04fe61
--- /dev/null
+++ b/tailbone/views/poser/master.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Poser Views for Views...
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from rattail.util import simple_error
+
+from webhelpers2.html import HTML, tags
+
+from tailbone.views import MasterView
+
+
+class PoserMasterView(MasterView):
+    """
+    Master view base class for Poser
+    """
+    model_key = 'key'
+    filterable = False
+    pageable = False
+
+    def __init__(self, request):
+        super(PoserMasterView, self).__init__(request)
+        app = self.get_rattail_app()
+        self.poser_handler = app.get_poser_handler()
+
+        # nb. pre-load all data b/c all views potentially need access
+        self.data = self.get_data()
+
+    def get_data(self, session=None):
+        if hasattr(self, 'data'):
+            return self.data
+
+        try:
+            return self.get_poser_data(session)
+
+        except Exception as error:
+            self.request.session.flash(simple_error(error), 'error')
+
+            if not self.request.is_root:
+                self.request.session.flash("You must become root in order "
+                                           "to do Poser Setup.", 'error')
+            else:
+                link = tags.link_to("Poser Setup",
+                                    self.request.route_url('poser_setup'))
+                msg = HTML.literal("Please see the {} page.".format(link))
+                self.request.session.flash(msg, 'error')
+            return []
+
+    def get_poser_data(self, session=None):
+        raise NotImplementedError("TODO: you must implement this in subclass")
diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py
new file mode 100644
index 00000000..ded80b18
--- /dev/null
+++ b/tailbone/views/poser/reports.py
@@ -0,0 +1,314 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Poser Report Views
+"""
+
+import os
+
+from rattail.util import simple_error
+
+import colander
+from deform import widget as dfwidget
+from webhelpers2.html import HTML
+
+from .master import PoserMasterView
+
+
+class PoserReportView(PoserMasterView):
+    """
+    Master view for Poser reports
+    """
+    normalized_model_name = 'poser_report'
+    model_title = "Poser Report"
+    model_key = 'report_key'
+    route_prefix = 'poser_reports'
+    url_prefix = '/poser/reports'
+    editable = False            # TODO: should allow this somehow?
+    downloadable = True
+
+    labels = {
+        'report_key': "Poser Key",
+    }
+
+    grid_columns = [
+        'report_key',
+        'report_name',
+        'description',
+        'error',
+    ]
+
+    form_fields = [
+        'report_key',
+        'report_name',
+        'description',
+        'flavor',
+        'include_comments',
+        'module_file',
+        'module_file_path',
+        'error',
+    ]
+
+    has_rows = True
+
+    @property
+    def model_row_class(self):
+        return self.model.ReportOutput
+
+    row_labels = {
+        'id': "ID",
+    }
+
+    row_grid_columns = [
+        'id',
+        'report_name',
+        'filename',
+        'created',
+        'created_by',
+    ]
+
+    def get_poser_data(self, session=None):
+        return self.poser_handler.get_all_reports(ignore_errors=False)
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True)
+        g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True)
+
+        g.set_renderer('error', self.render_report_error)
+
+        g.set_sort_defaults('report_name')
+
+        g.set_link('report_key')
+        g.set_link('report_name')
+        g.set_link('description')
+        g.set_link('error')
+
+        g.set_searchable('report_key')
+        g.set_searchable('report_name')
+        g.set_searchable('description')
+
+        if self.request.has_perm('report_output.create'):
+            g.actions.append(self.make_action(
+                'generate', icon='arrow-circle-right',
+                url=self.get_generate_url))
+
+    def get_generate_url(self, report, i=None):
+        if not report.get('error'):
+            return self.request.route_url('generate_specific_report',
+                                          type_key=report['report'].type_key)
+
+    def render_report_error(self, report, field):
+        error = report.get('error')
+        if error:
+            return HTML.tag('span', class_='has-background-warning', c=[error])
+
+    def get_instance(self):
+        report_key = self.request.matchdict['report_key']
+        for report in self.get_data():
+            if report['report_key'] == report_key:
+                return report
+        raise self.notfound()
+
+    def get_instance_title(self, report):
+        return report['report_name']
+
+    def make_form_schema(self):
+        return PoserReportSchema()
+
+    def make_create_form(self):
+        return self.make_form({})
+
+    def save_create_form(self, form):
+        self.before_create(form)
+
+        report = self.poser_handler.make_report(
+            form.validated['report_key'],
+            form.validated['report_name'],
+            form.validated['description'],
+            flavor=form.validated['flavor'],
+            include_comments=form.validated['include_comments'])
+
+        return report
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        report = f.model_instance
+
+        # report_key
+        f.set_default('report_key', 'cool_widgets')
+        f.set_helptext('report_key', "Unique computer-friendly key for the report type.")
+        if self.creating:
+            f.set_validator('report_key', self.unique_report_key)
+
+        # report_name
+        f.set_default('report_name', "Cool Widgets Weekly")
+        f.set_helptext('report_name', "Human-friendly display name for the report.")
+
+        # description
+        f.set_default('description', "How many cool widgets we come across each week")
+        f.set_helptext('description', "Brief description of the report.")
+
+        # flavor
+        if self.creating:
+            f.set_helptext('flavor', "Determines the type of sample code to generate.")
+            flavors = self.poser_handler.get_supported_report_flavors()
+            values = [(key, flavor['description'])
+                      for key, flavor in flavors.items()]
+            f.set_widget('flavor', dfwidget.SelectWidget(values=values))
+            f.set_validator('flavor', colander.OneOf(flavors))
+            if flavors:
+                f.set_default('flavor', list(flavors)[0])
+        else:
+            f.remove('flavor')
+
+        # include_comments
+        if not self.creating:
+            f.remove('include_comments')
+
+        # module_file
+        if self.creating:
+            f.remove('module_file')
+        else:
+            # nb. set this key as workaround for render method, which
+            # expects object to have this field
+            report['module_file'] = os.path.basename(report['module_file_path'])
+            f.set_renderer('module_file', self.render_downloadable_file)
+
+        # error
+        if self.creating or not report.get('error'):
+            f.remove('error')
+        else:
+            f.set_renderer('error', self.render_report_error)
+
+    def unique_report_key(self, node, value):
+        for report in self.get_data():
+            if report['report_key'] == value:
+                raise node.raise_invalid("Poser report key must be unique")
+
+    def download_path(self, report, filename):
+        return report['module_file_path']
+
+    def get_row_data(self, report):
+        model = self.model
+
+        if report.get('error'):
+            return []
+
+        return self.Session.query(model.ReportOutput)\
+                           .filter(model.ReportOutput.report_type == report['report'].type_key)
+
+    def get_parent(self, output):
+        key = output.report_type
+        for report in self.get_data():
+            if not report.get('error'):
+                if report['report'].type_key == key:
+                    return report
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        g.set_renderer('id', self.render_id_str)
+
+        g.set_sort_defaults('created', 'desc')
+
+        g.set_link('id')
+        g.set_link('filename')
+        g.set_link('created')
+
+    def row_view_action_url(self, output, i):
+        return self.request.route_url('report_output.view', uuid=output.uuid)
+
+    def delete_instance(self, report):
+        self.poser_handler.delete_report(report['report_key'])
+
+    def replace(self):
+        app = self.get_rattail_app()
+        report = self.get_instance()
+
+        value = self.request.POST['replacement_module']
+        tempdir = app.make_temp_dir()
+        filepath = os.path.join(tempdir, os.path.basename(value.filename))
+        with open(filepath, 'wb') as f:
+            f.write(value.file.read())
+
+        try:
+            newreport = self.poser_handler.replace_report(report['report_key'],
+                                                          filepath)
+        except Exception as error:
+            self.request.session.flash(simple_error(error), 'error')
+        else:
+            report = newreport
+        finally:
+            os.remove(filepath)
+            os.rmdir(tempdir)
+
+        return self.redirect(self.get_action_url('view', report))
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._poser_report_defaults(config)
+
+    @classmethod
+    def _poser_report_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
+        model_title = cls.get_model_title()
+
+        # replace module
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.replace'.format(permission_prefix),
+                                       "Upload replacement module for {}".format(model_title))
+        config.add_route('{}.replace'.format(route_prefix),
+                         '{}/replace'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='replace',
+                        route_name='{}.replace'.format(route_prefix),
+                        permission='{}.replace'.format(permission_prefix))
+
+
+class PoserReportSchema(colander.MappingSchema):
+
+    report_key = colander.SchemaNode(colander.String())
+
+    report_name = colander.SchemaNode(colander.String())
+
+    description = colander.SchemaNode(colander.String())
+
+    flavor = colander.SchemaNode(colander.String())
+
+    include_comments = colander.SchemaNode(colander.Bool())
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PoserReportView = kwargs.get('PoserReportView', base['PoserReportView'])
+    PoserReportView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py
new file mode 100644
index 00000000..27efd549
--- /dev/null
+++ b/tailbone/views/poser/views.py
@@ -0,0 +1,330 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Poser Views for Views...
+"""
+
+import colander
+
+from .master import PoserMasterView
+from tailbone.providers import get_all_providers
+
+
+class PoserViewView(PoserMasterView):
+    """
+    Master view for Poser views
+    """
+    normalized_model_name = 'poser_view'
+    model_title = "Poser View"
+    route_prefix = 'poser_views'
+    url_prefix = '/poser/views'
+    configurable = True
+    config_title = "Included Views"
+
+    # TODO
+    creatable = False
+    editable = False
+    deletable = False
+    # downloadable = True
+
+    grid_columns = [
+        'key',
+        'class_name',
+        'description',
+        'error',
+    ]
+
+    def get_poser_data(self, session=None):
+        return self.poser_handler.get_all_tailbone_views()
+
+    def make_form_schema(self):
+        return PoserViewSchema()
+
+    def make_create_form(self):
+        return self.make_form({})
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        view = f.model_instance
+
+        # key
+        f.set_default('key', 'cool_widget')
+        f.set_helptext('key', "Unique key for the view; used as basis for filename.")
+        if self.creating:
+            f.set_validator('view_key', self.unique_view_key)
+
+        # class_name
+        f.set_default('class_name', "CoolWidget")
+        f.set_helptext('class_name', "Python-friendly basis for view class name.")
+
+        # description
+        f.set_default('description', "Master view for Cool Widgets")
+        f.set_helptext('description', "Brief description of the view.")
+
+    def unique_view_key(self, node, value):
+        for view in self.get_data():
+            if view['key'] == value:
+                raise node.raise_invalid("Poser view key must be unique")
+
+    def collect_available_view_settings(self):
+
+        # TODO: this probably should be more dynamic?  definitely need
+        # to let integration packages register some more options...
+
+        everything = {'rattail': {
+
+            'people': {
+
+                # TODO: need some way for integration / extension
+                # packages to register alternate view options for some
+                # of these.  that is the main reason these are dicts
+                # even though at the moment it's a bit overkill.
+
+                'tailbone.views.customers': {
+                    # 'spec': 'tailbone.views.customers',
+                    'label': "Customers",
+                },
+                'tailbone.views.customergroups': {
+                    # 'spec': 'tailbone.views.customergroups',
+                    'label': "Customer Groups",
+                },
+                'tailbone.views.employees': {
+                    # 'spec': 'tailbone.views.employees',
+                    'label': "Employees",
+                },
+                'tailbone.views.members': {
+                    # 'spec': 'tailbone.views.members',
+                    'label': "Members",
+                },
+            },
+
+            'products': {
+
+                'tailbone.views.departments': {
+                    # 'spec': 'tailbone.views.departments',
+                    'label': "Departments",
+                },
+
+                'tailbone.views.ifps': {
+                    # 'spec': 'tailbone.views.ifps',
+                    'label': "IFPS PLU Codes",
+                },
+
+                'tailbone.views.subdepartments': {
+                    # 'spec': 'tailbone.views.subdepartments',
+                    'label': "Subdepartments",
+                },
+
+                'tailbone.views.vendors': {
+                    # 'spec': 'tailbone.views.vendors',
+                    'label': "Vendors",
+                },
+
+                'tailbone.views.products': {
+                    # 'spec': 'tailbone.views.products',
+                    'label': "Products",
+                },
+
+                'tailbone.views.brands': {
+                    # 'spec': 'tailbone.views.brands',
+                    'label': "Brands",
+                },
+
+                'tailbone.views.categories': {
+                    # 'spec': 'tailbone.views.categories',
+                    'label': "Categories",
+                },
+
+                'tailbone.views.depositlinks': {
+                    # 'spec': 'tailbone.views.depositlinks',
+                    'label': "Deposit Links",
+                },
+
+                'tailbone.views.families': {
+                    # 'spec': 'tailbone.views.families',
+                    'label': "Families",
+                },
+
+                'tailbone.views.reportcodes': {
+                    # 'spec': 'tailbone.views.reportcodes',
+                    'label': "Report Codes",
+                },
+            },
+
+            'batches': {
+
+                'tailbone.views.batch.delproduct': {
+                    'label': "Delete Product",
+                },
+                'tailbone.views.batch.inventory': {
+                    'label': "Inventory",
+                },
+                'tailbone.views.batch.labels': {
+                    'label': "Labels",
+                },
+                'tailbone.views.batch.newproduct': {
+                    'label': "New Product",
+                },
+                'tailbone.views.batch.pricing': {
+                    'label': "Pricing",
+                },
+                'tailbone.views.batch.product': {
+                    'label': "Product",
+                },
+                'tailbone.views.batch.vendorcatalog': {
+                    'label': "Vendor Catalog",
+                },
+            },
+
+            'other': {
+
+                'tailbone.views.datasync': {
+                    # 'spec': 'tailbone.views.datasync',
+                    'label': "DataSync",
+                },
+
+                'tailbone.views.importing': {
+                    # 'spec': 'tailbone.views.importing',
+                    'label': "Importing / Exporting",
+                },
+
+                'tailbone.views.stores': {
+                    # 'spec': 'tailbone.views.stores',
+                    'label': "Stores",
+                },
+
+                'tailbone.views.taxes': {
+                    # 'spec': 'tailbone.views.taxes',
+                    'label': "Taxes",
+                },
+            },
+        }}
+
+        for key, views in everything['rattail'].items():
+            for vkey, view in views.items():
+                view['options'] = [vkey]
+
+        providers = get_all_providers(self.rattail_config)
+        for provider in providers.values():
+
+            # loop thru provider top-level groups
+            for topkey, groups in provider.get_provided_views().items():
+
+                # get or create top group
+                topgroup = everything.setdefault(topkey, {})
+
+                # loop thru provider view groups
+                for key, views in groups.items():
+
+                    # add group to top group, if it's new
+                    if key not in topgroup:
+                        topgroup[key] = views
+
+                        # also must init the options for group
+                        for vkey, view in views.items():
+                            view['options'] = [vkey]
+
+                    else: # otherwise must "update" existing group
+
+                        # get ref to existing ("standard") group
+                        stdgroup = topgroup[key]
+
+                        # loop thru views within provider group
+                        for vkey, view in views.items():
+
+                            # add view to group if it's new
+                            if vkey not in stdgroup:
+                                view['options'] = [vkey]
+                                stdgroup[vkey] = view
+
+                            else: # otherwise "update" existing view
+                                stdgroup[vkey]['options'].append(view['spec'])
+
+        return everything
+
+    def configure_get_simple_settings(self):
+        settings = []
+
+        view_settings = self.collect_available_view_settings()
+        for topgroup in view_settings.values():
+            for view_section, section_settings in topgroup.items():
+                for key in section_settings:
+                    settings.append({'section': 'tailbone.includes',
+                                     'option': key})
+
+        return settings
+
+    def configure_get_context(self, simple_settings=None,
+                              input_file_templates=True):
+
+        # first get normal context
+        context = super().configure_get_context(
+            simple_settings=simple_settings,
+            input_file_templates=input_file_templates)
+
+        # first add available options
+        view_settings = self.collect_available_view_settings()
+        view_options = {}
+        for topgroup in view_settings.values():
+            for key, views in topgroup.items():
+                for vkey, view in views.items():
+                    view_options[vkey] = view['options']
+        context['view_options'] = view_options
+
+        # then add all available settings as sorted (key, label) options
+        for topkey, topgroup in view_settings.items():
+            for key in list(topgroup):
+                settings = topgroup[key]
+                settings = [(key, setting.get('label', key))
+                            for key, setting in settings.items()]
+                settings.sort(key=lambda itm: itm[1])
+                topgroup[key] = settings
+        context['view_settings'] = view_settings
+
+        return context
+
+    def configure_flash_settings_saved(self):
+        super().configure_flash_settings_saved()
+        self.request.session.flash("Please restart the web app!", 'warning')
+
+
+class PoserViewSchema(colander.MappingSchema):
+
+    key = colander.SchemaNode(colander.String())
+
+    class_name = colander.SchemaNode(colander.String())
+
+    description = colander.SchemaNode(colander.String())
+
+    # include_comments = colander.SchemaNode(colander.Bool())
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PoserViewView = kwargs.get('PoserViewView', base['PoserViewView'])
+    PoserViewView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index e22e2554..3986f8b0 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,11 @@
 "Principal" master view
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import copy
+from collections import OrderedDict
 
-from rattail.db.auth import has_permission
 from rattail.core import Object
-from rattail.util import OrderedDict
 
-import wtforms
 from webhelpers2.html import HTML
 
 from tailbone.db import Session
@@ -44,10 +40,10 @@ class PrincipalMasterView(MasterView):
     Master view base class for security principal models, i.e. User and Role.
     """
 
-    def get_fallback_templates(self, template, mobile=False):
+    def get_fallback_templates(self, template, **kwargs):
         return [
             '/principal/{}.mako'.format(template),
-        ] + super(PrincipalMasterView, self).get_fallback_templates(template, mobile=mobile)
+        ] + super().get_fallback_templates(template, **kwargs)
 
     def perm_sortkey(self, item):
         key, value = item
@@ -57,45 +53,56 @@ class PrincipalMasterView(MasterView):
         """
         View for finding all users who have been granted a given permission
         """
-        permissions = copy.deepcopy(self.request.registry.settings.get('tailbone_permissions', {}))
+        permissions = copy.deepcopy(
+            self.request.registry.settings.get('wutta_permissions', {}))
 
         # sort groups, and permissions for each group, for UI's sake
         sorted_perms = sorted(permissions.items(), key=self.perm_sortkey)
         for key, group in sorted_perms:
             group['perms'] = sorted(group['perms'].items(), key=self.perm_sortkey)
 
-        # group options are stable, permission options may depend on submitted group
-        group_choices = [(gkey, group['label']) for gkey, group in sorted_perms]
-        permission_choices = [('_any_', "(any)")]
-        if self.request.method == 'POST':
-            if self.request.POST.get('permission_group') in permissions:
-                permission_choices.extend([
-                    (pkey, perm['label'])
-                    for pkey, perm in permissions[self.request.POST['permission_group']]['perms']
-                ])
-
-        class PermissionForm(wtforms.Form):
-            permission_group = wtforms.SelectField(choices=group_choices)
-            permission = wtforms.SelectField(choices=permission_choices)
-
+        # if both field values are in query string, do lookup
         principals = None
-        form = PermissionForm(self.request.POST)
-        if self.request.method == 'POST' and form.validate():
-            permission = form.permission.data
-            principals = self.find_principals_with_permission(self.Session(), permission)
+        permission_group = self.request.GET.get('permission_group')
+        permission = self.request.GET.get('permission')
+        grid = None
+        if permission_group and permission:
+            principals = self.find_principals_with_permission(self.Session(),
+                                                              permission)
+            grid = self.find_by_perm_make_results_grid(principals)
+        else: # otherwise clear both values
+            permission_group = None
+            permission = None
 
-        context = {'form': form, 'permissions': sorted_perms, 'principals': principals}
+        context = {
+            'permissions': sorted_perms,
+            'principals': principals,
+            'principals_data': self.find_by_perm_results_data(principals),
+            'grid': grid,
+        }
 
-        if self.get_use_buefy():
-            perms = self.get_buefy_perms_data(sorted_perms)
-            context['buefy_perms'] = perms
-            context['buefy_sorted_groups'] = list(perms)
-            context['selected_group'] = self.request.POST.get('permission_group', 'common')
-            context['selected_permission'] = self.request.POST.get('permission', None)
+        perms = self.get_perms_data(sorted_perms)
+        context['perms_data'] = perms
+        context['sorted_groups_data'] = list(perms)
+
+        if permission_group and permission_group not in perms:
+            permission_group = None
+        if permission:
+            if permission_group:
+                group = dict([(p['permkey'], p) for p in perms[permission_group]['permissions']])
+                if permission in group:
+                    context['selected_permission_label'] = group[permission]['label']
+                else:
+                    permission = None
+            else:
+                permission = None
+
+        context['selected_group'] = permission_group
+        context['selected_permission'] = permission
 
         return self.render_to_response('find_by_perm', context)
 
-    def get_buefy_perms_data(self, sorted_perms):
+    def get_perms_data(self, sorted_perms):
         data = OrderedDict()
         for gkey, group in sorted_perms:
 
@@ -114,6 +121,35 @@ class PrincipalMasterView(MasterView):
 
         return data
 
+    def find_by_perm_make_results_grid(self, principals):
+        route_prefix = self.get_route_prefix()
+        factory = self.get_grid_factory()
+        g = factory(self.request,
+                    key=f'{route_prefix}.results',
+                    data=[],
+                    columns=[],
+                    actions=[
+                        self.make_action('view', icon='eye',
+                                         click_handler='navigateTo(props.row._url)'),
+                    ])
+        self.find_by_perm_configure_results_grid(g)
+        return g
+
+    def find_by_perm_configure_results_grid(self, g):
+        pass
+
+    def find_by_perm_results_data(self, principals):
+        data = []
+        for principal in principals or []:
+            data.append(self.find_by_perm_normalize(principal))
+        return data
+
+    def find_by_perm_normalize(self, principal):
+        return {
+            'uuid': principal.uuid,
+            '_url': self.get_action_url('view', principal),
+        }
+
     @classmethod
     def defaults(cls, config):
         cls._principal_defaults(config)
@@ -127,8 +163,11 @@ class PrincipalMasterView(MasterView):
         model_title_plural = cls.get_model_title_plural()
 
         # find principal by permission
-        config.add_route('{}.find_by_perm'.format(route_prefix), '{}/find-by-perm'.format(url_prefix))
-        config.add_view(cls, attr='find_by_perm', route_name='{}.find_by_perm'.format(route_prefix),
+        config.add_route('{}.find_by_perm'.format(route_prefix),
+                         '{}/find-by-perm'.format(url_prefix),
+                         request_method='GET')
+        config.add_view(cls, attr='find_by_perm',
+                        route_name='{}.find_by_perm'.format(route_prefix),
                         permission='{}.find_by_perm'.format(permission_prefix))
         config.add_tailbone_permission(permission_prefix, '{}.find_by_perm'.format(permission_prefix),
                                        "Find all {} with permission X".format(model_title_plural))
@@ -144,6 +183,9 @@ class PermissionsRenderer(Object):
         return self.render()
 
     def render(self):
+        app = self.request.rattail_config.get_app()
+        auth = app.get_auth_handler()
+
         principal = self.principal
         html = ''
         for groupkey in sorted(self.permissions, key=lambda k: self.permissions[k]['label'].lower()):
@@ -151,9 +193,9 @@ class PermissionsRenderer(Object):
             perms = self.permissions[groupkey]['perms']
             rendered = False
             for key in sorted(perms, key=lambda p: perms[p]['label'].lower()):
-                checked = has_permission(Session(), principal, key,
-                                         include_guest=self.include_guest,
-                                         include_authenticated=self.include_authenticated)
+                checked = auth.has_permission(Session(), principal, key,
+                                              include_anonymous=self.include_guest,
+                                              include_authenticated=self.include_authenticated)
                 if checked:
                     label = perms[key]['label']
                     span = HTML.tag('span', c="[X]" if checked else "[ ]")
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 8b2b8575..8461ae03 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,34 +24,28 @@
 Product Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import re
 import logging
-
-import six
+from collections import OrderedDict
 import humanize
 import sqlalchemy as sa
 from sqlalchemy import orm
 import sqlalchemy_continuum as continuum
 
 from rattail import enum, pod, sil
-from rattail.db import model, api, auth, Session as RattailSession
+from rattail.db import api, auth, Session as RattailSession
+from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem
 from rattail.gpc import GPC
 from rattail.threads import Thread
 from rattail.exceptions import LabelPrintingError
-from rattail.util import load_object, pretty_quantity, OrderedDict
-from rattail.batch import get_batch_handler
-from rattail.time import localtime, make_utc
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
-from pyramid import httpexceptions
 from webhelpers2.html import tags, HTML
 
 from tailbone import forms, grids
-from tailbone.db import Session
-from tailbone.views import MasterView, AutocompleteView
+from tailbone.views import MasterView
 from tailbone.util import raw_datetime
 
 
@@ -77,42 +71,45 @@ log = logging.getLogger(__name__)
 #         return query
 
 
-class ProductsView(MasterView):
+class ProductView(MasterView):
     """
     Master view for the Product class.
     """
-    model_class = model.Product
-    supports_mobile = True
+    model_class = Product
     has_versions = True
     results_downloadable_xlsx = True
+    supports_autocomplete = True
+    bulk_deletable = True
+    mergeable = True
+    touchable = True
+    configurable = True
 
     labels = {
         'item_id': "Item ID",
         'upc': "UPC",
+        'vendor': "Vendor (preferred)",
+        'vendor_any': "Vendor (any)",
         'status_code': "Status",
         'tax1': "Tax 1",
         'tax2': "Tax 2",
         'tax3': "Tax 3",
+        'tpr_price': "TPR Price",
+        'tpr_price_ends': "TPR Price Ends",
     }
 
     grid_columns = [
-        'upc',
+        '_product_key_',
         'brand',
         'description',
         'size',
         'department',
         'vendor',
-        'cost',
-        'true_cost',
-        'true_margin',
         'regular_price',
         'current_price',
     ]
 
     form_fields = [
-        'item_id',
-        'scancode',
-        'upc',
+        '_product_key_',
         'brand',
         'description',
         'unit_size',
@@ -124,6 +121,7 @@ class ProductsView(MasterView):
         'default_pack',
         'case_size',
         'weighed',
+        'average_weight',
         'department',
         'subdepartment',
         'category',
@@ -133,6 +131,10 @@ class ProductsView(MasterView):
         'regular_price',
         'current_price',
         'current_price_ends',
+        'sale_price',
+        'sale_price_ends',
+        'tpr_price',
+        'tpr_price_ends',
         'vendor',
         'cost',
         'deposit_link',
@@ -159,64 +161,43 @@ class ProductsView(MasterView):
         'inventory_on_order',
     ]
 
-    mobile_form_fields = form_fields
-
-    # These aliases enable the grid queries to filter products which may be
-    # purchased from *any* vendor, and yet sort by only the "preferred" vendor
-    # (since that's what shows up in the grid column).
-    ProductVendorCost = orm.aliased(model.ProductCost)
-    ProductVendorCostAny = orm.aliased(model.ProductCost)
-    VendorAny = orm.aliased(model.Vendor)
-
-    # same, but for prices
-    RegularPrice = orm.aliased(model.ProductPrice)
-    CurrentPrice = orm.aliased(model.ProductPrice)
-
     def __init__(self, request):
-        super(ProductsView, self).__init__(request)
-        self.print_labels = request.rattail_config.getbool('tailbone', 'products.print_labels', default=False)
+        super().__init__(request)
+        self.expose_label_printing = self.rattail_config.getbool(
+            'tailbone', 'products.print_labels', default=False)
+
+        app = self.get_rattail_app()
+        self.products_handler = app.get_products_handler()
+        self.merge_handler = self.products_handler
+        # TODO: deprecate / remove these
+        self.product_handler = self.products_handler
+        self.handler = self.products_handler
 
     def query(self, session):
-        user = self.request.user
-        if user and user not in session:
-            user = session.merge(user)
+        query = super().query(session)
+        model = self.model
 
-        query = session.query(model.Product)
-        # TODO: was this old `has_permission()` call here for a reason..? hope not..
-        # if not auth.has_permission(session, user, 'products.view_deleted'):
-        if not self.request.has_perm('products.view_deleted'):
+        if not self.has_perm('view_deleted'):
             query = query.filter(model.Product.deleted == False)
 
-        # TODO: This used to be a good idea I thought...but in dev it didn't
-        # seem to make much difference, except with a larger (50K) data set it
-        # totally bogged things down instead of helping...
-        # query = query\
-        #     .options(orm.joinedload(model.Product.brand))\
-        #     .options(orm.joinedload(model.Product.department))\
-        #     .options(orm.joinedload(model.Product.subdepartment))\
-        #     .options(orm.joinedload(model.Product.regular_price))\
-        #     .options(orm.joinedload(model.Product.current_price))\
-        #     .options(orm.joinedload(model.Product.vendor))
-
-        query = query.outerjoin(model.ProductInventory)
-
         return query
 
+    def get_departments(self):
+        """
+        Returns the list of departments to be exposed in a drop-down.
+        """
+        model = self.model
+        return self.Session.query(model.Department)\
+                           .filter(sa.or_(
+                               model.Department.product == True,
+                               model.Department.product == None))\
+                           .order_by(model.Department.name)\
+                           .all()
+
     def configure_grid(self, g):
-        super(ProductsView, self).configure_grid(g)
-
-        def join_vendor(q):
-            return q.outerjoin(self.ProductVendorCost,
-                               sa.and_(
-                                   self.ProductVendorCost.product_uuid == model.Product.uuid,
-                                   self.ProductVendorCost.preference == 1))\
-                    .outerjoin(model.Vendor)
-
-        def join_vendor_any(q):
-            return q.outerjoin(self.ProductVendorCostAny,
-                               self.ProductVendorCostAny.product_uuid == model.Product.uuid)\
-                    .outerjoin(self.VendorAny,
-                               self.VendorAny.uuid == self.ProductVendorCostAny.vendor_uuid)
+        super().configure_grid(g)
+        app = self.get_rattail_app()
+        model = self.model
 
         ProductCostCode = orm.aliased(model.ProductCost)
         ProductCostCodeAny = orm.aliased(model.ProductCost)
@@ -231,21 +212,62 @@ class ProductsView(MasterView):
             return q.outerjoin(ProductCostCodeAny,
                                ProductCostCodeAny.product_uuid == model.Product.uuid)
 
-        g.joiners['brand'] = lambda q: q.outerjoin(model.Brand)
-        g.joiners['department'] = lambda q: q.outerjoin(model.Department,
-                                                        model.Department.uuid == model.Product.department_uuid)
-        g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment,
-                                                           model.Subdepartment.uuid == model.Product.subdepartment_uuid)
-        g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode)
-        g.joiners['vendor'] = join_vendor
-        g.joiners['vendor_any'] = join_vendor_any
-        g.joiners['vendor_code'] = join_vendor_code
-        g.joiners['vendor_code_any'] = join_vendor_code_any
+        # product key
+        field = self.get_product_key_field()
+        g.filters[field].default_active = True
+        g.filters[field].default_verb = 'equal'
+        g.set_sort_defaults(field)
+        g.set_link(field)
 
-        g.sorters['brand'] = g.make_sorter(model.Brand.name)
-        g.sorters['department'] = g.make_sorter(model.Department.name)
-        g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name)
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+        # brand
+        g.set_joiner('brand', lambda q: q.outerjoin(model.Brand))
+        g.set_sorter('brand', model.Brand.name)
+        g.set_filter('brand', model.Brand.name,
+                     default_active=True, default_verb='contains')
+
+        # department
+        g.set_joiner('department', lambda q: q.outerjoin(model.Department))
+        g.set_sorter('department', model.Department.name)
+        departments = self.get_departments()
+        department_choices = OrderedDict([('', "(any)")]
+                                         + [(d.uuid, d.name) for d in departments])
+        g.set_filter('department', model.Department.uuid,
+                     value_enum=department_choices,
+                     verbs=['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'],
+                     default_active=True, default_verb='equal')
+
+        # subdepartment
+        g.set_joiner('subdepartment', lambda q: q.outerjoin(
+            model.Subdepartment,
+            model.Subdepartment.uuid == model.Product.subdepartment_uuid))
+        g.set_sorter('subdepartment', model.Subdepartment.name)
+        g.set_filter('subdepartment', model.Subdepartment.name)
+
+        g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode)
+
+        # vendor
+        ProductVendorCost = orm.aliased(model.ProductCost)
+        def join_vendor(q):
+            return q.outerjoin(ProductVendorCost,
+                               sa.and_(
+                                   ProductVendorCost.product_uuid == model.Product.uuid,
+                                   ProductVendorCost.preference == 1))\
+                    .outerjoin(model.Vendor)
+        g.set_joiner('vendor', join_vendor)
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name)
+
+        # vendor_any
+        ProductVendorCostAny = orm.aliased(model.ProductCost)
+        VendorAny = orm.aliased(model.Vendor)
+        def join_vendor_any(q):
+            return q.outerjoin(ProductVendorCostAny,
+                               ProductVendorCostAny.product_uuid == model.Product.uuid)\
+                    .outerjoin(VendorAny,
+                               VendorAny.uuid == ProductVendorCostAny.vendor_uuid)
+        g.set_joiner('vendor_any', join_vendor_any)
+        g.set_filter('vendor_any', VendorAny.name)
+                     # factory=VendorAnyFilter, joiner=join_vendor_any)
 
         ProductTrueCost = orm.aliased(model.ProductVolatile)
         ProductTrueMargin = orm.aliased(model.ProductVolatile)
@@ -263,48 +285,77 @@ class ProductsView(MasterView):
         g.set_renderer('true_margin', self.render_true_margin)
 
         # on_hand
-        g.set_sorter('on_hand', model.ProductInventory.on_hand)
-        g.set_filter('on_hand', model.ProductInventory.on_hand)
+        InventoryOnHand = orm.aliased(model.ProductInventory)
+        g.set_joiner('on_hand', lambda q: q.outerjoin(InventoryOnHand))
+        g.set_sorter('on_hand', InventoryOnHand.on_hand)
+        g.set_filter('on_hand', InventoryOnHand.on_hand)
 
         # on_order
-        g.set_sorter('on_order', model.ProductInventory.on_order)
-        g.set_filter('on_order', model.ProductInventory.on_order)
+        InventoryOnOrder = orm.aliased(model.ProductInventory)
+        g.set_sorter('on_order', InventoryOnOrder.on_order)
+        g.set_filter('on_order', InventoryOnOrder.on_order)
 
-        g.filters['upc'].default_active = True
-        g.filters['upc'].default_verb = 'equal'
         g.filters['description'].default_active = True
         g.filters['description'].default_verb = 'contains'
-        g.filters['brand'] = g.make_filter('brand', model.Brand.name,
-                                           default_active=True, default_verb='contains')
-        g.filters['department'] = g.make_filter('department', model.Department.name,
-                                                default_active=True, default_verb='contains')
-        g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name)
         g.filters['code'] = g.make_filter('code', model.ProductCode.code)
-        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name)
-        g.filters['vendor_any'] = g.make_filter('vendor_any', self.VendorAny.name)
-                                                # factory=VendorAnyFilter, joiner=join_vendor_any)
-        g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code)
-        g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code)
+
+        # g.joiners['vendor_code_any'] = join_vendor_code_any
+        # g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code)
+        # g.joiners['vendor_code'] = join_vendor_code
+        # g.filters['vendor_code'] = g.make_filter('vendor_code', ProductCostCode.code)
+
+        # vendor_code*
+        g.set_joiner('vendor_code', join_vendor_code)
+        g.set_filter('vendor_code', ProductCostCode.code)
+        g.set_label('vendor_code', "Vendor Code (preferred)")
+        g.set_joiner('vendor_code_any', join_vendor_code_any)
+        g.set_filter('vendor_code_any', ProductCostCodeAny.code)
+        g.set_label('vendor_code_any', "Vendor Code (any)")
 
         # category
-        g.set_joiner('category', lambda q: q.outerjoin(model.Category))
-        g.set_filter('category', model.Category.name)
+        CategoryByCode = orm.aliased(model.Category)
+        CategoryByName = orm.aliased(model.Category)
+        g.set_joiner('category_code',
+                     lambda q: q.outerjoin(CategoryByCode,
+                                           CategoryByCode.uuid == model.Product.category_uuid))
+        g.set_filter('category_code', CategoryByCode.code)
+        g.set_joiner('category_name',
+                     lambda q: q.outerjoin(CategoryByName,
+                                           CategoryByName.uuid == model.Product.category_uuid))
+        g.set_filter('category_name', CategoryByName.name)
 
         # family
         g.set_joiner('family', lambda q: q.outerjoin(model.Family))
         g.set_filter('family', model.Family.name)
 
+        # regular_price
         g.set_label('regular_price', "Reg. Price")
+        RegularPrice = orm.aliased(model.ProductPrice)
         g.set_joiner('regular_price', lambda q: q.outerjoin(
-            self.RegularPrice, self.RegularPrice.uuid == model.Product.regular_price_uuid))
-        g.set_sorter('regular_price', self.RegularPrice.price)
-        g.set_filter('regular_price', self.RegularPrice.price, label="Regular Price")
+            RegularPrice, RegularPrice.uuid == model.Product.regular_price_uuid))
+        g.set_sorter('regular_price', RegularPrice.price)
+        g.set_filter('regular_price', RegularPrice.price, label="Regular Price")
 
+        # current_price
         g.set_label('current_price', "Cur. Price")
+        g.set_renderer('current_price', self.render_current_price_for_grid)
+        CurrentPrice = orm.aliased(model.ProductPrice)
         g.set_joiner('current_price', lambda q: q.outerjoin(
-            self.CurrentPrice, self.CurrentPrice.uuid == model.Product.current_price_uuid))
-        g.set_sorter('current_price', self.CurrentPrice.price)
-        g.set_filter('current_price', self.CurrentPrice.price, label="Current Price")
+            CurrentPrice, CurrentPrice.uuid == model.Product.current_price_uuid))
+        g.set_sorter('current_price', CurrentPrice.price)
+        g.set_filter('current_price', CurrentPrice.price, label="Current Price")
+
+        # tpr_price
+        TPRPrice = orm.aliased(model.ProductPrice)
+        g.set_joiner('tpr_price', lambda q: q.outerjoin(
+            TPRPrice, TPRPrice.uuid == model.Product.tpr_price_uuid))
+        g.set_filter('tpr_price', TPRPrice.price)
+
+        # sale_price
+        SalePrice = orm.aliased(model.ProductPrice)
+        g.set_joiner('sale_price', lambda q: q.outerjoin(
+            SalePrice, SalePrice.uuid == model.Product.sale_price_uuid))
+        g.set_filter('sale_price', SalePrice.price)
 
         # suggested_price
         g.set_renderer('suggested_price', self.render_grid_suggested_price)
@@ -319,42 +370,348 @@ class ProductsView(MasterView):
         g.set_renderer('cost', self.render_cost)
         g.set_label('cost', "Unit Cost")
 
+        # tax
+        g.set_joiner('tax', lambda q: q.outerjoin(model.Tax))
+        taxes = self.Session.query(model.Tax)\
+                            .order_by(model.Tax.code)\
+                            .all()
+        taxes = OrderedDict([(tax.uuid, tax.description)
+                             for tax in taxes])
+        g.set_filter('tax', model.Tax.uuid, value_enum=taxes)
+
         # report_code_name
         g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode))
         g.set_filter('report_code_name', model.ReportCode.name)
 
-        g.set_sort_defaults('upc')
-
-        if self.print_labels and self.request.has_perm('products.print_labels'):
-            g.more_actions.append(grids.GridAction('print_label', icon='print'))
-
-        g.set_type('upc', 'gpc')
+        if self.expose_label_printing and self.has_perm('print_labels'):
+            g.actions.append(self.make_action(
+                'print_label', icon='print', url='#',
+                click_handler='quickLabelPrint(props.row)'))
 
         g.set_renderer('regular_price', self.render_price)
-        g.set_renderer('current_price', self.render_price)
         g.set_renderer('on_hand', self.render_on_hand)
         g.set_renderer('on_order', self.render_on_order)
 
-        g.set_link('upc')
         g.set_link('item_id')
         g.set_link('description')
 
-        g.set_label('vendor', "Vendor (preferred)")
-        g.set_label('vendor_any', "Vendor (any)")
-        g.set_label('vendor', "Pref. Vendor")
+    def render_cost(self, product, field):
+        cost = getattr(product, field)
+        if not cost:
+            return ""
+        if cost.unit_cost is None:
+            return ""
+        return "${:0.2f}".format(cost.unit_cost)
 
-    def configure_common_form(self, f):
-        super(ProductsView, self).configure_common_form(f)
+    def render_price(self, product, field):
+        # TODO: previously this rendered null (empty string) if
+        # product was marked "not for sale" - but why? important?
+        #if not product.not_for_sale:
+        price = product[field]
+        if price:
+            return self.products_handler.render_price(price)
+        
+    def render_current_price_for_grid(self, product, field):
+        text = self.render_price(product, field) or ""
+
+        price = product.current_price
+        if price:
+            app = self.get_rattail_app()
+
+            if price.starts:
+                starts = app.localtime(price.starts, from_utc=True)
+                starts = app.render_date(starts.date())
+            else:
+                starts = "??"
+
+            if price.ends:
+                ends = app.localtime(price.ends, from_utc=True)
+                ends = app.render_date(ends.date())
+            else:
+                ends = "??"
+
+            return HTML.tag('span', c=text,
+                            title="{} thru {}".format(starts, ends))
+
+        return text
+
+    def add_price_history_link(self, text, typ):
+        if not self.rattail_config.versioning_enabled():
+            return text
+        if not self.has_perm('versions'):
+            return text
+
+        kwargs = {'@click.prevent': 'showPriceHistory_{}()'.format(typ)}
+        history = tags.link_to("(view history)", '#', **kwargs)
+        if not text:
+            return history
+
+        text = HTML.tag('p', c=[text])
+        history = HTML.tag('p', c=[history])
+        div = HTML.tag('div', c=[text, history])
+        # nb. for some reason we must wrap once more for oruga,
+        # otherwise it splits up the field?!
+        return HTML.tag('div', c=[div])
+
+    def show_price_effective_dates(self):
+        if not self.rattail_config.versioning_enabled():
+            return False
+        return self.rattail_config.getbool(
+            'tailbone', 'products.show_effective_price_dates',
+            default=True)
+
+    def render_regular_price(self, product, field):
+        app = self.get_rattail_app()
+        text = self.render_price(product, field)
+
+        if text and self.show_price_effective_dates():
+            history = self.get_regular_price_history(product)
+            if history:
+                date = app.localtime(history[0]['changed'], from_utc=True).date()
+                text = "{} (as of {})".format(text, date)
+
+        return self.add_price_history_link(text, 'regular')
+
+    def render_current_price(self, product, field):
+        app = self.get_rattail_app()
+        text = self.render_price(product, field)
+
+        if text and self.show_price_effective_dates():
+            history = self.get_current_price_history(product)
+            if history:
+                date = app.localtime(history[0]['changed'], from_utc=True).date()
+                text = "{} (as of {})".format(text, date)
+
+        return self.add_price_history_link(text, 'current')
+
+    def warn_if_regprice_more_than_srp(self, product, text):
+        sugprice = product.suggested_price.price if product.suggested_price else None
+        regprice = product.regular_price.price if product.regular_price else None
+        if sugprice and regprice and sugprice < regprice:
+            return HTML.tag('span', style='color: red;', c=text)
+        return text
+
+    def render_suggested_price(self, product, column):
+        text = self.render_price(product, column)
+        if not text:
+            return
+
+        app = self.get_rattail_app()
+        if self.show_price_effective_dates():
+            history = self.get_suggested_price_history(product)
+            if history:
+                date = app.localtime(history[0]['changed'], from_utc=True).date()
+                text = "{} (as of {})".format(text, date)
+
+        text = self.warn_if_regprice_more_than_srp(product, text)
+        return self.add_price_history_link(text, 'suggested')
+
+    def render_grid_suggested_price(self, product, field):
+        text = self.render_price(product, field)
+        if not text:
+            return ""
+
+        text = self.warn_if_regprice_more_than_srp(product, text)
+        return text
+
+    def render_true_cost(self, product, field):
+        if not product.volatile:
+            return ""
+        if product.volatile.true_cost is None:
+            return ""
+        return "${:0.3f}".format(product.volatile.true_cost)
+
+    def render_true_margin(self, product, field):
+        if not product.volatile:
+            return ""
+        if product.volatile.true_margin is None:
+            return ""
+        app = self.get_rattail_app()
+        return app.render_percent(product.volatile.true_margin,
+                                  places=3)
+
+    def render_on_hand(self, product, column):
+        inventory = product.inventory
+        if not inventory:
+            return ""
+        app = self.get_rattail_app()
+        return app.render_quantity(inventory.on_hand)
+
+    def render_on_order(self, product, column):
+        inventory = product.inventory
+        if not inventory:
+            return ""
+        app = self.get_rattail_app()
+        return app.render_quantity(inventory.on_order)
+
+    def template_kwargs_index(self, **kwargs):
+        kwargs = super().template_kwargs_index(**kwargs)
+        app = self.get_rattail_app()
+        label_handler = app.get_label_handler()
+        model = self.model
+
+        if self.expose_label_printing:
+            kwargs['label_profiles'] = label_handler.get_label_profiles(self.Session())
+            kwargs['quick_label_speedbump_threshold'] = self.rattail_config.getint(
+                'tailbone', 'products.quick_labels.speedbump_threshold')
+
+        return kwargs
+
+    def grid_extra_class(self, product, i):
+        classes = []
+        if product.not_for_sale:
+            classes.append('not-for-sale')
+        if product.discontinued:
+            classes.append('discontinued')
+        if product.deleted:
+            classes.append('deleted')
+        if classes:
+            return ' '.join(classes)
+
+    def get_xlsx_fields(self):
+        fields = super().get_xlsx_fields()
+
+        i = fields.index('department_uuid')
+        fields.insert(i + 1, 'department_number')
+        fields.insert(i + 2, 'department_name')
+
+        i = fields.index('subdepartment_uuid')
+        fields.insert(i + 1, 'subdepartment_number')
+        fields.insert(i + 2, 'subdepartment_name')
+
+        i = fields.index('category_uuid')
+        fields.insert(i + 1, 'category_code')
+
+        i = fields.index('family_uuid')
+        fields.insert(i + 1, 'family_code')
+
+        i = fields.index('report_code_uuid')
+        fields.insert(i + 1, 'report_code')
+
+        i = fields.index('deposit_link_uuid')
+        fields.insert(i + 1, 'deposit_link_code')
+
+        i = fields.index('tax_uuid')
+        fields.insert(i + 1, 'tax_code')
+
+        i = fields.index('brand_uuid')
+        fields.insert(i + 1, 'brand_name')
+
+        i = fields.index('suggested_price_uuid')
+        fields.insert(i + 1, 'suggested_price')
+
+        i = fields.index('regular_price_uuid')
+        fields.insert(i + 1, 'regular_price')
+
+        i = fields.index('current_price_uuid')
+        fields.insert(i + 1, 'current_price')
+
+        fields.append('vendor_uuid')
+        fields.append('vendor_id')
+        fields.append('vendor_name')
+        fields.append('vendor_item_code')
+        fields.append('unit_cost')
+        fields.append('true_margin')
+
+        return fields
+
+    def get_xlsx_row(self, product, fields):
+        row = super().get_xlsx_row(product, fields)
+
+        if 'upc' in fields and isinstance(row['upc'], GPC):
+            row['upc'] = row['upc'].pretty()
+
+        if 'department_number' in fields:
+            row['department_number'] = product.department.number if product.department else None
+        if 'department_name' in fields:
+            row['department_name'] = product.department.name if product.department else None
+
+        if 'subdepartment_number' in fields:
+            row['subdepartment_number'] = product.subdepartment.number if product.subdepartment else None
+        if 'subdepartment_name' in fields:
+            row['subdepartment_name'] = product.subdepartment.name if product.subdepartment else None
+
+        if 'category_code' in fields:
+            row['category_code'] = product.category.code if product.category else None
+
+        if 'family_code' in fields:
+            row['family_code'] = product.family.code if product.family else None
+
+        if 'report_code' in fields:
+            row['report_code'] = product.report_code.code if product.report_code else None
+
+        if 'deposit_link_code' in fields:
+            row['deposit_link_code'] = product.deposit_link.code if product.deposit_link else None
+
+        if 'tax_code' in fields:
+            row['tax_code'] = product.tax.code if product.tax else None
+
+        if 'brand_name' in fields:
+            row['brand_name'] = product.brand.name if product.brand else None
+
+        if 'suggested_price' in fields:
+            row['suggested_price'] = product.suggested_price.price if product.suggested_price else None
+
+        if 'regular_price' in fields:
+            row['regular_price'] = product.regular_price.price if product.regular_price else None
+
+        if 'current_price' in fields:
+            row['current_price'] = product.current_price.price if product.current_price else None
+
+        if 'vendor_uuid' in fields:
+            row['vendor_uuid'] = product.cost.vendor.uuid if product.cost else None
+
+        if 'vendor_id' in fields:
+            row['vendor_id'] = product.cost.vendor.id if product.cost else None
+
+        if 'vendor_name' in fields:
+            row['vendor_name'] = product.cost.vendor.name if product.cost else None
+
+        if 'vendor_item_code' in fields:
+            row['vendor_item_code'] = product.cost.code if product.cost else None
+
+        if 'unit_cost' in fields:
+            row['unit_cost'] = product.cost.unit_cost if product.cost else None
+
+        if 'true_margin' in fields:
+            row['true_margin'] = None
+            if product.volatile and product.volatile.true_margin:
+                row['true_margin'] = product.volatile.true_margin
+
+        return row
+
+    def download_results_normalize(self, product, fields, **kwargs):
+        data = super().download_results_normalize(
+            product, fields, **kwargs)
+
+        if 'upc' in data:
+            if isinstance(data['upc'], GPC):
+                data['upc'] = str(data['upc'])
+
+        return data
+
+    def get_instance(self):
+        model = self.model
+        key = self.request.matchdict['uuid']
+        product = self.Session.get(model.Product, key)
+        if product:
+            return product
+        price = self.Session.get(model.ProductPrice, key)
+        if price:
+            return price.product
+        raise self.notfound()
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        model = self.model
         product = f.model_instance
 
-        # upc
-        f.set_type('upc', 'gpc')
-
         # unit_size
         f.set_type('unit_size', 'quantity')
 
         # unit_of_measure
         f.set_enum('unit_of_measure', self.enum.UNIT_OF_MEASURE)
+        f.set_renderer('unit_of_measure', self.render_unit_of_measure)
         f.set_label('unit_of_measure', "Unit of Measure")
 
         # packs
@@ -413,6 +770,34 @@ class ProductsView(MasterView):
             f.set_readonly('current_price_ends')
             f.set_renderer('current_price_ends', self.render_current_price_ends)
 
+        # sale_price
+        if self.creating:
+            f.remove_field('sale_price')
+        else:
+            f.set_readonly('sale_price')
+            f.set_renderer('sale_price', self.render_price)
+
+        # sale_price_ends
+        if self.creating:
+            f.remove_field('sale_price_ends')
+        else:
+            f.set_readonly('sale_price_ends')
+            f.set_renderer('sale_price_ends', self.render_sale_price_ends)
+
+        # tpr_price
+        if self.creating:
+            f.remove_field('tpr_price')
+        else:
+            f.set_readonly('tpr_price')
+            f.set_renderer('tpr_price', self.render_price)
+
+        # tpr_price_ends
+        if self.creating:
+            f.remove_field('tpr_price_ends')
+        else:
+            f.set_readonly('tpr_price_ends')
+            f.set_renderer('tpr_price_ends', self.render_tpr_price_ends)
+
         # vendor
         if self.creating:
             f.remove_field('vendor')
@@ -450,277 +835,11 @@ class ProductsView(MasterView):
             f.set_renderer('inventory_on_order', self.render_inventory_on_order)
             f.set_label('inventory_on_order', "On Order")
 
-    def render_cost(self, product, field):
-        cost = getattr(product, field)
-        if not cost:
-            return ""
-        if cost.unit_cost is None:
-            return ""
-        return "${:0.2f}".format(cost.unit_cost)
-
-    def render_price(self, product, column):
-        price = product[column]
-        if price:
-            if not product.not_for_sale:
-                if price.price is not None and price.pack_price is not None:
-                    if price.multiple > 1:
-                        return HTML.literal("$ {:0.2f} / {}&nbsp; ($ {:0.2f} / {})".format(
-                            price.price, price.multiple,
-                            price.pack_price, price.pack_multiple))
-                    return HTML.literal("$ {:0.2f}&nbsp; ($ {:0.2f} / {})".format(
-                        price.price, price.pack_price, price.pack_multiple))
-                if price.price is not None:
-                    if price.multiple is not None and price.multiple > 1:
-                        return "$ {:0.2f} / {}".format(price.price, price.multiple)
-                    return "$ {:0.2f}".format(price.price)
-                if price.pack_price is not None:
-                    return "$ {:0.2f} / {}".format(price.pack_price, price.pack_multiple)
-        return ""
-        
-    def add_price_history_link(self, text, typ):
-        if not self.rattail_config.versioning_enabled():
-            return text
-        if not self.has_perm('versions'):
-            return text
-
-        if self.get_use_buefy():
-            kwargs = {'@click.prevent': 'showPriceHistory_{}()'.format(typ)}
-        else:
-            kwargs = {'id': 'view-{}-price-history'.format(typ)}
-        history = tags.link_to("(view history)", '#', **kwargs)
-        if not text:
-            return history
-
-        text = HTML.tag('span', c=[text])
-        br = HTML.tag('br')
-        return HTML.tag('div', c=[text, br, history])
-
-    def show_price_effective_dates(self):
-        if not self.rattail_config.versioning_enabled():
-            return False
-        return self.rattail_config.getbool(
-            'tailbone', 'products.show_effective_price_dates',
-            default=True)
-
-    def render_regular_price(self, product, field):
-        text = self.render_price(product, field)
-
-        if text and self.show_price_effective_dates():
-            history = self.get_regular_price_history(product)
-            if history:
-                date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date()
-                text = "{} (as of {})".format(text, date)
-
-        return self.add_price_history_link(text, 'regular')
-
-    def render_current_price(self, product, field):
-        text = self.render_price(product, field)
-
-        if text and self.show_price_effective_dates():
-            history = self.get_current_price_history(product)
-            if history:
-                date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date()
-                text = "{} (as of {})".format(text, date)
-
-        return self.add_price_history_link(text, 'current')
-
-    def warn_if_regprice_more_than_srp(self, product, text):
-        sugprice = product.suggested_price.price if product.suggested_price else None
-        regprice = product.regular_price.price if product.regular_price else None
-        if sugprice and regprice and sugprice < regprice:
-            return HTML.tag('span', style='color: red;', c=text)
-        return text
-
-    def render_suggested_price(self, product, column):
-        text = self.render_price(product, column)
-
-        if text and self.show_price_effective_dates():
-            history = self.get_suggested_price_history(product)
-            if history:
-                date = localtime(self.rattail_config, history[0]['changed'], from_utc=True).date()
-                text = "{} (as of {})".format(text, date)
-
-        text = self.warn_if_regprice_more_than_srp(product, text)
-        return self.add_price_history_link(text, 'suggested')
-
-    def render_grid_suggested_price(self, product, field):
-        text = self.render_price(product, field)
-        if not text:
-            return ""
-
-        text = self.warn_if_regprice_more_than_srp(product, text)
-        return text
-
-    def render_true_cost(self, product, field):
-        if not product.volatile:
-            return ""
-        if product.volatile.true_cost is None:
-            return ""
-        return "${:0.3f}".format(product.volatile.true_cost)
-
-    def render_true_margin(self, product, field):
-        if not product.volatile:
-            return ""
-        if product.volatile.true_margin is None:
-            return ""
-        return "{:0.3f} %".format(product.volatile.true_margin * 100)
-
-    def render_on_hand(self, product, column):
-        inventory = product.inventory
-        if not inventory:
-            return ""
-        return pretty_quantity(inventory.on_hand)
-
-    def render_on_order(self, product, column):
-        inventory = product.inventory
-        if not inventory:
-            return ""
-        return pretty_quantity(inventory.on_order)
-
-    def template_kwargs_index(self, **kwargs):
-        if self.print_labels:
-            kwargs['label_profiles'] = Session.query(model.LabelProfile)\
-                                              .filter(model.LabelProfile.visible == True)\
-                                              .order_by(model.LabelProfile.ordinal)\
-                                              .all()
-        return kwargs
-
-
-    def grid_extra_class(self, product, i):
-        classes = []
-        if product.not_for_sale:
-            classes.append('not-for-sale')
-        if product.deleted:
-            classes.append('deleted')
-        if classes:
-            return ' '.join(classes)
-
-    def get_xlsx_fields(self):
-        fields = super(ProductsView, self).get_xlsx_fields()
-
-        i = fields.index('department_uuid')
-        fields.insert(i + 1, 'department_number')
-        fields.insert(i + 2, 'department_name')
-
-        i = fields.index('subdepartment_uuid')
-        fields.insert(i + 1, 'subdepartment_number')
-        fields.insert(i + 2, 'subdepartment_name')
-
-        i = fields.index('category_uuid')
-        fields.insert(i + 1, 'category_code')
-
-        i = fields.index('family_uuid')
-        fields.insert(i + 1, 'family_code')
-
-        i = fields.index('report_code_uuid')
-        fields.insert(i + 1, 'report_code')
-
-        i = fields.index('deposit_link_uuid')
-        fields.insert(i + 1, 'deposit_link_code')
-
-        i = fields.index('tax_uuid')
-        fields.insert(i + 1, 'tax_code')
-
-        i = fields.index('brand_uuid')
-        fields.insert(i + 1, 'brand_name')
-
-        i = fields.index('suggested_price_uuid')
-        fields.insert(i + 1, 'suggested_price')
-
-        i = fields.index('regular_price_uuid')
-        fields.insert(i + 1, 'regular_price')
-
-        i = fields.index('current_price_uuid')
-        fields.insert(i + 1, 'current_price')
-
-        fields.append('vendor_uuid')
-        fields.append('vendor_id')
-        fields.append('vendor_name')
-        fields.append('vendor_item_code')
-        fields.append('unit_cost')
-
-        return fields
-
-    def get_xlsx_row(self, product, fields):
-        row = super(ProductsView, self).get_xlsx_row(product, fields)
-
-        if 'upc' in fields and isinstance(row['upc'], GPC):
-            row['upc'] = row['upc'].pretty()
-
-        if 'department_number' in fields:
-            row['department_number'] = product.department.number if product.department else None
-        if 'department_name' in fields:
-            row['department_name'] = product.department.name if product.department else None
-
-        if 'subdepartment_number' in fields:
-            row['subdepartment_number'] = product.subdepartment.number if product.subdepartment else None
-        if 'subdepartment_name' in fields:
-            row['subdepartment_name'] = product.subdepartment.name if product.subdepartment else None
-
-        if 'category_code' in fields:
-            row['category_code'] = product.category.code if product.category else None
-
-        if 'family_code' in fields:
-            row['family_code'] = product.family.code if product.family else None
-
-        if 'report_code' in fields:
-            row['report_code'] = product.report_code.code if product.report_code else None
-
-        if 'deposit_link_code' in fields:
-            row['deposit_link_code'] = product.deposit_link.code if product.deposit_link else None
-
-        if 'tax_code' in fields:
-            row['tax_code'] = product.tax.code if product.tax else None
-
-        if 'brand_name' in fields:
-            row['brand_name'] = product.brand.name if product.brand else None
-
-        if 'suggested_price' in fields:
-            row['suggested_price'] = product.suggested_price.price if product.suggested_price else None
-
-        if 'regular_price' in fields:
-            row['regular_price'] = product.regular_price.price if product.regular_price else None
-
-        if 'current_price' in fields:
-            row['current_price'] = product.current_price.price if product.current_price else None
-
-        if 'vendor_uuid' in fields:
-            row['vendor_uuid'] = product.cost.vendor.uuid if product.cost else None
-
-        if 'vendor_id' in fields:
-            row['vendor_id'] = product.cost.vendor.id if product.cost else None
-
-        if 'vendor_name' in fields:
-            row['vendor_name'] = product.cost.vendor.name if product.cost else None
-
-        if 'vendor_item_code' in fields:
-            row['vendor_item_code'] = product.cost.code if product.cost else None
-
-        if 'unit_cost' in fields:
-            row['unit_cost'] = product.cost.unit_cost if product.cost else None
-
-        return row
-
-    def get_instance(self):
-        key = self.request.matchdict['uuid']
-        product = Session.query(model.Product).get(key)
-        if product:
-            return product
-        price = Session.query(model.ProductPrice).get(key)
-        if price:
-            return price.product
-        raise httpexceptions.HTTPNotFound()
-
-    def configure_form(self, f):
-        super(ProductsView, self).configure_form(f)
-        product = f.model_instance
-
         # department
         if self.creating or self.editing:
             if 'department' in f.fields:
                 f.replace('department', 'department_uuid')
-                departments = self.Session.query(model.Department)\
-                                          .order_by(model.Department.number)
+                departments = self.get_departments()
                 dept_values = [(d.uuid, "{} {}".format(d.number, d.name))
                                for d in departments]
                 require_department = False
@@ -780,7 +899,7 @@ class ProductsView(MasterView):
                 f.set_label('family_uuid', "Family")
         else:
             f.set_readonly('family')
-            # f.set_renderer('family', self.render_family)
+            f.set_renderer('family', self.render_family)
 
         # report_code
         if self.creating or self.editing:
@@ -837,7 +956,7 @@ class ProductsView(MasterView):
                 f.set_label('tax_uuid', "Tax")
         else:
             f.set_readonly('tax')
-            # f.set_renderer('tax', self.render_tax)
+            f.set_renderer('tax', self.render_tax)
 
         # tax1/2/3
         f.set_readonly('tax1')
@@ -852,11 +971,11 @@ class ProductsView(MasterView):
                 brand_display = ""
                 if self.request.method == 'POST':
                     if self.request.POST.get('brand_uuid'):
-                        brand = self.Session.query(model.Brand).get(self.request.POST['brand_uuid'])
+                        brand = self.Session.get(model.Brand, self.request.POST['brand_uuid'])
                         if brand:
-                            brand_display = six.text_type(brand)
+                            brand_display = str(brand)
                 elif self.editing:
-                    brand_display = six.text_type(product.brand or '')
+                    brand_display = str(product.brand or '')
                 brands_url = self.request.route_url('brands.autocomplete')
                 f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget(
                     field_display=brand_display, service_url=brands_url))
@@ -864,6 +983,9 @@ class ProductsView(MasterView):
         else:
             f.set_readonly('brand')
 
+        # case_size
+        f.set_type('case_size', 'quantity')
+
         # status_code
         f.set_label('status_code', "Status")
 
@@ -879,7 +1001,7 @@ class ProductsView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        product = super(ProductsView, self).objectify(form, data=data)
+        product = super().objectify(form, data=data)
 
         # regular_price_amount
         if (self.creating or self.editing) and 'regular_price_amount' in form.fields:
@@ -887,6 +1009,14 @@ class ProductsView(MasterView):
 
         return product
 
+    def render_unit_of_measure(self, product, field):
+        uom = getattr(product, field)
+        if uom is None:
+            return
+        if uom == self.enum.UNIT_OF_MEASURE_NONE:
+            return
+        return self.enum.UNIT_OF_MEASURE.get(uom, uom)
+
     def render_department(self, product, field):
         department = product.department
         if not department:
@@ -935,7 +1065,7 @@ class ProductsView(MasterView):
             else:
                 code = pack.item_id
             text = "({}) {}".format(code, pack.full_description)
-            url = self.get_action_url('view', pack, mobile=self.mobile)
+            url = self.get_action_url('view', pack)
             links.append(tags.link_to(text, url))
 
         items = [HTML.tag('li', c=[link]) for link in links]
@@ -954,7 +1084,7 @@ class ProductsView(MasterView):
             code = unit.item_id
 
         text = "({}) {}".format(code, unit.full_description)
-        url = self.get_action_url('view', unit, mobile=self.mobile)
+        url = self.get_action_url('view', unit)
         return tags.link_to(text, url)
 
     def render_current_price_ends(self, product, field):
@@ -965,13 +1095,30 @@ class ProductsView(MasterView):
             return ""
         return raw_datetime(self.request.rattail_config, value)
 
+    def render_sale_price_ends(self, product, field):
+        if not product.sale_price:
+            return
+        ends = product.sale_price.ends
+        if not ends:
+            return
+        return raw_datetime(self.rattail_config, ends)
+
+    def render_tpr_price_ends(self, product, field):
+        if not product.tpr_price:
+            return
+        ends = product.tpr_price.ends
+        if not ends:
+            return
+        return raw_datetime(self.rattail_config, ends)
+
     def render_inventory_on_hand(self, product, field):
         if not product.inventory:
             return ""
         value = product.inventory.on_hand
         if not value:
             return ""
-        return pretty_quantity(value)
+        app = self.get_rattail_app()
+        return app.render_quantity(value)
 
     def render_inventory_on_order(self, product, field):
         if not product.inventory:
@@ -979,12 +1126,14 @@ class ProductsView(MasterView):
         value = product.inventory.on_order
         if not value:
             return ""
-        return pretty_quantity(value)
+        app = self.get_rattail_app()
+        return app.render_quantity(value)
 
     def price_history(self):
         """
         AJAX view for fetching various types of price history for a product.
         """
+        app = self.get_rattail_app()
         product = self.get_instance()
 
         typ = self.request.params.get('type', 'regular')
@@ -998,14 +1147,15 @@ class ProductsView(MasterView):
         for history in data:
             history = dict(history)
             price = history['price']
-            history['price'] = float(price)
-            history['price_display'] = "${:0.2f}".format(price)
-            changed = localtime(self.rattail_config, history['changed'], from_utc=True)
-            history['changed'] = six.text_type(changed)
+            if price is not None:
+                history['price'] = float(price)
+                history['price_display'] = app.render_currency(price)
+            changed = app.localtime(history['changed'], from_utc=True)
+            history['changed'] = str(changed)
             history['changed_display_html'] = raw_datetime(self.rattail_config, changed)
             user = history.pop('changed_by')
             history['changed_by_uuid'] = user.uuid if user else None
-            history['changed_by_display'] = six.text_type(user or "??")
+            history['changed_by_display'] = str(user or "??")
             jsdata.append(history)
         return jsdata
 
@@ -1013,6 +1163,7 @@ class ProductsView(MasterView):
         """
         AJAX view for fetching cost history for a product.
         """
+        app = self.get_rattail_app()
         product = self.get_instance()
         data = self.get_cost_history(product)
 
@@ -1026,47 +1177,29 @@ class ProductsView(MasterView):
                 history['cost_display'] = "${:0.2f}".format(cost)
             else:
                 history['cost_display'] = None
-            changed = localtime(self.rattail_config, history['changed'], from_utc=True)
-            history['changed'] = six.text_type(changed)
+            changed = app.localtime(history['changed'], from_utc=True)
+            history['changed'] = str(changed)
             history['changed_display_html'] = raw_datetime(self.rattail_config, changed)
             user = history.pop('changed_by')
             history['changed_by_uuid'] = user.uuid
-            history['changed_by_display'] = six.text_type(user)
+            history['changed_by_display'] = str(user)
             jsdata.append(history)
         return jsdata
 
     def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
         product = kwargs['instance']
-        use_buefy = self.get_use_buefy()
 
-        # TODO: pretty sure this is no longer needed?  guess we'll find out
-        # kwargs['image'] = False
-
-        # maybe provide image URL for product; we prefer image from our DB if
-        # present, but otherwise a "POD" image URL can be attempted.
-        if product.image:
-            kwargs['image_url'] = self.request.route_url('products.image', uuid=product.uuid)
-
-        elif product.upc:
-            if self.rattail_config.getbool('tailbone', 'products.show_pod_image', default=False):
-                # here we try to give a URL to a so-called "POD" image for the product
-                kwargs['image_url'] = pod.get_image_url(self.rattail_config, product.upc)
-                kwargs['image_path'] = pod.get_image_path(self.rattail_config, product.upc)
-
-        # maybe use "image not found" placeholder image
-        if not kwargs.get('image_url'):
-            kwargs['image_url'] = self.request.static_url('tailbone:static/img/product.png')
+        kwargs['image_url'] = self.products_handler.get_image_url(product)
 
         # add price history, if user has access
         if self.rattail_config.versioning_enabled() and self.has_perm('versions'):
 
             # regular price
-            if use_buefy:
-                data = []       # defer fetching until user asks for it
-            else:
-                data = self.get_regular_price_history(product)
-            grid = grids.Grid('products.regular_price_history', data,
-                              request=self.request,
+            data = []       # defer fetching until user asks for it
+            grid = grids.Grid(self.request,
+                              key='products.regular_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'since',
@@ -1078,12 +1211,10 @@ class ProductsView(MasterView):
             kwargs['regular_price_history_grid'] = grid
 
             # current price
-            if use_buefy:
-                data = []       # defer fetching until user asks for it
-            else:
-                data = self.get_current_price_history(product)
-            grid = grids.Grid('products.current_price_history', data,
-                              request=self.request,
+            data = []       # defer fetching until user asks for it
+            grid = grids.Grid(self.request,
+                              key='products.current_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'price_type',
@@ -1099,12 +1230,10 @@ class ProductsView(MasterView):
             kwargs['current_price_history_grid'] = grid
 
             # suggested price
-            if use_buefy:
-                data = []       # defer fetching until user asks for it
-            else:
-                data = self.get_suggested_price_history(product)
-            grid = grids.Grid('products.suggested_price_history', data,
-                              request=self.request,
+            data = []       # defer fetching until user asks for it
+            grid = grids.Grid(self.request,
+                              key='products.suggested_price_history',
+                              data=data,
                               columns=[
                                   'price',
                                   'since',
@@ -1116,12 +1245,10 @@ class ProductsView(MasterView):
             kwargs['suggested_price_history_grid'] = grid
 
             # cost history
-            if use_buefy:
-                data = []       # defer fetching until user asks for it
-            else:
-                data = self.get_cost_history(product)
-            grid = grids.Grid('products.cost_history', data,
-                              request=self.request,
+            data = []       # defer fetching until user asks for it
+            grid = grids.Grid(self.request,
+                              key='products.cost_history',
+                              data=data,
                               columns=[
                                   'cost',
                                   'vendor',
@@ -1140,17 +1267,154 @@ class ProductsView(MasterView):
         kwargs['costs_label_vendor'] = "Vendor"
         kwargs['costs_label_code'] = "Order Code"
         kwargs['costs_label_case_size'] = "Case Size"
+
+        kwargs['vendor_sources'] = self.get_context_vendor_sources(product)
+        kwargs['lookup_codes'] = self.get_context_lookup_codes(product)
+
+        kwargs['panel_fields'] = self.get_panel_fields(product)
+
         return kwargs
 
+    def get_panel_fields(self, product):
+        return {
+            'main': self.get_panel_fields_main(product),
+            'flag': self.get_panel_fields_flag(product),
+        }
+
+    def get_panel_fields_main(self, product):
+        product_key_field = self.get_product_key_field()
+        fields = [
+            product_key_field,
+            'brand',
+            'description',
+            'size',
+            'unit_size',
+            'unit_of_measure',
+            'average_weight',
+            'case_size',
+        ]
+        if product.is_pack_item():
+            fields.extend([
+                'pack_size',
+                'unit',
+                'default_pack',
+            ])
+        elif product.packs:
+            fields.append('packs')
+
+        for supp in self.iter_view_supplements():
+            if hasattr(supp, 'get_panel_fields_main'):
+                fields.extend(supp.get_panel_fields_main(product))
+
+        return fields
+
+    def get_panel_fields_flag(self, product):
+        return [
+            'weighed',
+            'discountable',
+            'special_order',
+            'organic',
+            'not_for_sale',
+            'discontinued',
+            'deleted',
+        ]
+
+    def get_context_vendor_sources(self, product):
+        app = self.get_rattail_app()
+        route_prefix = self.get_route_prefix()
+        units_only = self.products_handler.units_only()
+
+        columns = [
+            'preferred',
+            'vendor',
+            'vendor_item_code',
+            'case_size',
+            'case_cost',
+            'unit_cost',
+            'status',
+        ]
+        if units_only:
+            columns.remove('case_size')
+            columns.remove('case_cost')
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.vendor_sources',
+            data=[],
+            columns=columns,
+            labels={
+                'preferred': "Pref.",
+                'vendor_item_code': "Order Code",
+            },
+        )
+
+        sources = []
+        link_vendor = self.request.has_perm('vendors.view')
+        for cost in product.costs:
+
+            source = {
+                'uuid': cost.uuid,
+                'preferred': "X" if cost.preference == 1 else None,
+                'vendor_item_code': cost.code,
+                'unit_cost': app.render_currency(cost.unit_cost, scale=4),
+                'status': "discontinued" if cost.discontinued else "available",
+            }
+
+            if not units_only:
+                source['case_size'] = app.render_quantity(cost.case_size)
+                source['case_cost'] = app.render_currency(cost.case_cost)
+
+            text = str(cost.vendor)
+            if link_vendor:
+                url = self.request.route_url('vendors.view', uuid=cost.vendor.uuid)
+                source['vendor'] = tags.link_to(text, url)
+            else:
+                source['vendor'] = text
+
+            sources.append(source)
+
+        return {'grid': g, 'data': sources}
+
+    def get_context_lookup_codes(self, product):
+        route_prefix = self.get_route_prefix()
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.lookup_codes',
+            data=[],
+            columns=[
+                'sequence',
+                'code',
+            ],
+            labels={
+                'sequence': "Seq.",
+            },
+        )
+
+        lookup_codes = []
+        for code in product._codes:
+
+            lookup_codes.append({
+                'uuid': code.uuid,
+                'sequence': code.ordinal,
+                'code': code.code,
+            })
+
+        return {'grid': g, 'data': lookup_codes}
+
     def get_regular_price_history(self, product):
         """
         Returns a sequence of "records" which corresponds to the given
         product's regular price history.
         """
+        app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # first we find all relevant ProductVersion records
@@ -1216,10 +1480,12 @@ class ProductsView(MasterView):
         Returns a sequence of "records" which corresponds to the given
         product's current price history.
         """
+        app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # first we find all relevant ProductVersion records
@@ -1358,10 +1624,12 @@ class ProductsView(MasterView):
         Returns a sequence of "records" which corresponds to the given
         product's SRP history.
         """
+        app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductPriceVersion = continuum.version_class(model.ProductPrice)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # first we find all relevant ProductVersion records
@@ -1427,10 +1695,12 @@ class ProductsView(MasterView):
         Returns a sequence of "records" which corresponds to the given
         product's cost history.
         """
+        app = self.get_rattail_app()
+        model = self.model
         Transaction = continuum.transaction_class(model.Product)
         ProductVersion = continuum.version_class(model.Product)
         ProductCostVersion = continuum.version_class(model.ProductCost)
-        now = make_utc()
+        now = app.make_utc()
         history = []
 
         # we just find all relevant (preferred!) ProductCostVersion records
@@ -1493,38 +1763,8 @@ class ProductsView(MasterView):
                                                 'instance_title': self.get_instance_title(instance),
                                                 'form': form})
 
-    def mobile_index(self):
-        """
-        Mobile "home" page for products
-        """
-        self.mobile = True
-        context = {
-            'quick_lookup': False,
-            'placeholder': "Enter {}".format(self.rattail_config.product_key_title()),
-            'quick_lookup_keyboard_wedge': True,
-        }
-        if self.rattail_config.getbool('rattail', 'products.mobile.quick_lookup', default=False):
-            context['quick_lookup'] = True
-        else:
-            self.listing = True
-            grid = self.make_mobile_grid()
-            context['grid'] = grid
-        return self.render_to_response('index', context, mobile=True)
-
-    def mobile_quick_lookup(self):
-        entry = self.request.POST['quick_entry'].strip()
-        provided = GPC(entry, calc_check_digit=False)
-        product = api.get_product_by_upc(self.Session(), provided)
-        if not product:
-            checked = GPC(entry, calc_check_digit='upc')
-            product = api.get_product_by_upc(self.Session(), checked)
-        if not product:
-            product = api.get_product_by_code(self.Session(), entry)
-        if not product:
-            raise self.notfound()
-        return self.redirect(self.get_action_url('view', product, mobile=True))
-
     def get_version_child_classes(self):
+        model = self.model
         return [
             (model.ProductCode, 'product_uuid'),
             (model.ProductCost, 'product_uuid'),
@@ -1537,40 +1777,176 @@ class ProductsView(MasterView):
         """
         product = self.get_instance()
         if not product.image:
-            raise httpexceptions.HTTPNotFound()
+            raise self.notfound()
         # TODO: how to properly detect image type?
-        # self.request.response.content_type = six.binary_type('image/png')
-        self.request.response.content_type = six.binary_type('image/jpeg')
+        # content_type = 'image/png'
+        content_type = 'image/jpeg'
+        self.request.response.content_type = content_type
         self.request.response.body = product.image.bytes
         return self.request.response
 
+    def print_labels(self):
+        app = self.get_rattail_app()
+        label_handler = app.get_label_handler()
+        model = self.model
+
+        profile = self.request.params.get('profile')
+        profile = self.Session.get(model.LabelProfile, profile) if profile else None
+        if not profile:
+            return {'error': "Label profile not found"}
+
+        product = self.request.params.get('product')
+        product = self.Session.get(model.Product, product) if product else None
+        if not product:
+            return {'error': "Product not found"}
+
+        quantity = self.request.params.get('quantity')
+        if not quantity.isdigit():
+            return {'error': "Quantity must be numeric"}
+        quantity = int(quantity)
+
+        printer = label_handler.get_printer(profile)
+        if not printer:
+            return {'error': "Couldn't get printer from label profile"}
+
+        try:
+            printer.print_labels([({'product': product}, quantity)])
+        except Exception as error:
+            log.warning("error occurred while printing labels", exc_info=True)
+            return {'error': str(error)}
+        return {'ok': True}
+
     def search(self):
+        """
+        Perform a product search across multiple fields, and return
+        the results as JSON suitable for row data for a table
+        component.
+        """
+        if 'term' not in self.request.GET:
+            # TODO: deprecate / remove this?  not sure if/where it is used
+            return self.search_v1()
+
+        term = self.request.GET.get('term')
+        if not term:
+            return {'ok': True, 'results': []}
+
+        supported_fields = [
+            'product_key',
+            'vendor_code',
+            'alt_code',
+            'brand_name',
+            'description',
+        ]
+
+        search_fields = []
+        for field in supported_fields:
+            key = 'search_{}'.format(field)
+            if self.request.GET.get(key) == 'true':
+                search_fields.append(field)
+
+        final_results = []
+        session = self.Session()
+        model = self.model
+
+        lookup_fields = []
+        if 'product_key' in search_fields:
+            lookup_fields.append('_product_key_')
+        if 'vendor_code' in search_fields:
+            lookup_fields.append('vendor_code')
+        if 'alt_code' in search_fields:
+            lookup_fields.append('alt_code')
+        if lookup_fields:
+            product = self.products_handler.locate_product_for_entry(
+                session, term, lookup_fields=lookup_fields,
+                first_if_multiple=True)
+            if product:
+                final_results.append(self.search_normalize_result(product))
+
+        # base wildcard query
+        query = session.query(model.Product)
+        if 'brand_name' in search_fields:
+            query = query.outerjoin(model.Brand)
+
+        # now figure out wildcard criteria
+        criteria = []
+        for word in term.split():
+            if 'brand_name' in search_fields and 'description' in search_fields:
+                criteria.append(sa.or_(
+                    model.Brand.name.ilike('%{}%'.format(word)),
+                    model.Product.description.ilike('%{}%'.format(word))))
+            elif 'brand_name' in search_fields:
+                criteria.append(model.Brand.name.ilike('%{}%'.format(word)))
+            elif 'description' in search_fields:
+                criteria.append(model.Product.description.ilike('%{}%'.format(word)))
+
+        # execute wildcard query if applicable
+        max_results = 30        # TODO: make conifgurable?
+        elided = 0
+        if criteria:
+            query = query.filter(sa.and_(*criteria))
+            count = query.count()
+            if count > max_results:
+                elided = count - max_results
+            for product in query[:max_results]:
+                final_results.append(self.search_normalize_result(product))
+
+        return {'ok': True, 'results': final_results, 'elided': elided}
+
+    def search_normalize_result(self, product, **kwargs):
+        return self.products_handler.normalize_product(product, fields=[
+            'product_key',
+            'url',
+            'image_url',
+            'brand_name',
+            'description',
+            'size',
+            'full_description',
+            'department_name',
+            'unit_price',
+            'unit_price_display',
+            'sale_price',
+            'sale_price_display',
+            'sale_ends_display',
+            'vendor_name',
+            # TODO: should be case_size
+            'case_quantity',
+            'case_price',
+            'case_price_display',
+            'uom_choices',
+            'organic',
+        ])
+
+    # TODO: deprecate / remove this?  not sure if/where it is used
+    # (hm, still used by the old Instacart -> Configure page..)
+    def search_v1(self):
         """
         Locate a product(s) by UPC.
 
         Eventually this should be more generic, or at least offer more fields for
         search.  For now it operates only on the ``Product.upc`` field.
         """
+        model = self.model
         data = None
         upc = self.request.GET.get('upc', '').strip()
         upc = re.sub(r'\D', '', upc)
         if upc:
-            product = api.get_product_by_upc(Session(), upc)
+            product = api.get_product_by_upc(self.Session(), upc)
             if not product:
                 # Try again, assuming caller did not include check digit.
                 upc = GPC(upc, calc_check_digit='upc')
-                product = api.get_product_by_upc(Session(), upc)
+                product = api.get_product_by_upc(self.Session(), upc)
             if product and (not product.deleted or self.request.has_perm('products.view_deleted')):
                 data = {
                     'uuid': product.uuid,
-                    'upc': six.text_type(product.upc),
+                    'upc': str(product.upc),
                     'upc_pretty': product.upc.pretty(),
                     'full_description': product.full_description,
-                    'image_url': pod.get_image_url(self.rattail_config, product.upc),
+                    'image_url': pod.get_image_url(self.rattail_config, product.upc,
+                                                   require=False),
                 }
                 uuid = self.request.GET.get('with_vendor_cost')
                 if uuid:
-                    vendor = Session.query(model.Vendor).get(uuid)
+                    vendor = self.Session.get(model.Vendor, uuid)
                     if not vendor:
                         return {'error': "Vendor not found"}
                     cost = product.cost_for_vendor(vendor)
@@ -1585,19 +1961,31 @@ class ProductsView(MasterView):
         return {'product': data}
 
     def get_supported_batches(self):
-        return {
-            'labels': 'rattail.batch.labels:LabelBatchHandler',
-            'pricing': 'rattail.batch.pricing:PricingBatchHandler',
-        }
+        app = self.get_rattail_app()
+        pricing = app.get_batch_handler('pricing')
+        return OrderedDict([
+            ('labels', {
+                'spec': self.rattail_config.get('rattail.batch', 'labels.handler',
+                                                default='rattail.batch.labels:LabelBatchHandler'),
+            }),
+            ('pricing', {
+                'spec': pricing.get_spec(),
+            }),
+            ('delproduct', {
+                'spec': self.rattail_config.get('rattail.batch', 'delproduct.handler',
+                                                default='rattail.batch.delproduct:DeleteProductBatchHandler'),
+            }),
+        ])
 
     def make_batch(self):
         """
         View for making a new batch from current product grid query.
         """
+        app = self.get_rattail_app()
         supported = self.get_supported_batches()
         batch_options = []
         for key, info in list(supported.items()):
-            handler = load_object(info['spec'])(self.rattail_config)
+            handler = app.load_object(info['spec'])(self.rattail_config)
             handler.spec = info['spec']
             handler.option_key = key
             handler.option_title = info.get('title', handler.get_model_title())
@@ -1629,8 +2017,9 @@ class ProductsView(MasterView):
                 params_forms[key] = forms.Form(schema=schema, request=self.request)
 
         if self.request.method == 'POST':
-            if form.validate(newstyle=True):
+            if form.validate():
                 data = form.validated
+                fully_validated = True
 
                 # collect general params
                 batch_key = data['batch_type']
@@ -1640,27 +2029,32 @@ class ProductsView(MasterView):
 
                 # collect batch-type-specific params
                 pform = params_forms.get(batch_key)
-                if pform and pform.validate(newstyle=True):
-                    pdata = pform.validated
-                    for field in pform.schema:
-                        param_name = pform.schema[field.name].param_name
-                        params[param_name] = pdata[field.name]
+                if pform:
+                    if pform.validate():
+                        pdata = pform.validated
+                        for field in pform.schema:
+                            param_name = pform.schema[field.name].param_name
+                            params[param_name] = pdata[field.name]
+                    else:
+                        fully_validated = False
 
-                # TODO: should this be done elsewhere?
-                for name in params:
-                    if params[name] is colander.null:
-                        params[name] = None
+                if fully_validated:
 
-                handler = supported[batch_key]
-                products = self.get_products_for_batch(batch_key)
-                progress = self.make_progress('products.batch')
-                thread = Thread(target=self.make_batch_thread,
-                                args=(handler, self.request.user.uuid, products, params, progress))
-                thread.start()
-                return self.render_progress(progress, {
-                    'cancel_url': self.get_index_url(),
-                    'cancel_msg': "Batch creation was canceled.",
-                })
+                    # TODO: should this be done elsewhere?
+                    for name in params:
+                        if params[name] is colander.null:
+                            params[name] = None
+
+                    handler = supported[batch_key]
+                    products = self.get_products_for_batch(batch_key)
+                    progress = self.make_progress('products.batch')
+                    thread = Thread(target=self.make_batch_thread,
+                                    args=(handler, self.request.user.uuid, products, params, progress))
+                    thread.start()
+                    return self.render_progress(progress, {
+                        'cancel_url': self.get_index_url(),
+                        'cancel_msg': "Batch creation was canceled.",
+                    })
 
         return self.render_to_response('batch', {
             'form': form,
@@ -1680,7 +2074,9 @@ class ProductsView(MasterView):
         """
         Return params schema for making a pricing batch.
         """
-        return colander.SchemaNode(
+        app = self.get_rattail_app()
+
+        schema = colander.SchemaNode(
             colander.Mapping(),
             colander.SchemaNode(colander.Decimal(), name='min_diff_threshold',
                                 quant='1.00', missing=colander.null,
@@ -1691,33 +2087,105 @@ class ProductsView(MasterView):
             colander.SchemaNode(colander.Boolean(), name='calculate_for_manual'),
         )
 
+        pricing = app.get_batch_handler('pricing')
+        if pricing.allow_future():
+            schema.insert(0, colander.SchemaNode(
+                colander.Date(),
+                name='start_date',
+                missing=colander.null,
+                title="Start Date (FUTURE only)",
+                widget=forms.widgets.JQueryDateWidget()))
+
+        return schema
+
+    def make_batch_params_schema_delproduct(self):
+        """
+        Return params schema for making a "delete products" batch.
+        """
+        return colander.SchemaNode(
+            colander.Mapping(),
+            colander.SchemaNode(colander.Integer(), name='inactivity_months',
+                                # TODO: probably should be configurable
+                                default=18),
+        )
+
     def make_batch_thread(self, handler, user_uuid, products, params, progress):
         """
         Threat target for making a batch from current products query.
         """
+        model = self.model
         session = RattailSession()
-        user = session.query(model.User).get(user_uuid)
+        user = session.get(model.User, user_uuid)
         assert user
         params['created_by'] = user
-        batch = handler.make_batch(session, **params)
-        batch.products = products.with_session(session).all()
-        handler.do_populate(batch, user, progress=progress)
+        try:
+            batch = handler.make_batch(session, **params)
+            batch.products = products.with_session(session).all()
+            handler.do_populate(batch, user, progress=progress)
 
-        session.commit()
-        session.refresh(batch)
-        session.close()
+        except Exception as error:
+            session.rollback()
+            log.exception("failed to make '%s' batch with params: %s",
+                          handler.batch_key, params)
+            session.close()
+            if progress:
+                progress.session.load()
+                progress.session['error'] = True
+                progress.session['error_msg'] = "Failed to make '{}' batch: {}".format(
+                    handler.batch_key, simple_error(error))
+                progress.session.save()
 
-        progress.session.load()
-        progress.session['complete'] = True
-        progress.session['success_url'] = self.get_batch_view_url(batch)
-        progress.session['success_msg'] = 'Batch has been created: {}'.format(batch)
-        progress.session.save()
+        else:
+            session.commit()
+            session.refresh(batch)
+            session.close()
+
+            if progress:
+                progress.session.load()
+                progress.session['complete'] = True
+                progress.session['success_url'] = self.get_batch_view_url(batch)
+                progress.session['success_msg'] = 'Batch has been created: {}'.format(batch)
+                progress.session.save()
 
     def get_batch_view_url(self, batch):
         if batch.batch_key == 'labels':
             return self.request.route_url('labels.batch.view', uuid=batch.uuid)
         if batch.batch_key == 'pricing':
             return self.request.route_url('batch.pricing.view', uuid=batch.uuid)
+        if batch.batch_key == 'delproduct':
+            return self.request.route_url('batch.delproduct.view', uuid=batch.uuid)
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # display
+            {'section': 'rattail',
+             'option': 'product.key'},
+            {'section': 'rattail',
+             'option': 'product.key_title'},
+            {'section': 'tailbone',
+             'option': 'products.show_pod_image',
+             'type': bool},
+            {'section': 'rattail.pod',
+             'option': 'pictures.gtin.root_url'},
+
+            # handling
+            {'section': 'rattail',
+             'option': 'products.convert_type2_for_gpc_lookup',
+             'type': bool},
+            {'section': 'rattail',
+             'option': 'products.units_only',
+             'type': bool},
+
+            # labels
+            {'section': 'tailbone',
+             'option': 'products.print_labels',
+             'type': bool},
+            {'section': 'tailbone',
+             'option': 'products.quick_labels.speedbump_threshold',
+             'type': int},
+
+        ]
 
     @classmethod
     def defaults(cls, config):
@@ -1733,11 +2201,18 @@ class ProductsView(MasterView):
         template_prefix = cls.get_template_prefix()
         permission_prefix = cls.get_permission_prefix()
         model_title = cls.get_model_title()
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
+        model_title_plural = cls.get_model_title_plural()
 
         # print labels
-        config.add_tailbone_permission('products', 'products.print_labels',
-                                       "Print labels for products")
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.print_labels'.format(permission_prefix),
+                                       "Print labels for {}".format(model_title_plural))
+        config.add_route('{}.print_labels'.format(route_prefix),
+                         '{}/labels'.format(url_prefix))
+        config.add_view(cls, attr='print_labels',
+                        route_name='{}.print_labels'.format(route_prefix),
+                        permission='{}.print_labels'.format(permission_prefix),
+                        renderer='json')
 
         # view deleted products
         config.add_tailbone_permission('products', 'products.view_deleted',
@@ -1751,10 +2226,10 @@ class ProductsView(MasterView):
                         renderer='{}/batch.mako'.format(template_prefix),
                         permission='{}.make_batch'.format(permission_prefix))
 
-        # search (by upc)
+        # search
         config.add_route('products.search', '/products/search')
         config.add_view(cls, attr='search', route_name='products.search',
-                        renderer='json', permission='products.view')
+                        renderer='json', permission='products.list')
 
         # product image
         config.add_route('products.image', '/products/{uuid}/image')
@@ -1774,70 +2249,510 @@ class ProductsView(MasterView):
                         renderer='json',
                         permission='{}.versions'.format(permission_prefix))
 
-        # mobile quick lookup
-        if legacy_mobile:
-            config.add_route('mobile.products.quick_lookup', '/mobile/products/quick-lookup')
-            config.add_view(cls, attr='mobile_quick_lookup', route_name='mobile.products.quick_lookup')
 
-
-class ProductsAutocomplete(AutocompleteView):
+class PendingProductView(MasterView):
     """
-    Autocomplete view for products.
+    Master view for the Pending Product class.
     """
-    mapped_class = model.Product
-    fieldname = 'description'
+    model_class = PendingProduct
+    route_prefix = 'pending_products'
+    url_prefix = '/products/pending'
+    bulk_deletable = True
 
-    def query(self, term):
-        q = Session.query(model.Product).outerjoin(model.Brand)
-        q = q.filter(sa.or_(
-                model.Brand.name.ilike('%{}%'.format(term)),
-                model.Product.description.ilike('%{}%'.format(term))))
-        if not self.request.has_perm('products.view_deleted'):
-            q = q.filter(model.Product.deleted == False)
-        q = q.order_by(model.Brand.name, model.Product.description)
-        q = q.options(orm.joinedload(model.Product.brand))
-        return q
+    labels = {
+        'regular_price_amount': "Regular Price",
+        'status_code': "Status",
+        'user': "Created by",
+    }
 
-    def display(self, product):
-        return product.full_description
+    grid_columns = [
+        '_product_key_',
+        'brand_name',
+        'description',
+        'size',
+        'department_name',
+        'created',
+        'user',
+        'status_code',
+    ]
+
+    form_fields = [
+        '_product_key_',
+        'product',
+        'brand_name',
+        'brand',
+        'description',
+        'size',
+        'department_name',
+        'department',
+        'vendor_name',
+        'vendor',
+        'vendor_item_code',
+        'unit_cost',
+        'case_size',
+        'regular_price_amount',
+        'special_order',
+        'notes',
+        'status_code',
+        'created',
+        'user',
+        'resolved',
+        'resolved_by',
+    ]
+
+    has_rows = True
+    model_row_class = CustomerOrderItem
+    rows_title = "Customer Orders"
+    # TODO: add support for this someday
+    rows_viewable = False
+
+    row_labels = {
+        'order_id': "Order ID",
+        'product_brand': "Brand",
+        'product_description': "Description",
+        'product_size': "Size",
+        'order_created': "Ordered",
+        'status_code': "Status",
+    }
+
+    row_grid_columns = [
+        'order_id',
+        'customer',
+        'person',
+        '_product_key_',
+        'product_brand',
+        'product_description',
+        'product_size',
+        'order_quantity',
+        'total_price',
+        'order_created',
+        'status_code',
+        'flagged',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+        model = self.model
+
+        # description
+        g.set_link('description')
+
+        # status_code
+        g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
+        g.set_filter('status_code', model.PendingProduct.status_code,
+                     value_enum=self.enum.PENDING_PRODUCT_STATUS,
+                     default_active=True,
+                     default_verb='equal',
+                     default_value=str(self.enum.PENDING_PRODUCT_STATUS_PENDING))
+
+        # created
+        g.set_sort_defaults('created', 'desc')
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        model = self.model
+        pending = f.model_instance
+
+        # product
+        f.set_readonly('product') # TODO
+        f.set_renderer('product', self.render_product)
+
+        # department
+        if self.creating or self.editing:
+            if 'department' in f:
+                f.remove('department_name')
+                f.replace('department', 'department_uuid')
+                f.set_widget('department_uuid', forms.widgets.DepartmentWidget(self.request, required=False))
+                f.set_label('department_uuid', "Department")
+        else:
+            f.set_renderer('department', self.render_department)
+            if pending.department:
+                f.remove('department_name')
+
+        # brand
+        if self.creating or self.editing:
+            f.remove('brand_name')
+            f.replace('brand', 'brand_uuid')
+            f.set_label('brand_uuid', "Brand")
+
+            f.set_node('brand_uuid', colander.String(), missing=colander.null)
+            brand_display = ""
+            if self.request.method == 'POST':
+                if self.request.POST.get('brand_uuid'):
+                    brand = self.Session.get(model.Brand, self.request.POST['brand_uuid'])
+                    if brand:
+                        brand_display = str(brand)
+            elif self.editing:
+                brand_display = str(pending.brand or '')
+            brands_url = self.request.route_url('brands.autocomplete')
+            f.set_widget('brand_uuid', forms.widgets.JQueryAutocompleteWidget(
+                field_display=brand_display, service_url=brands_url))
+        else:
+            f.set_renderer('brand', self.render_brand)
+            if pending.brand:
+                f.remove('brand_name')
+            elif pending.brand_name:
+                f.remove('brand')
+
+        # description
+        f.set_required('description')
+
+        # vendor
+        if self.creating or self.editing:
+            if 'vendor' in f:
+                f.remove('vendor_name')
+                f.replace('vendor', 'vendor_uuid')
+                f.set_node('vendor_uuid', colander.String())
+                vendor_display = ""
+                if self.request.method == 'POST':
+                    if self.request.POST.get('vendor_uuid'):
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid'])
+                        if vendor:
+                            vendor_display = str(vendor)
+                f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget(
+                    field_display=vendor_display,
+                    service_url=self.request.route_url('vendors.autocomplete')))
+                f.set_label('vendor_uuid', "Vendor")
+        else:
+            f.set_renderer('vendor', self.render_vendor)
+            if pending.vendor:
+                f.remove('vendor_name')
+            elif pending.vendor_name:
+                f.remove('vendor')
+
+        # case_size
+        f.set_type('case_size', 'quantity')
+
+        # regular_price_amount
+        f.set_type('regular_price_amount', 'currency')
+
+        # notes
+        f.set_type('notes', 'text')
+
+        # created
+        if self.creating:
+            f.remove('created')
+        else:
+            f.set_readonly('created')
+
+        # status_code
+        if self.creating:
+            f.remove('status_code')
+        else:
+            f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
+            if self.viewing:
+                f.set_renderer('status_code', self.render_status_code)
+
+                if (self.has_perm('ignore_product')
+                    and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                                                self.enum.PENDING_PRODUCT_STATUS_READY)):
+                    f.set_vuejs_component_kwargs(**{'@ignore-product': 'ignoreProductInit'})
+
+                if (self.has_perm('resolve_product')
+                    and pending.status_code in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                                                self.enum.PENDING_PRODUCT_STATUS_READY,
+                                                self.enum.PENDING_PRODUCT_STATUS_IGNORED)):
+                    f.set_vuejs_component_kwargs(**{'@resolve-product': 'resolveProductInit'})
+
+        # user
+        if self.creating:
+            f.remove('user')
+        else:
+            f.set_readonly('user')
+            f.set_renderer('user', self.render_user)
+
+        # resolved*
+        if self.creating:
+            f.remove('resolved', 'resolved_by')
+        elif pending.resolved:
+            f.set_renderer('resolved_by', self.render_user)
+        else:
+            f.remove('resolved', 'resolved_by')
+
+    def render_status_code(self, pending, field):
+        status = pending.status_code
+        if not status:
+            return
+
+        # will just show status text by default
+        text = self.enum.PENDING_PRODUCT_STATUS.get(status, str(status))
+        html = text
+
+        # but maybe also show buttons to change status
+        buttons = []
+
+        if (self.has_perm('ignore_product')
+            and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                           self.enum.PENDING_PRODUCT_STATUS_READY)):
+            buttons.append(self.make_button("Ignore Product",
+                                            type='is-warning',
+                                            icon_left='ban',
+                                            **{'@click': "$emit('ignore-product')"}))
+
+        if (self.has_perm('resolve_product')
+            and status in (self.enum.PENDING_PRODUCT_STATUS_PENDING,
+                           self.enum.PENDING_PRODUCT_STATUS_READY,
+                           self.enum.PENDING_PRODUCT_STATUS_IGNORED)):
+            buttons.append(self.make_button("Resolve Product",
+                                            is_primary=True,
+                                            icon_left='object-ungroup',
+                                            **{'@click': "$emit('resolve-product')"}))
+
+        if buttons:
+            text = HTML.tag('span', class_='control', c=[text])
+            buttons = HTML.tag('div', class_='buttons', c=buttons)
+            html = HTML.tag('b-field', grouped='grouped', c=[text, buttons])
+
+        return html
+
+    def editable_instance(self, pending):
+        if self.request.is_root:
+            return True
+        if pending.status_code == self.enum.PENDING_PRODUCT_STATUS_RESOLVED:
+            return False
+        return True
+
+    def objectify(self, form, data=None):
+        if data is None:
+            data = form.validated
+
+        pending = super().objectify(form, data)
+
+        if not pending.user:
+            pending.user = self.request.user
+
+        self.Session.add(pending)
+        self.Session.flush()
+        self.Session.refresh(pending)
+
+        if pending.department:
+            pending.department_name = pending.department.name
+
+        if pending.brand:
+            pending.brand_name = pending.brand.name
+
+        return pending
+
+    def before_delete(self, pending):
+        """
+        Event hook, called just before deletion is attempted.
+        """
+        model = self.model
+        model_title = self.get_model_title()
+        count = self.Session.query(model.CustomerOrderItem)\
+                            .filter(model.CustomerOrderItem.pending_product == pending)\
+                            .count()
+        if count:
+            self.request.session.flash("Cannot delete this {} because it is still "
+                                       "referenced by {} Customer Orders.".format(model_title, count),
+                                       'error')
+            return self.redirect(self.get_action_url('view', pending))
+
+        count = self.Session.query(model.CustomerOrderBatchRow)\
+                            .filter(model.CustomerOrderBatchRow.pending_product == pending)\
+                            .count()
+        if count:
+            self.request.session.flash("Cannot delete this {} because it is still "
+                                       "referenced by {} \"new\" Customer Order Batches.".format(model_title, count),
+                                       'error')
+            return self.redirect(self.get_action_url('view', pending))
+
+    def resolve_product(self):
+        model = self.model
+        pending = self.get_instance()
+        redirect = self.redirect(self.get_action_url('view', pending))
+
+        uuid = self.request.POST['product_uuid']
+        product = self.Session.get(model.Product, uuid)
+        if not product:
+            self.request.session.flash("Product not found!", 'error')
+            return redirect
+
+        app = self.get_rattail_app()
+        products_handler = app.get_products_handler()
+        kwargs = self.get_resolve_product_kwargs()
+
+        try:
+            products_handler.resolve_product(pending, product, self.request.user, **kwargs)
+        except Exception as error:
+            log.warning("failed to resolve product", exc_info=True)
+            self.request.session.flash(f"Resolve failed: {simple_error(error)}", 'error')
+            return redirect
+
+        return redirect
+
+    def get_resolve_product_kwargs(self, **kwargs):
+        return kwargs
+
+    def ignore_product(self):
+        model = self.model
+        pending = self.get_instance()
+        pending.status_code = self.enum.PENDING_PRODUCT_STATUS_IGNORED
+        return self.redirect(self.get_action_url('view', pending))
+
+    def get_row_data(self, pending):
+        model = self.model
+        return self.Session.query(model.CustomerOrderItem)\
+                           .filter(model.CustomerOrderItem.pending_product == pending)
+
+    def get_parent(self, item):
+        return item.pending_product
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+        app = self.get_rattail_app()
+
+        # order_id
+        g.set_renderer('order_id', lambda item, field: item.order.id)
+
+        # contact
+        handler = app.get_batch_handler('custorder')
+        if handler.new_order_requires_customer():
+            g.remove('person')
+            g.set_renderer('customer', lambda item, field: item.order.customer)
+        else:
+            g.remove('customer')
+            g.set_renderer('person', lambda item, field: item.order.person)
+
+        # product_key
+        field = self.get_product_key_field()
+        if not self.rows_viewable:
+            g.set_link(field, False)
+        g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}'))
+
+        # "numbers"
+        g.set_type('order_quantity', 'quantity')
+        g.set_type('total_price', 'currency')
+
+        # order_created
+        g.set_renderer('order_created',
+                       lambda item, field: raw_datetime(self.rattail_config,
+                                                        app.localtime(item.order.created,
+                                                                      from_utc=True),
+                                                        as_date=True))
+
+        # status_code
+        g.set_enum('status_code', self.enum.CUSTORDER_ITEM_STATUS)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._pending_product_defaults(config)
+
+    @classmethod
+    def _pending_product_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        model_title = cls.get_model_title()
+
+        # resolve product
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.resolve_product'.format(permission_prefix),
+                                       "Resolve a {} as a Product".format(model_title))
+        config.add_route('{}.resolve_product'.format(route_prefix),
+                         '{}/resolve-product'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='resolve_product',
+                        route_name='{}.resolve_product'.format(route_prefix),
+                        permission='{}.resolve_product'.format(permission_prefix))
+
+        # ignore product
+        config.add_tailbone_permission(permission_prefix,
+                                       f'{permission_prefix}.ignore_product',
+                                       f"Mark {model_title} as ignored")
+        config.add_route(f'{route_prefix}.ignore_product',
+                         f'{instance_url_prefix}/ignore-product',
+                         request_method='POST')
+        config.add_view(cls, attr='ignore_product',
+                        route_name=f'{route_prefix}.ignore_product',
+                        permission=f'{permission_prefix}.ignore_product')
 
 
-def print_labels(request):
-    profile = request.params.get('profile')
-    profile = Session.query(model.LabelProfile).get(profile) if profile else None
-    if not profile:
-        return {'error': "Label profile not found"}
+class ProductCostView(MasterView):
+    """
+    Master view for Product Costs
+    """
+    model_class = ProductCost
+    route_prefix = 'product_costs'
+    url_prefix = '/products/costs'
+    has_versions = True
 
-    product = request.params.get('product')
-    product = Session.query(model.Product).get(product) if product else None
-    if not product:
-        return {'error': "Product not found"}
+    grid_columns = [
+        '_product_key_',
+        'vendor',
+        'preference',
+        'code',
+        'case_size',
+        'case_cost',
+        'pack_size',
+        'pack_cost',
+        'unit_cost',
+    ]
 
-    quantity = request.params.get('quantity')
-    if not quantity.isdigit():
-        return {'error': "Quantity must be numeric"}
-    quantity = int(quantity)
+    def query(self, session):
+        """ """
+        query = super().query(session)
+        model = self.app.model
 
-    printer = profile.get_printer(request.rattail_config)
-    if not printer:
-        return {'error': "Couldn't get printer from label profile"}
+        # always join on Product
+        return query.join(model.Product)
 
-    try:
-        printer.print_labels([(product, quantity, {})])
-    except Exception as error:
-        log.warning("error occurred while printing labels", exc_info=True)
-        return {'error': six.text_type(error)}
-    return {}
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+        model = self.app.model
+
+        # product key
+        field = self.get_product_key_field()
+        g.set_renderer(field, self.render_product_key)
+        g.set_sorter(field, getattr(model.Product, field))
+        g.set_sort_defaults(field)
+        g.set_filter(field, getattr(model.Product, field))
+
+        # vendor
+        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
+
+    def render_product_key(self, cost, field):
+        """ """
+        handler = self.app.get_products_handler()
+        return handler.render_product_key(cost.product)
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+
+        # product
+        f.set_renderer('product', self.render_product)
+        if 'product_uuid' in f and 'product' in f:
+            f.remove('product')
+            f.replace('product_uuid', 'product')
+
+        # vendor
+        f.set_renderer('vendor', self.render_vendor)
+        if 'vendor_uuid' in f and 'vendor' in f:
+            f.remove('vendor')
+            f.replace('vendor_uuid', 'vendor')
+
+        # futures
+        # TODO: should eventually show a subgrid here?
+        f.remove('futures')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ProductView = kwargs.get('ProductView', base['ProductView'])
+    ProductView.defaults(config)
+
+    PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
+    PendingProductView.defaults(config)
+
+    ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
+    ProductCostView.defaults(config)
 
 
 def includeme(config):
-
-    config.add_route('products.autocomplete', '/products/autocomplete')
-    config.add_view(ProductsAutocomplete, route_name='products.autocomplete',
-                    renderer='json', permission='products.list')
-
-    config.add_route('products.print_labels', '/products/labels')
-    config.add_view(print_labels, route_name='products.print_labels',
-                    renderer='json', permission='products.print_labels')
-
-    ProductsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py
index e3cbef01..3f47ba3e 100644
--- a/tailbone/views/progress.py
+++ b/tailbone/views/progress.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,20 +24,29 @@
 Progress Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from tailbone.progress import get_progress_session
 
 
 def progress(request):
     key = request.matchdict['key']
-    session = get_progress_session(request, key, type=request.GET.get('sessiontype'))
+    session = get_progress_session(request, key,
+                                   type=request.GET.get('sessiontype'))
+
     if session.get('complete'):
+
         msg = session.get('success_msg')
         if msg:
             request.session.flash(msg)
+
+        bits = session.get('extra_session_bits')
+        if bits:
+            for key, value in bits.items():
+                request.session[key] = value
+
     elif session.get('error'):
-        request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error')
+        msg = session.get('error_msg', "An unspecified error occurred.")
+        request.session.flash(msg, 'error')
+
     return session
 
 
@@ -52,9 +61,17 @@ def cancel(request):
     return {}
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    progress = kwargs.get('progress', base['progress'])
     config.add_route('progress', '/progress/{key}')
     config.add_view(progress, route_name='progress', renderer='json')
 
+    cancel = kwargs.get('cancel', base['cancel'])
     config.add_route('progress.cancel', '/progress/{key}/cancel')
     config.add_view(cancel, route_name='progress.cancel', renderer='json')
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py
new file mode 100644
index 00000000..bcc4cb5d
--- /dev/null
+++ b/tailbone/views/projects.py
@@ -0,0 +1,452 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Project views
+"""
+
+from collections import OrderedDict
+
+import colander
+from deform import widget as dfwidget
+
+from rattail.projects import (PythonProjectGenerator,
+                              PoserProjectGenerator,
+                              RattailAdjacentProjectGenerator)
+
+from tailbone import forms
+from tailbone.views import MasterView
+
+
+class GeneratedProjectView(MasterView):
+    """
+    View for generating new project source code
+    """
+    model_title = "Generated Project"
+    model_key = 'folder'
+    route_prefix = 'generated_projects'
+    url_prefix = '/generated-projects'
+    listable = False
+    viewable = False
+    editable = False
+    deletable = False
+
+    def __init__(self, request):
+        super(GeneratedProjectView, self).__init__(request)
+        self.project_handler = self.get_project_handler()
+
+    def get_project_handler(self):
+        app = self.get_rattail_app()
+        return app.get_project_handler()
+
+    def create(self):
+        supported = self.project_handler.get_supported_project_generators()
+        supported_keys = list(supported)
+
+        project_type = self.request.matchdict.get('project_type')
+        if project_type:
+            form = self.make_project_form(project_type)
+            if form.validate():
+                zipped = self.generate_project(project_type, form)
+                return self.file_response(zipped)
+
+        else: # no project_type
+
+            # make form to accept user choice of report type
+            schema = colander.Schema()
+            values = [(typ, typ) for typ in supported_keys]
+            schema.add(colander.SchemaNode(name='project_type',
+                                           typ=colander.String(),
+                                           validator=colander.OneOf(supported_keys),
+                                           widget=dfwidget.SelectWidget(values=values)))
+            form = forms.Form(schema=schema, request=self.request)
+            form.submit_label = "Continue"
+
+            # if form validates, then user has chosen a project type, so
+            # we redirect to the appropriate "generate project" page
+            if form.validate():
+                raise self.redirect(self.request.route_url(
+                    'generate_specific_project',
+                    project_type=form.validated['project_type']))
+
+        return self.render_to_response('create', {
+            'index_title': "Generate Project",
+            'project_type': project_type,
+            'form': form,
+        })
+
+    def generate_project(self, project_type, form):
+        context = dict(form.validated)
+        output = self.project_handler.generate_project(project_type,
+                                                       context=context)
+        return self.project_handler.zip_output(output)
+
+    def make_project_form(self, project_type):
+
+        # make form
+        schema = self.project_handler.make_project_schema(project_type)
+        form = forms.Form(schema=schema, request=self.request)
+        form.auto_disable = False
+        form.auto_disable_save = False
+        form.submit_label = "Generate Project"
+        form.cancel_url = self.request.route_url('generated_projects.create')
+
+        # apply normal config
+        self.configure_form_common(form, project_type)
+
+        # let supplemental views further configure form
+        for supp in self.iter_view_supplements():
+            configure = getattr(supp, 'configure_form_{}'.format(project_type), None)
+            if configure:
+                configure(form)
+
+        # if master view has more configure logic, do that too
+        configure = getattr(self, 'configure_form_{}'.format(project_type), None)
+        if configure:
+            configure(form)
+
+        return form
+
+    def configure_form_common(self, form, project_type):
+        generator = self.project_handler.get_project_generator(project_type,
+                                                               require=True)
+
+        # python-based projects
+        if isinstance(generator, PythonProjectGenerator):
+            self.configure_form_python(form)
+
+        # rattail-adjacent projects
+        if isinstance(generator, RattailAdjacentProjectGenerator):
+            self.configure_form_rattail_adjacent(form)
+
+        # poser-based projects
+        if isinstance(generator, PoserProjectGenerator):
+            self.configure_form_poser(form)
+
+    def configure_form_python(self, f):
+
+        f.set_grouping([
+            ("Naming", [
+                'name',
+                'pkg_name',
+                'pypi_name',
+            ]),
+        ])
+
+        # name
+        f.set_label('name', "Project Name")
+        f.set_helptext('name', "Human-friendly name generally used to refer to this project.")
+        f.set_default('name', "Poser Plus")
+
+        # pkg_name
+        f.set_label('pkg_name', "Package Name in Python")
+        f.set_helptext('pkg_name', "`For example, ~/src/${field_model_pkg_name.replace(/_/g, '-')}/${field_model_pkg_name}/__init__.py`",
+                       dynamic=True)
+        f.set_default('pkg_name', "poser_plus")
+
+        # pypi_name
+        f.set_label('pypi_name', "Package Name for PyPI")
+        f.set_helptext('pypi_name', "It's a good idea to use org name as namespace prefix here")
+        f.set_default('pypi_name', "Acme-Poser-Plus")
+
+    def configure_form_rattail_adjacent(self, f):
+
+        # extends_config
+        f.set_label('extends_config', "Extend Config")
+        f.set_helptext('extends_config', "Needed to customize default config values etc.")
+        f.set_default('extends_config', True)
+
+        # has_cli
+        f.set_label('has_cli', "Use Separate CLI")
+        f.set_helptext('has_cli', "`Needed for e.g. '${field_model_pkg_name} install' command.`",
+                       dynamic=True)
+        f.set_default('has_cli', True)
+
+        # extends_db
+        f.set_label('extends_db', "Extend DB Schema")
+        f.set_helptext('extends_db', "For adding custom tables/columns to the core schema")
+        f.set_default('extends_db', True)
+
+    def configure_form_poser(self, f):
+
+        # organization
+        f.set_helptext('organization', 'For use with branding etc.')
+        f.set_default('organization', "Acme Foods")
+
+        # has_db
+        f.set_label('has_db', "Use Rattail DB")
+        f.set_helptext('has_db', "Note that a DB is required for the Web App")
+        f.set_default('has_db', True)
+
+        # has_batch_schema
+        f.set_label('has_batch_schema', "Add Batch Schema")
+        f.set_helptext('has_batch_schema', 'Usually not needed - it\'s for "dynamic" (e.g. import/export) batches')
+
+        # has_web
+        f.set_label('has_web', "Use Tailbone Web App")
+        f.set_default('has_web', True)
+
+        # has_web_api
+        f.set_label('has_web_api', "Use Tailbone Web API")
+        f.set_helptext('has_web_api', "Needed for e.g. Vue.js SPA mobile apps")
+
+        # has_datasync
+        f.set_label('has_datasync', "Use DataSync Service")
+
+        # uses_fabric
+        f.set_label('uses_fabric', "Use Fabric")
+        f.set_default('uses_fabric', True)
+
+    def configure_form_rattail(self, f):
+
+        f.set_grouping([
+            ("Naming", [
+                'name',
+                'pkg_name',
+                'pypi_name',
+                'organization',
+            ]),
+            ("Core", [
+                'extends_config',
+                'has_cli',
+            ]),
+            ("Database", [
+                'has_db',
+                'extends_db',
+                'has_batch_schema',
+            ]),
+            ("Web", [
+                'has_web',
+                'has_web_api',
+            ]),
+            ("Integrations", [
+                # 'integrates_catapult',
+                # 'integrates_corepos',
+                # 'integrates_locsms',
+                'has_datasync',
+            ]),
+            ("Deployment", [
+                'uses_fabric',
+            ]),
+        ])
+
+        # # integrates_catapult
+        # f.set_label('integrates_catapult', "Integrate w/ Catapult")
+        # f.set_helptext('integrates_catapult', "Add schema, import/export logic etc. for ECRS Catapult")
+
+        # # integrates_corepos
+        # f.set_label('integrates_corepos', "Integrate w/ CORE-POS")
+        # f.set_helptext('integrates_corepos', "Add schema, import/export logic etc. for CORE-POS")
+
+        # # integrates_locsms
+        # f.set_label('integrates_locsms', "Integrate w/ LOC SMS")
+        # f.set_helptext('integrates_locsms', "Add schema, import/export logic etc. for LOC SMS")
+
+    def configure_form_rattail_integration(self, f):
+
+        f.set_grouping([
+            ("Naming", [
+                'integration_name',
+                'integration_url',
+                'name',
+                'pkg_name',
+                'pypi_name',
+            ]),
+            ("Options", [
+                'extends_config',
+                'extends_db',
+                'has_cli',
+            ]),
+        ])
+
+        # default settings
+        f.set_default('name', 'rattail-foo')
+        f.set_default('pkg_name', 'rattail_foo')
+        f.set_default('pypi_name', 'rattail-foo')
+        f.set_default('has_cli', False)
+
+        # integration_name
+        f.set_helptext('integration_name', "Name of the system to be integrated")
+        f.set_default('integration_name', "Foo")
+
+        # integration_url
+        f.set_label('integration_url', "Integration URL")
+        f.set_helptext('integration_url', "Reference URL for the system to be integrated")
+        f.set_default('integration_url', "https://www.example.com/")
+
+    def configure_form_rattail_shopfoo(self, f):
+
+        # first do normal integration setup
+        self.configure_form_rattail_integration(f)
+
+        f.set_grouping([
+            ("Naming", [
+                'integration_name',
+                'integration_url',
+                'name',
+                'pkg_name',
+                'pypi_name',
+            ]),
+            ("Options", [
+                'has_cli',
+            ]),
+        ])
+
+        # default settings
+        f.set_default('integration_name', 'Shopfoo')
+        f.set_default('name', 'rattail-shopfoo')
+        f.set_default('pkg_name', 'rattail_shopfoo')
+        f.set_default('pypi_name', 'rattail-shopfoo')
+        f.set_default('has_cli', False)
+
+    def configure_form_tailbone_integration(self, f):
+
+        f.set_grouping([
+            ("Naming", [
+                'integration_name',
+                'integration_url',
+                'name',
+                'pkg_name',
+                'pypi_name',
+            ]),
+            ("Options", [
+                'has_static_files',
+            ]),
+        ])
+
+        # integration_name
+        f.set_helptext('integration_name', "Name of the system to be integrated")
+        f.set_default('integration_name', "Foo")
+
+        # integration_url
+        f.set_label('integration_url', "Integration URL")
+        f.set_helptext('integration_url', "Reference URL for the system to be integrated")
+        f.set_default('integration_url', "https://www.example.com/")
+
+        # has_static_files
+        f.set_helptext('has_static_files', "Register a subfolder for static files (images etc.)")
+
+    def configure_form_tailbone_shopfoo(self, f):
+
+        # first do normal integration setup
+        self.configure_form_tailbone_integration(f)
+
+        f.set_grouping([
+            ("Naming", [
+                'integration_name',
+                'integration_url',
+                'name',
+                'pkg_name',
+                'pypi_name',
+            ]),
+        ])
+
+        # default settings
+        f.set_default('integration_name', 'Shopfoo')
+        f.set_default('name', 'tailbone-shopfoo')
+        f.set_default('pkg_name', 'tailbone_shopfoo')
+        f.set_default('pypi_name', 'tailbone-shopfoo')
+
+    def configure_form_byjove(self, f):
+
+        f.set_grouping([
+            ("Naming", [
+                'system_name',
+                'name',
+                'slug',
+            ]),
+        ])
+
+        # system_name
+        f.set_default('system_name', "Okay Then")
+        f.set_helptext('system_name',
+                       "Name of overall system to which mobile app belongs.")
+
+        # name
+        f.set_label('name', "Mobile App Name")
+        f.set_default('name', "Okay Then Mobile")
+        f.set_helptext('name', "Display name for the mobile app.")
+
+        # slug
+        f.set_default('slug', "okay-then-mobile")
+        f.set_helptext('slug', "Used for NPM-compatible project name etc.")
+
+    def configure_form_fabric(self, f):
+
+        f.set_grouping([
+            ("Naming", [
+                'name',
+                'pkg_name',
+                'pypi_name',
+                'organization',
+            ]),
+            ("Theo", [
+                'integrates_with',
+            ]),
+        ])
+
+        # naming defaults
+        f.set_default('name', "Acme Fabric")
+        f.set_default('pkg_name', "acmefab")
+        f.set_default('pypi_name', "Acme-Fabric")
+
+        # organization
+        f.set_helptext('organization', 'For use with branding etc.')
+        f.set_default('organization', "Acme Foods")
+
+        # integrates_with
+        f.set_helptext('integrates_with', "Which POS system should Theo integrate with, if any")
+        f.set_enum('integrates_with', OrderedDict([
+            ('', "(nothing)"),
+            ('catapult', "ECRS Catapult"),
+            ('corepos', "CORE-POS"),
+            ('locsms', "LOC SMS")
+        ]))
+        f.set_default('integrates_with', '')
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._generated_project_defaults(config)
+
+    @classmethod
+    def _generated_project_defaults(cls, config):
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+
+        # generate project (accept custom params, truly create)
+        config.add_route('generate_specific_project',
+                         '{}/new/{{project_type}}'.format(url_prefix))
+        config.add_view(cls, attr='create',
+                        route_name='generate_specific_project',
+                        permission='{}.create'.format(permission_prefix))
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    GeneratedProjectView = kwargs.get('GeneratedProjectView', base['GeneratedProjectView'])
+    GeneratedProjectView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py
index fbb77bc9..e7bebdff 100644
--- a/tailbone/views/purchases/core.py
+++ b/tailbone/views/purchases/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for "true" purchase orders
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from webhelpers2.html import HTML, tags
@@ -42,14 +38,17 @@ class PurchaseView(MasterView):
     """
     model_class = model.Purchase
     creatable = False
-    editable = False
 
     has_rows = True
     model_row_class = model.PurchaseItem
     row_model_title = 'Purchase Item'
 
+    labels = {
+        'id': "ID",
+    }
+
     grid_columns = [
-        'store',
+        'id',
         'vendor',
         'department',
         'buyer',
@@ -62,13 +61,13 @@ class PurchaseView(MasterView):
     ]
 
     form_fields = [
+        'id',
         'store',
         'vendor',
         'department',
         'status',
         'buyer',
         'date_ordered',
-        'date_received',
         'po_number',
         'po_total',
         'ship_method',
@@ -76,6 +75,7 @@ class PurchaseView(MasterView):
         'invoice_date',
         'invoice_number',
         'invoice_total',
+        'date_received',
         'created',
         'created_by',
         'batches',
@@ -139,28 +139,38 @@ class PurchaseView(MasterView):
             if purchase.date_ordered:
                 return "{} (ordered {})".format(purchase.vendor, purchase.date_ordered.strftime('%Y-%m-%d'))
             return "{} (ordered)".format(purchase.vendor)
-        return six.text_type(purchase)
+        return str(purchase)
 
     def configure_grid(self, g):
-        super(PurchaseView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
-        g.joiners['store'] = lambda q: q.join(model.Store)
-        g.filters['store'] = g.make_filter('store', model.Store.name)
-        g.sorters['store'] = g.make_sorter(model.Store.name)
+        # store
+        g.set_joiner('store', lambda q: q.join(model.Store))
+        g.set_sorter('store', model.Store.name)
+        g.set_filter('store', model.Store.name)
 
-        g.joiners['vendor'] = lambda q: q.join(model.Vendor)
-        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
-                                            default_active=True, default_verb='contains')
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+        # vendor
+        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name,
+                     default_active=True,
+                     default_verb='contains')
 
-        g.joiners['department'] = lambda q: q.join(model.Department)
-        g.filters['department'] = g.make_filter('department', model.Department.name)
-        g.sorters['department'] = g.make_sorter(model.Department.name)
+        # department
+        g.set_joiner('department', lambda q: q.join(model.Department))
+        g.set_sorter('department', model.Department.name)
+        g.set_filter('department', model.Department.name)
 
-        g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person)
-        g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name,
-                                           default_active=True, default_verb='contains')
-        g.sorters['buyer'] = g.make_sorter(model.Person.display_name)
+        # buyer
+        g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person))
+        g.set_sorter('buyer', model.Person.display_name)
+        g.set_filter('buyer', model.Person.display_name,
+                     default_active=True,
+                     default_verb='contains')
+
+        # id
+        g.set_renderer('id', self.render_id_str)
 
         # date_ordered
         g.filters['date_ordered'].default_active = True
@@ -182,28 +192,68 @@ class PurchaseView(MasterView):
         g.set_type('po_total', 'currency')
         g.set_type('invoice_total', 'currency')
         g.set_label('invoice_number', "Invoice No.")
+
+        g.set_link('id')
+        g.set_link('vendor')
         g.set_link('date_ordered')
         g.set_link('po_total')
         g.set_link('date_received')
         g.set_link('invoice_total')
 
     def configure_form(self, f):
-        super(PurchaseView, self).configure_form(f)
+        super().configure_form(f)
+
+        # id
+        f.set_renderer('id', self.render_id_str)
+        f.set_readonly('id')
 
         f.set_renderer('store', self.render_store)
+
+        # vendor
         f.set_renderer('vendor', self.render_vendor)
+        f.set_readonly('vendor')
+
+        # department
         f.set_renderer('department', self.render_department)
 
+        # buyer
+        f.set_readonly('buyer')
+
+        # date_ordered
+        f.set_type('date_ordered', 'date_jquery')
+
+        # po_number
+        f.set_label('po_number', "PO Number")
+
+        # po_total
+        f.set_type('po_total', 'currency')
+        f.set_readonly('po_total')
+        f.set_label('po_total', "PO Total")
+
+        # notes_to_vendor
+        f.set_type('notes_to_vendor', 'text_wrapped')
+
+        # date_received
+        f.set_type('date_received', 'date_jquery')
+
+        # invoice_date
+        f.set_type('invoice_date', 'date_jquery')
+
+        # invoice_total
+        f.set_type('invoice_total', 'currency')
+        f.set_readonly('invoice_total')
+
+        # status
         f.set_readonly('status')
         f.set_enum('status', self.enum.PURCHASE_STATUS)
 
-        f.set_label('po_number', "PO Number")
-        f.set_label('po_total', "PO Total")
-        f.set_type('po_total', 'currency')
-
-        f.set_type('invoice_total', 'currency')
-
+        # batches
         f.set_renderer('batches', self.render_batches)
+        f.set_readonly('batches')
+
+        # created
+        f.set_readonly('created')
+        f.set_readonly('created_by')
 
         if self.viewing:
             purchase = f.model_instance
@@ -220,14 +270,6 @@ class PurchaseView(MasterView):
         url = self.request.route_url('stores.view', uuid=store.uuid)
         return tags.link_to(text, url)
 
-    def render_vendor(self, purchase, field):
-        vendor = purchase.vendor
-        if not vendor:
-            return ""
-        text = "({}) {}".format(vendor.id, vendor.name)
-        url = self.request.route_url('vendors.view', uuid=vendor.uuid)
-        return tags.link_to(text, url)
-
     def render_department(self, purchase, field):
         department = purchase.department
         if not department:
@@ -283,7 +325,7 @@ class PurchaseView(MasterView):
                       .filter(model.PurchaseItem.purchase == purchase)
 
     def configure_row_grid(self, g):
-        super(PurchaseView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_sort_defaults('sequence')
 
@@ -306,15 +348,26 @@ class PurchaseView(MasterView):
 
         purchase = self.get_instance()
         if purchase.status == self.enum.PURCHASE_STATUS_ORDERED:
-            g.hide_column('cases_received')
-            g.hide_column('units_received')
-            g.hide_column('invoice_total')
+            g.remove('cases_received',
+                     'units_received',
+                     'invoice_total')
         elif purchase.status in (self.enum.PURCHASE_STATUS_RECEIVED,
                                  self.enum.PURCHASE_STATUS_COSTED):
-            g.hide_column('po_total')
+            g.remove('po_total')
 
     def configure_row_form(self, f):
-        super(PurchaseView, self).configure_row_form(f)
+        super().configure_row_form(f)
+
+        # quantity fields
+        f.set_type('case_quantity', 'quantity')
+        f.set_type('cases_ordered', 'quantity')
+        f.set_type('units_ordered', 'quantity')
+        f.set_type('cases_received', 'quantity')
+        f.set_type('units_received', 'quantity')
+        f.set_type('cases_damaged', 'quantity')
+        f.set_type('units_damaged', 'quantity')
+        f.set_type('cases_expired', 'quantity')
+        f.set_type('units_expired', 'quantity')
 
         # currency fields
         f.set_type('po_unit_cost', 'currency')
@@ -339,22 +392,31 @@ class PurchaseView(MasterView):
 
     @classmethod
     def defaults(cls, config):
+        cls._purchase_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _purchase_defaults(cls, config):
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
         permission_prefix = cls.get_permission_prefix()
         model_key = cls.get_model_key()
         model_title = cls.get_model_title()
 
-        cls._defaults(config)
-
         # receiving worksheet
         config.add_tailbone_permission(permission_prefix, '{}.receiving_worksheet'.format(permission_prefix),
                                        "Print receiving worksheet for {}".format(model_title))
         config.add_route('{}.receiving_worksheet'.format(route_prefix), '{}/{{{}}}/receiving-worksheet'.format(url_prefix, model_key))
         config.add_view(cls, attr='receiving_worksheet', route_name='{}.receiving_worksheet'.format(route_prefix),
                         permission='{}.receiving_worksheet'.format(permission_prefix))
-        
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PurchaseView = kwargs.get('PurchaseView', base['PurchaseView'])
+    PurchaseView.defaults(config)
 
 
 def includeme(config):
-    PurchaseView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py
index dafe5d5e..7da096eb 100644
--- a/tailbone/views/purchases/credits.py
+++ b/tailbone/views/purchases/credits.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for "true" purchase credits
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from webhelpers2.html import tags
@@ -100,10 +96,12 @@ class PurchaseCreditView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(PurchaseCreditView, self).configure_grid(g)
+        super().configure_grid(g)
 
+        # vendor
         g.set_joiner('vendor', lambda q: q.outerjoin(model.Vendor))
         g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
 
         g.set_sort_defaults('date_received', 'desc')
 
@@ -112,7 +110,7 @@ class PurchaseCreditView(MasterView):
         g.filters['status'].default_active = True
         g.filters['status'].default_verb = 'not_equal'
         # TODO: should not have to convert value to string!
-        g.filters['status'].default_value = six.text_type(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED)
+        g.filters['status'].default_value = str(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED)
 
         # g.set_type('upc', 'gpc')
         g.set_type('cases_shorted', 'quantity')
@@ -154,7 +152,7 @@ class PurchaseCreditView(MasterView):
         for uuid in self.request.POST.get('uuids', '').split(','):
             uuid = uuid.strip()
             if uuid:
-                credit = self.Session.query(model.PurchaseCredit).get(uuid)
+                credit = self.Session.get(model.PurchaseCredit, uuid)
                 if credit:
                     credits_.append(credit)
         if not credits_:
@@ -175,16 +173,26 @@ class PurchaseCreditView(MasterView):
     def status_options(self):
         options = []
         for value in sorted(self.enum.PURCHASE_CREDIT_STATUS):
-            options.append(tags.Option(self.enum.PURCHASE_CREDIT_STATUS[value], value))
+            options.append({
+                'value': value,
+                'label': self.enum.PURCHASE_CREDIT_STATUS[value]})
         return options
 
     @classmethod
     def defaults(cls, config):
+        cls._purchase_credit_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _purchase_credit_defaults(cls, config):
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
         permission_prefix = cls.get_permission_prefix()
         model_title_plural = cls.get_model_title_plural()
 
+        # fix perm group name
+        config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
+
         # change status
         config.add_tailbone_permission(permission_prefix, '{}.change_status'.format(permission_prefix),
                                        "Change status for {}".format(model_title_plural))
@@ -192,8 +200,13 @@ class PurchaseCreditView(MasterView):
         config.add_view(cls, attr='change_status', route_name='{}.change_status'.format(route_prefix),
                         permission='{}.change_status'.format(permission_prefix))
 
-        cls._defaults(config)
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    PurchaseCreditView = kwargs.get('PurchaseCreditView', base['PurchaseCreditView'])
+    PurchaseCreditView.defaults(config)
 
 
 def includeme(config):
-    PurchaseCreditView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py
index 8f80b456..09d62909 100644
--- a/tailbone/views/purchasing/__init__.py
+++ b/tailbone/views/purchasing/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2021 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -32,3 +32,4 @@ from .batch import PurchasingBatchView
 def includeme(config):
     config.include('tailbone.views.purchasing.ordering')
     config.include('tailbone.views.purchasing.receiving')
+    config.include('tailbone.views.purchasing.costing')
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 4f7a14e6..5e00704e 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,19 +24,15 @@
 Base class for purchasing batch views
 """
 
-from __future__ import unicode_literals, absolute_import
+import warnings
 
-import six
-
-from rattail.db import model, api
-from rattail.time import localtime
+from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 import colander
 from deform import widget as dfwidget
-from pyramid import httpexceptions
 from webhelpers2.html import tags, HTML
 
-from tailbone import forms, grids
+from tailbone import forms
 from tailbone.views.batch import BatchMasterView
 
 
@@ -45,8 +41,8 @@ class PurchasingBatchView(BatchMasterView):
     Master view base class, for purchase batches.  The views for both
     "ordering" and "receiving" batches will inherit from this.
     """
-    model_class = model.PurchaseBatch
-    model_row_class = model.PurchaseBatchRow
+    model_class = PurchaseBatch
+    model_row_class = PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     supports_new_product = False
     cloneable = True
@@ -73,6 +69,8 @@ class PurchasingBatchView(BatchMasterView):
         'store',
         'buyer',
         'vendor',
+        'description',
+        'workflow',
         'department',
         'purchase',
         'vendor_email',
@@ -99,8 +97,10 @@ class PurchasingBatchView(BatchMasterView):
         'upc': "UPC",
         'item_id': "Item ID",
         'brand_name': "Brand",
+        'case_quantity': "Case Size",
         'po_line_number': "PO Line Number",
         'po_unit_cost': "PO Unit Cost",
+        'po_case_size': "PO Case Size",
         'po_total': "PO Total",
     }
 
@@ -129,16 +129,24 @@ class PurchasingBatchView(BatchMasterView):
         'description',
         'size',
         'case_quantity',
+        'ordered',
         'cases_ordered',
         'units_ordered',
+        'received',
         'cases_received',
         'units_received',
+        'damaged',
         'cases_damaged',
         'units_damaged',
+        'expired',
         'cases_expired',
         'units_expired',
+        'mispick',
         'cases_mispick',
         'units_mispick',
+        'missing',
+        'cases_missing',
+        'units_missing',
         'po_line_number',
         'po_unit_cost',
         'po_total',
@@ -150,62 +158,209 @@ class PurchasingBatchView(BatchMasterView):
         'credits',
     ]
 
-    mobile_row_form_fields = [
-        'upc',
-        'item_id',
-        'product',
-        'brand_name',
-        'description',
-        'size',
-        'case_quantity',
-        'cases_ordered',
-        'units_ordered',
-        'cases_received',
-        'units_received',
-        'cases_damaged',
-        'units_damaged',
-        'cases_expired',
-        'units_expired',
-        'cases_mispick',
-        'units_mispick',
-        # 'po_line_number',
-        'po_unit_cost',
-        'po_total',
-        # 'invoice_line_number',
-        'invoice_unit_cost',
-        'invoice_total',
-        'status_code',
-        # 'credits',
-    ]
-
     @property
     def batch_mode(self):
         raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
 
+    def get_supported_workflows(self):
+        """
+        Return the supported "create batch" workflows.
+        """
+        enum = self.app.enum
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.supported_ordering_workflows()
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            return self.batch_handler.supported_receiving_workflows()
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
+            return self.batch_handler.supported_costing_workflows()
+        raise ValueError("unknown batch mode")
+
+    def allow_any_vendor(self):
+        """
+        Return boolean indicating whether creating a batch for "any"
+        vendor is allowed, vs. only supported vendors.
+        """
+        enum = self.app.enum
+
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.allow_ordering_any_vendor()
+
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor')
+            if value is not None:
+                return value
+            value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only')
+            if value is not None:
+                warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; "
+                              "please use rattail.batch.purchase.allow_receiving_any_vendor instead",
+                              DeprecationWarning)
+                # nb. must negate this setting
+                return not value
+            return False
+
+        raise ValueError("unknown batch mode")
+
+    def get_supported_vendors(self):
+        """
+        Return the supported vendors for creating a batch.
+        """
+        return []
+
+    def create(self, form=None, **kwargs):
+        """
+        Custom view for creating a new batch.  We split the process
+        into two steps, 1) choose workflow and 2) create batch.  This
+        is because the specific form details for creating a batch will
+        depend on which "type" of batch creation is to be done, and
+        it's much easier to keep conditional logic for that in the
+        server instead of client-side etc.
+        """
+        model = self.app.model
+        enum = self.app.enum
+        route_prefix = self.get_route_prefix()
+
+        workflows = self.get_supported_workflows()
+        valid_workflows = [workflow['workflow_key']
+                           for workflow in workflows]
+
+        # if user has already identified their desired workflow, then
+        # we can just farm out to the default logic.  we will of
+        # course configure our form differently, based on workflow,
+        # but this create() method at least will not need
+        # customization for that.
+        if self.request.matched_route.name.endswith('create_workflow'):
+
+            redirect = self.redirect(self.request.route_url(f'{route_prefix}.create'))
+
+            # however we do have one more thing to check - the workflow
+            # requested must of course be valid!
+            workflow_key = self.request.matchdict['workflow_key']
+            if workflow_key not in valid_workflows:
+                self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error')
+                raise redirect
+
+            # also, we require vendor to be correctly identified.  if
+            # someone e.g. navigates to a URL by accident etc. we want
+            # to gracefully handle and redirect
+            uuid = self.request.matchdict['vendor_uuid']
+            vendor = self.Session.get(model.Vendor, uuid)
+            if not vendor:
+                self.request.session.flash("Invalid vendor selection.  "
+                                           "Please choose an existing vendor.",
+                                           'warning')
+                raise redirect
+
+            # okay now do the normal thing, per workflow
+            return super().create(**kwargs)
+
+        # on the other hand, if caller provided a form, that means we are in
+        # the middle of some other custom workflow, e.g. "add child to truck
+        # dump parent" or some such.  in which case we also defer to the normal
+        # logic, so as to not interfere with that.
+        if form:
+            return super().create(form=form, **kwargs)
+
+        # okay, at this point we need the user to select a vendor and workflow
+        self.creating = True
+        context = {}
+
+        # form to accept user choice of vendor/workflow
+        schema = colander.Schema()
+        schema.add(colander.SchemaNode(colander.String(), name='vendor'))
+        schema.add(colander.SchemaNode(colander.String(), name='workflow',
+                                       validator=colander.OneOf(valid_workflows)))
+        factory = self.get_form_factory()
+        form = factory(schema=schema, request=self.request)
+
+        # configure vendor field
+        vendor_handler = self.app.get_vendor_handler()
+        if self.allow_any_vendor():
+            # user may choose *any* available vendor
+            use_dropdown = vendor_handler.choice_uses_dropdown()
+            if use_dropdown:
+                vendors = self.Session.query(model.Vendor)\
+                                      .order_by(model.Vendor.id)\
+                                      .all()
+                vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}")
+                                 for vendor in vendors]
+                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+                if len(vendors) == 1:
+                    form.set_default('vendor', vendors[0].uuid)
+            else:
+                vendor_display = ""
+                if self.request.method == 'POST':
+                    if self.request.POST.get('vendor'):
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
+                        if vendor:
+                            vendor_display = str(vendor)
+                vendors_url = self.request.route_url('vendors.autocomplete')
+                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
+                    field_display=vendor_display, service_url=vendors_url))
+        else: # only "supported" vendors allowed
+            vendors = self.get_supported_vendors()
+            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
+                             for vendor in vendors]
+            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+        form.set_validator('vendor', self.valid_vendor_uuid)
+
+        # configure workflow field
+        values = [(workflow['workflow_key'], workflow['display'])
+                  for workflow in workflows]
+        form.set_widget('workflow',
+                        dfwidget.SelectWidget(values=values))
+        if len(workflows) == 1:
+            form.set_default('workflow', workflows[0]['workflow_key'])
+
+        form.submit_label = "Continue"
+        form.cancel_url = self.get_index_url()
+
+        # if form validates, that means user has chosen a creation
+        # type, so we just redirect to the appropriate "new batch of
+        # type X" page
+        if form.validate():
+            workflow_key = form.validated['workflow']
+            vendor_uuid = form.validated['vendor']
+            url = self.request.route_url(f'{route_prefix}.create_workflow',
+                                         workflow_key=workflow_key,
+                                         vendor_uuid=vendor_uuid)
+            raise self.redirect(url)
+
+        context['form'] = form
+        if hasattr(form, 'make_deform_form'):
+            context['dform'] = form.make_deform_form()
+        return self.render_to_response('create', context)
+
     def query(self, session):
+        model = self.model
         return session.query(model.PurchaseBatch)\
                       .filter(model.PurchaseBatch.mode == self.batch_mode)
 
     def configure_grid(self, g):
-        super(PurchasingBatchView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
-        g.joiners['vendor'] = lambda q: q.join(model.Vendor)
-        g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
-                                            default_active=True, default_verb='contains')
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
+        # vendor
+        g.set_link('vendor')
+        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name,
+                     default_active=True, default_verb='contains')
 
-        g.joiners['department'] = lambda q: q.join(model.Department)
-        g.filters['department'] = g.make_filter('department', model.Department.name)
-        g.sorters['department'] = g.make_sorter(model.Department.name)
+        # department
+        g.set_joiner('department', lambda q: q.join(model.Department))
+        g.set_filter('department', model.Department.name)
+        g.set_sorter('department', model.Department.name)
 
-        g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person)
-        g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name,
-                                           default_active=True, default_verb='contains')
-        g.sorters['buyer'] = g.make_sorter(model.Person.display_name)
+        g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person))
+        g.set_filter('buyer', model.Person.display_name)
+        g.set_sorter('buyer', model.Person.display_name)
 
-        if self.request.has_perm('{}.execute'.format(self.get_permission_prefix())):
-            g.filters['complete'].default_active = True
-            g.filters['complete'].default_verb = 'is_true'
+        # TODO: we used to include the 'complete' filter by default, but it
+        # seems to likely be confusing for newcomers, so it is no longer
+        # default.  not sure if there are any other implications...?
+        # if self.request.has_perm('{}.execute'.format(self.get_permission_prefix())):
+        #     g.filters['complete'].default_active = True
+        #     g.filters['complete'].default_verb = 'is_true'
 
         # invoice_total
         g.set_type('invoice_total', 'currency')
@@ -229,7 +384,7 @@ class PurchasingBatchView(BatchMasterView):
 #         return form
 
     def configure_common_form(self, f):
-        super(PurchasingBatchView, self).configure_common_form(f)
+        super().configure_common_form(f)
 
         # po_total
         if self.creating:
@@ -242,19 +397,41 @@ class PurchasingBatchView(BatchMasterView):
             f.set_type('po_total_calculated', 'currency')
 
     def configure_form(self, f):
-        super(PurchasingBatchView, self).configure_form(f)
+        super().configure_form(f)
+        model = self.app.model
+        enum = self.app.enum
+        route_prefix = self.get_route_prefix()
+
+        today = self.app.today()
         batch = f.model_instance
-        today = localtime(self.rattail_config).date()
+        workflow = self.request.matchdict.get('workflow_key')
+        vendor_handler = self.app.get_vendor_handler()
 
         # mode
-        f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE)
+        f.set_enum('mode', enum.PURCHASE_BATCH_MODE)
+
+        # workflow
+        if self.creating:
+            if workflow:
+                f.set_widget('workflow', dfwidget.HiddenWidget())
+                f.set_default('workflow', workflow)
+                f.set_hidden('workflow')
+                # nb. show readonly '_workflow'
+                f.insert_after('workflow', '_workflow')
+                f.set_readonly('_workflow')
+                f.set_renderer('_workflow', self.render_workflow)
+            else:
+                f.set_readonly('workflow')
+                f.set_renderer('workflow', self.render_workflow)
+        else:
+            f.remove('workflow')
 
         # store
-        single_store = self.rattail_config.single_store()
+        single_store = self.config.single_store()
         if self.creating:
             f.replace('store', 'store_uuid')
             if single_store:
-                store = self.rattail_config.get_store(self.Session())
+                store = self.config.get_store(self.Session())
                 f.set_widget('store_uuid', dfwidget.HiddenWidget())
                 f.set_default('store_uuid', store.uuid)
                 f.set_hidden('store_uuid')
@@ -278,26 +455,24 @@ class PurchasingBatchView(BatchMasterView):
         if self.creating:
             f.replace('vendor', 'vendor_uuid')
             f.set_label('vendor_uuid', "Vendor")
-            widget_type = self.rattail_config.get('tailbone', 'default_widget.vendor',
-                                                  default='autocomplete')
-            if widget_type == 'autocomplete':
-                vendor_display = ""
-                if self.request.method == 'POST':
-                    if self.request.POST.get('vendor_uuid'):
-                        vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor_uuid'])
-                        if vendor:
-                            vendor_display = six.text_type(vendor)
-                vendors_url = self.request.route_url('vendors.autocomplete')
-                f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=vendor_display, service_url=vendors_url))
-            elif widget_type == 'dropdown':
+            use_dropdown = vendor_handler.choice_uses_dropdown()
+            if use_dropdown:
                 vendors = self.Session.query(model.Vendor)\
                                       .order_by(model.Vendor.id)
                 vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
                                  for vendor in vendors]
                 f.set_widget('vendor_uuid', dfwidget.SelectWidget(values=vendor_values))
             else:
-                raise NotImplementedError("Unsupported vendor widget type: {}".format(widget_type))
+                vendor_display = ""
+                if self.request.method == 'POST':
+                    if self.request.POST.get('vendor_uuid'):
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid'])
+                        if vendor:
+                            vendor_display = str(vendor)
+                vendors_url = self.request.route_url('vendors.autocomplete')
+                f.set_widget('vendor_uuid', forms.widgets.JQueryAutocompleteWidget(
+                    field_display=vendor_display, service_url=vendors_url))
+            f.set_validator('vendor_uuid', self.valid_vendor_uuid)
         elif self.editing:
             f.set_readonly('vendor')
 
@@ -325,21 +500,71 @@ class PurchasingBatchView(BatchMasterView):
                 buyer_display = ""
                 if self.request.method == 'POST':
                     if self.request.POST.get('buyer_uuid'):
-                        buyer = self.Session.query(model.Employee).get(self.request.POST['buyer_uuid'])
+                        buyer = self.Session.get(model.Employee, self.request.POST['buyer_uuid'])
                         if buyer:
-                            buyer_display = six.text_type(buyer)
+                            buyer_display = str(buyer)
                 elif self.creating:
-                    buyer = self.request.user.employee
+                    buyer = self.app.get_employee(self.request.user)
                     if buyer:
-                        buyer_display = six.text_type(buyer)
+                        buyer_display = str(buyer)
                         f.set_default('buyer_uuid', buyer.uuid)
                 elif self.editing:
-                    buyer_display = six.text_type(batch.buyer or '')
+                    buyer_display = str(batch.buyer or '')
                 buyers_url = self.request.route_url('employees.autocomplete')
                 f.set_widget('buyer_uuid', forms.widgets.JQueryAutocompleteWidget(
                     field_display=buyer_display, service_url=buyers_url))
                 f.set_label('buyer_uuid', "Buyer")
 
+        # order_file
+        if self.creating:
+            f.set_type('order_file', 'file', required=False)
+        else:
+            f.set_readonly('order_file')
+            f.set_renderer('order_file', self.render_downloadable_file)
+
+        # order_parser_key
+        if self.creating:
+            kwargs = {}
+            if 'vendor_uuid' in self.request.matchdict:
+                vendor = self.Session.get(model.Vendor,
+                                          self.request.matchdict['vendor_uuid'])
+                if vendor:
+                    kwargs['vendor'] = vendor
+            parsers = vendor_handler.get_supported_order_parsers(**kwargs)
+            parser_values = [(p.key, p.title) for p in parsers]
+            if len(parsers) == 1:
+                f.set_default('order_parser_key', parsers[0].key)
+            f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values))
+            f.set_label('order_parser_key', "Order Parser")
+        else:
+            f.remove_field('order_parser_key')
+
+        # invoice_file
+        if self.creating:
+            f.set_type('invoice_file', 'file', required=False)
+        else:
+            f.set_readonly('invoice_file')
+            f.set_renderer('invoice_file', self.render_downloadable_file)
+
+        # invoice_parser_key
+        if self.creating:
+            kwargs = {}
+
+            if 'vendor_uuid' in self.request.matchdict:
+                vendor = self.Session.get(model.Vendor,
+                                          self.request.matchdict['vendor_uuid'])
+                if vendor:
+                    kwargs['vendor'] = vendor
+
+            parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs)
+            parser_values = [(p.key, p.display) for p in parsers]
+            if len(parsers) == 1:
+                f.set_default('invoice_parser_key', parsers[0].key)
+
+            f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values))
+        else:
+            f.remove_field('invoice_parser_key')
+
         # date_ordered
         f.set_type('date_ordered', 'date_jquery')
         if self.creating:
@@ -390,6 +615,35 @@ class PurchasingBatchView(BatchMasterView):
                             'vendor_contact',
                             'status_code')
 
+        # tweak some things if we are in "step 2" of creating new batch
+        if self.creating and workflow:
+
+            # display vendor but do not allow changing
+            vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid'])
+            if not vendor:
+                raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}")
+            f.set_readonly('vendor_uuid')
+            f.set_default('vendor_uuid', str(vendor))
+
+            # cancel should take us back to choosing a workflow
+            f.cancel_url = self.request.route_url(f'{route_prefix}.create')
+
+    def render_workflow(self, batch, field):
+        key = self.request.matchdict['workflow_key']
+        info = self.get_workflow_info(key)
+        if info:
+            return info['display']
+
+    def get_workflow_info(self, key):
+        enum = self.app.enum
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.ordering_workflow_info(key)
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            return self.batch_handler.receiving_workflow_info(key)
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
+            return self.batch_handler.costing_workflow_info(key)
+        raise ValueError("unknown batch mode")
+
     def render_store(self, batch, field):
         store = batch.store
         if not store:
@@ -399,20 +653,30 @@ class PurchasingBatchView(BatchMasterView):
         return tags.link_to(text, url)
 
     def render_purchase(self, batch, field):
-        purchase = batch.purchase
-        if not purchase:
-            return ""
-        text = six.text_type(purchase)
-        url = self.request.route_url('purchases.view', uuid=purchase.uuid)
-        return tags.link_to(text, url)
+        model = self.model
 
-    def render_vendor(self, batch, field):
-        vendor = batch.vendor
-        if not vendor:
-            return ""
-        text = "({}) {}".format(vendor.id, vendor.name)
-        url = self.request.route_url('vendors.view', uuid=vendor.uuid)
-        return tags.link_to(text, url)
+        # default logic can only render the "normal" (built-in)
+        # purchase field; anything else must be handled by view
+        # supplement if possible
+        if field != 'purchase':
+            for supp in self.iter_view_supplements():
+                renderer = getattr(supp, f'render_purchase_{field}', None)
+                if renderer:
+                    return renderer(batch)
+
+        # nothing to render if no purchase found
+        purchase = getattr(batch, field)
+        if not purchase:
+            return
+
+        # render link to native purchase, if possible
+        text = str(purchase)
+        if isinstance(purchase, model.Purchase):
+            url = self.request.route_url('purchases.view', uuid=purchase.uuid)
+            return tags.link_to(text, url)
+
+        # otherwise just render purchase as-is
+        return text
 
     def render_vendor_email(self, batch, field):
         if batch.vendor.email:
@@ -423,7 +687,7 @@ class PurchasingBatchView(BatchMasterView):
 
     def render_vendor_contact(self, batch, field):
         if batch.vendor.contact:
-            return six.text_type(batch.vendor.contact)
+            return str(batch.vendor.contact)
 
     def render_vendor_phone(self, batch, field):
         return self.get_vendor_phone_number(batch)
@@ -443,19 +707,21 @@ class PurchasingBatchView(BatchMasterView):
         employee = batch.buyer
         if not employee:
             return ""
-        text = six.text_type(employee)
+        text = str(employee)
         if self.request.has_perm('employees.view'):
             url = self.request.route_url('employees.view', uuid=employee.uuid)
             return tags.link_to(text, url)
         return text
 
     def get_store_values(self):
+        model = self.model
         stores = self.Session.query(model.Store)\
                              .order_by(model.Store.id)
         return [(s.uuid, "({}) {}".format(s.id, s.name))
                 for s in stores]
 
     def get_vendors(self):
+        model = self.model
         return self.Session.query(model.Vendor)\
                            .order_by(model.Vendor.name)
 
@@ -464,12 +730,8 @@ class PurchasingBatchView(BatchMasterView):
         return [(v.uuid, "({}) {}".format(v.id, v.name))
                 for v in vendors]
 
-    def get_vendor_values(self):
-        vendors = self.get_vendors()
-        return [(v.uuid, "({}) {}".format(v.id, v.name))
-                for v in vendors]
-
     def get_buyers(self):
+        model = self.model
         return self.Session.query(model.Employee)\
                            .join(model.Person)\
                            .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)\
@@ -477,10 +739,11 @@ class PurchasingBatchView(BatchMasterView):
 
     def get_buyer_values(self):
         buyers = self.get_buyers()
-        return [(b.uuid, six.text_type(b))
+        return [(b.uuid, str(b))
                 for b in buyers]
 
     def get_department_options(self):
+        model = self.model
         departments = self.Session.query(model.Department).order_by(model.Department.number)
         return [('{} {}'.format(d.number, d.name), d.uuid) for d in departments]
 
@@ -494,41 +757,14 @@ class PurchasingBatchView(BatchMasterView):
             if phone.type == 'Fax':
                 return phone.number
 
-    def eligible_purchases(self, vendor_uuid=None, mode=None):
-        if not vendor_uuid:
-            vendor_uuid = self.request.GET.get('vendor_uuid')
-        vendor = self.Session.query(model.Vendor).get(vendor_uuid) if vendor_uuid else None
-        if not vendor:
-            return {'error': "Must specify a vendor."}
+    def get_batch_kwargs(self, batch, **kwargs):
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
+        model = self.app.model
 
-        if mode is None:
-            mode = self.request.GET.get('mode')
-            mode = int(mode) if mode and mode.isdigit() else None
-        if not mode or mode not in self.enum.PURCHASE_BATCH_MODE:
-            return {'error': "Unknown mode: {}".format(mode)}
-
-        purchases = self.handler.get_eligible_purchases(vendor, mode)
-        return self.get_eligible_purchases_data(purchases)
-
-    def get_eligible_purchases_data(self, purchases):
-        return {'purchases': [{'key': p.uuid,
-                               'department_uuid': p.department_uuid or '',
-                               'display': self.render_eligible_purchase(p)}
-                              for p in purchases]}
-
-    def render_eligible_purchase(self, purchase):
-        if purchase.status == self.enum.PURCHASE_STATUS_ORDERED:
-            date = purchase.date_ordered
-            total = purchase.po_total
-        elif purchase.status == self.enum.PURCHASE_STATUS_RECEIVED:
-            date = purchase.date_received
-            total = purchase.invoice_total
-        return '{} for ${:0,.2f} ({})'.format(date, total or 0, purchase.department or purchase.buyer)
-
-    def get_batch_kwargs(self, batch, mobile=False):
-        kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
         kwargs['mode'] = self.batch_mode
+        kwargs['workflow'] = self.request.POST['workflow']
         kwargs['truck_dump'] = batch.truck_dump
+        kwargs['order_parser_key'] = batch.order_parser_key
         kwargs['invoice_parser_key'] = batch.invoice_parser_key
 
         if batch.store:
@@ -546,6 +782,11 @@ class PurchasingBatchView(BatchMasterView):
         elif batch.vendor_uuid:
             kwargs['vendor_uuid'] = batch.vendor_uuid
 
+        # must pull vendor from URL if it was not in form data
+        if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
+            if 'vendor_uuid' in self.request.matchdict:
+                kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
+
         if batch.department:
             kwargs['department'] = batch.department
         elif batch.department_uuid:
@@ -572,16 +813,20 @@ class PurchasingBatchView(BatchMasterView):
 
         if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
                                self.enum.PURCHASE_BATCH_MODE_COSTING):
-            purchase = batch.purchase
-            if not purchase and batch.purchase_uuid:
-                purchase = self.Session.query(model.Purchase).get(batch.purchase_uuid)
-                assert purchase
-            if purchase:
-                kwargs['purchase'] = purchase
-                kwargs['buyer'] = purchase.buyer
-                kwargs['buyer_uuid'] = purchase.buyer_uuid
-                kwargs['date_ordered'] = purchase.date_ordered
-                kwargs['po_total'] = purchase.po_total
+            field = self.batch_handler.get_purchase_order_fieldname()
+            if field == 'purchase':
+                purchase = batch.purchase
+                if not purchase and batch.purchase_uuid:
+                    purchase = self.Session.get(model.Purchase, batch.purchase_uuid)
+                    assert purchase
+                if purchase:
+                    kwargs['purchase'] = purchase
+                    kwargs['buyer'] = purchase.buyer
+                    kwargs['buyer_uuid'] = purchase.buyer_uuid
+                    kwargs['date_ordered'] = purchase.date_ordered
+                    kwargs['po_total'] = purchase.po_total
+            elif hasattr(batch, field):
+                kwargs[field] = getattr(batch, field)
 
         return kwargs
 
@@ -603,11 +848,8 @@ class PurchasingBatchView(BatchMasterView):
 #         query = super(PurchasingBatchView, self).get_row_data(batch)
 #         return query.options(orm.joinedload(model.PurchaseBatchRow.credits))
 
-    def sort_mobile_row_data(self, query):
-        return query.order_by(model.PurchaseBatchRow.modified.desc())
-
     def configure_row_grid(self, g):
-        super(PurchasingBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_type('upc', 'gpc')
         g.set_type('cases_ordered', 'quantity')
@@ -620,8 +862,11 @@ class PurchasingBatchView(BatchMasterView):
         g.set_type('po_total_calculated', 'currency')
         g.set_type('credits', 'boolean')
 
-        # we only want the grid column to have abbreviated label, but *not* the filter
+        # we only want the grid columns to have abbreviated labels,
+        # but *not* the filters
         # TODO: would be nice to somehow make this simpler
+        g.set_label('department_name', "Department")
+        g.filters['department_name'].label = "Department Name"
         g.set_label('cases_ordered', "Cases Ord.")
         g.filters['cases_ordered'].label = "Cases Ordered"
         g.set_label('units_ordered', "Units Ord.")
@@ -635,6 +880,21 @@ class PurchasingBatchView(BatchMasterView):
         g.set_label('units_received', "Units Rec.")
         g.filters['units_received'].label = "Units Received"
 
+        # catalog_unit_cost
+        g.set_renderer('catalog_unit_cost', self.render_row_grid_cost)
+        g.set_label('catalog_unit_cost', "Catalog Cost")
+        g.filters['catalog_unit_cost'].label = "Catalog Unit Cost"
+
+        # po_unit_cost
+        g.set_renderer('po_unit_cost', self.render_row_grid_cost)
+        g.set_label('po_unit_cost', "PO Cost")
+        g.filters['po_unit_cost'].label = "PO Unit Cost"
+
+        # invoice_unit_cost
+        g.set_renderer('invoice_unit_cost', self.render_row_grid_cost)
+        g.set_label('invoice_unit_cost', "Invoice Cost")
+        g.filters['invoice_unit_cost'].label = "Invoice Unit Cost"
+
         # invoice_total
         g.set_type('invoice_total', 'currency')
         g.set_label('invoice_total', "Total")
@@ -646,6 +906,16 @@ class PurchasingBatchView(BatchMasterView):
         g.set_label('po_total', "Total")
         g.set_label('credits', "Credits?")
 
+        g.set_link('upc')
+        g.set_link('vendor_code')
+        g.set_link('description')
+
+    def render_row_grid_cost(self, row, field):
+        cost = getattr(row, field)
+        if cost is None:
+            return ""
+        return "{:0,.3f}".format(cost)
+
     def make_row_grid_tools(self, batch):
         return self.make_default_row_grid_tools(batch)
 
@@ -658,11 +928,15 @@ class PurchasingBatchView(BatchMasterView):
                                row.STATUS_ORDERED_RECEIVED_DIFFER,
                                row.STATUS_TRUCKDUMP_UNCLAIMED,
                                row.STATUS_TRUCKDUMP_PARTCLAIMED,
-                               row.STATUS_OUT_OF_STOCK):
+                               row.STATUS_OUT_OF_STOCK,
+                               row.STATUS_ON_PO_NOT_INVOICE,
+                               row.STATUS_ON_INVOICE_NOT_PO,
+                               row.STATUS_COST_INCREASE,
+                               row.STATUS_DID_NOT_RECEIVE):
             return 'notice'
 
     def configure_row_form(self, f):
-        super(PurchasingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
         row = f.model_instance
         if self.creating:
             batch = self.get_instance()
@@ -673,7 +947,17 @@ class PurchasingBatchView(BatchMasterView):
         f.set_readonly('case_quantity')
 
         # quantity fields
+        f.set_renderer('ordered', self.render_row_quantity)
+        f.set_renderer('shipped', self.render_row_quantity)
+        f.set_renderer('received', self.render_row_quantity)
+        f.set_renderer('damaged', self.render_row_quantity)
+        f.set_renderer('expired', self.render_row_quantity)
+        f.set_renderer('mispick', self.render_row_quantity)
+        f.set_renderer('missing', self.render_row_quantity)
+
         f.set_type('case_quantity', 'quantity')
+        f.set_type('po_case_size', 'quantity')
+        f.set_type('invoice_case_size', 'quantity')
         f.set_type('cases_ordered', 'quantity')
         f.set_type('units_ordered', 'quantity')
         f.set_type('cases_shipped', 'quantity')
@@ -686,12 +970,14 @@ class PurchasingBatchView(BatchMasterView):
         f.set_type('units_expired', 'quantity')
         f.set_type('cases_mispick', 'quantity')
         f.set_type('units_mispick', 'quantity')
+        f.set_type('cases_missing', 'quantity')
+        f.set_type('units_missing', 'quantity')
 
         # currency fields
-        f.set_type('po_unit_cost', 'currency')
+        # nb. we only show "total" fields as currency, but not case or
+        # unit cost fields, b/c currency is rounded to 2 places
         f.set_type('po_total', 'currency')
         f.set_type('po_total_calculated', 'currency')
-        f.set_type('invoice_unit_cost', 'currency')
 
         # upc
         f.set_type('upc', 'gpc')
@@ -708,7 +994,8 @@ class PurchasingBatchView(BatchMasterView):
 
         # credits
         f.set_readonly('credits')
-        f.set_renderer('credits', self.render_row_credits)
+        if self.viewing:
+            f.set_renderer('credits', self.render_row_credits)
 
         if self.creating:
             f.remove_fields(
@@ -744,145 +1031,53 @@ class PurchasingBatchView(BatchMasterView):
             else:
                 f.remove_field('product')
 
-    def render_row_credits(self, row, field):
-        if not row.credits:
-            return ""
+    def render_row_quantity(self, row, field):
+        app = self.get_rattail_app()
+        cases = getattr(row, 'cases_{}'.format(field))
+        units = getattr(row, 'units_{}'.format(field))
+        # nb. do not render anything if empty quantities
+        if cases or units:
+            return app.render_cases_units(cases, units)
 
+    def make_row_credits_grid(self, row):
         route_prefix = self.get_route_prefix()
-        columns = [
-            'credit_type',
-            'cases_shorted',
-            'units_shorted',
-            'credit_total',
-        ]
-        g = grids.Grid(
-            key='{}.row_credits'.format(route_prefix),
-            data=row.credits,
-            columns=columns,
-            labels={'credit_type': "Type",
-                    'cases_shorted': "Cases",
-                    'units_shorted': "Units"})
-        g.set_type('cases_shorted', 'quantity')
-        g.set_type('units_shorted', 'quantity')
+        factory = self.get_grid_factory()
+
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.row_credits',
+            data=[],
+            columns=[
+                'credit_type',
+                'shorted',
+                'credit_total',
+                'expiration_date',
+                # 'mispick_upc',
+                # 'mispick_brand_name',
+                # 'mispick_description',
+                # 'mispick_size',
+            ],
+            labels={
+                'credit_type': "Type",
+                'shorted': "Quantity",
+                'credit_total': "Total",
+                # 'mispick_upc': "Mispick UPC",
+                # 'mispick_brand_name': "MP Brand",
+                # 'mispick_description': "MP Description",
+                # 'mispick_size': "MP Size",
+            })
+
         g.set_type('credit_total', 'currency')
-        return HTML.literal(g.render_grid())
 
-    def configure_mobile_row_form(self, f):
-        super(PurchasingBatchView, self).configure_mobile_row_form(f)
-        # row = f.model_instance
-        # if self.creating:
-        #     batch = self.get_instance()
-        # else:
-        #     batch = self.get_parent(row)
+        if not self.batch_handler.allow_expired_credits():
+            g.remove('expiration_date')
 
-        # # readonly fields
-        # f.set_readonly('case_quantity')
-        # f.set_readonly('credits')
+        return g
 
-        # quantity fields
-        f.set_type('case_quantity', 'quantity')
-        f.set_type('cases_ordered', 'quantity')
-        f.set_type('units_ordered', 'quantity')
-        f.set_type('cases_received', 'quantity')
-        f.set_type('units_received', 'quantity')
-        f.set_type('cases_damaged', 'quantity')
-        f.set_type('units_damaged', 'quantity')
-        f.set_type('cases_expired', 'quantity')
-        f.set_type('units_expired', 'quantity')
-        f.set_type('cases_mispick', 'quantity')
-        f.set_type('units_mispick', 'quantity')
-
-        # currency fields
-        f.set_type('po_unit_cost', 'currency')
-        f.set_type('po_total', 'currency')
-        f.set_type('po_total_calculated', 'currency')
-        f.set_type('invoice_unit_cost', 'currency')
-        f.set_type('invoice_total', 'currency')
-        f.set_type('invoice_total_calculated', 'currency')
-
-        # if self.creating:
-        #     f.remove_fields(
-        #         'upc',
-        #         'product',
-        #         'po_total',
-        #         'invoice_total',
-        #     )
-        #     if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
-        #         f.remove_fields('cases_received',
-        #                         'units_received')
-        #     elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
-        #         f.remove_fields('cases_ordered',
-        #                         'units_ordered')
-
-        # elif self.editing:
-        #     f.set_readonly('upc')
-        #     f.set_readonly('product')
-        #     f.remove_fields('po_total',
-        #                     'invoice_total',
-        #                     'status_code')
-
-        # elif self.viewing:
-        #     if row.product:
-        #         f.remove_fields('brand_name',
-        #                         'description',
-        #                         'size')
-        #     else:
-        #         f.remove_field('product')
-
-    def mobile_new_product(self):
-        """
-        View which allows user to create a new Product and add a row for it to
-        the Purchasing Batch.
-        """
-        batch = self.get_instance()
-        batch_url = self.get_action_url('view', batch, mobile=True)
-        form = forms.Form(schema=self.make_new_product_schema(),
-                          request=self.request,
-                          mobile=True,
-                          cancel_url=batch_url)
-
-        if form.validate(newstyle=True):
-            product = model.Product()
-            product.item_id = form.validated['item_id']
-            product.description = form.validated['description']
-            row = self.model_row_class()
-            row.product = product
-            self.handler.add_row(batch, row)
-            self.Session.flush()
-            return self.redirect(self.get_row_action_url('edit', row, mobile=True))
-
-        return self.render_to_response('new_product', {
-            'form': form,
-            'dform': form.make_deform_form(),
-            'instance_title': self.get_instance_title(batch),
-            'instance_url': batch_url,
-        }, mobile=True)
-
-    def make_new_product_schema(self):
-        """
-        Must return a ``colander.Schema`` instance for use with the form in the
-        :meth:`mobile_new_product()` view.
-        """
-        return NewProduct()
-
-#     def item_lookup(self, value, field=None):
-#         """
-#         Try to locate a single product using ``value`` as a lookup code.
-#         """
-#         batch = self.get_instance()
-#         product = api.get_product_by_vendor_code(Session(), value, vendor=batch.vendor)
-#         if product:
-#             return product.uuid
-#         if value.isdigit():
-#             product = api.get_product_by_upc(Session(), GPC(value))
-#             if not product:
-#                 product = api.get_product_by_upc(Session(), GPC(value, calc_check_digit='upc'))
-#             if product:
-#                 if not product.cost_for_vendor(batch.vendor):
-#                     raise fa.ValidationError("Product {} exists but has no cost for vendor {}".format(
-#                         product.upc.pretty(), batch.vendor))
-#                 return product.uuid
-#         raise fa.ValidationError("Product not found")
+    def render_row_credits(self, row, field):
+        g = self.make_row_credits_grid(row)
+        return HTML.literal(
+            g.render_table_element(data_prop='rowData.credits'))
 
 #     def before_create_row(self, form):
 #         row = form.fieldset.model
@@ -951,7 +1146,7 @@ class PurchasingBatchView(BatchMasterView):
                     batch.invoice_total -= row.invoice_total
 
             # do the "normal" save logic...
-            row = super(PurchasingBatchView, self).save_edit_row_form(form)
+            row = super().save_edit_row_form(form)
 
             # TODO: is this needed?
             # self.handler.refresh_row(row)
@@ -963,9 +1158,9 @@ class PurchasingBatchView(BatchMasterView):
 #         return self.redirect(self.request.current_route_url())
 
     # TODO: seems like this should be master behavior, controlled by setting?
-    def redirect_after_edit_row(self, row, mobile=False):
+    def redirect_after_edit_row(self, row, **kwargs):
         parent = self.get_parent(row)
-        return self.redirect(self.get_action_url('view', parent, mobile=mobile))
+        return self.redirect(self.get_action_url('view', parent))
 
 #     def get_execute_success_url(self, batch, result, **kwargs):
 #         # if batch execution yielded a Purchase, redirect to it
@@ -975,36 +1170,24 @@ class PurchasingBatchView(BatchMasterView):
 #         # otherwise just view batch again
 #         return self.get_action_url('view', batch)
 
+    @classmethod
+    def defaults(cls, config):
+        cls._purchase_batch_defaults(config)
+        cls._batch_defaults(config)
+        cls._defaults(config)
 
     @classmethod
-    def _purchasing_defaults(cls, config):
-        rattail_config = config.registry.settings.get('rattail_config')
+    def _purchase_batch_defaults(cls, config):
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
         permission_prefix = cls.get_permission_prefix()
-        model_key = cls.get_model_key()
-        model_title = cls.get_model_title()
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
 
-        # eligible purchases (AJAX)
-        config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix))
-        config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),
-                        renderer='json', permission='{}.view'.format(permission_prefix))
-
-        # add new product
-        if legacy_mobile and cls.supports_new_product:
-            config.add_tailbone_permission(permission_prefix, '{}.new_product'.format(permission_prefix),
-                                           "Create new Product when adding row to {}".format(model_title))
-            config.add_route('mobile.{}.new_product'.format(route_prefix), '{}/{{{}}}/new-product'.format(url_prefix, model_key))
-            config.add_view(cls, attr='mobile_new_product', route_name='mobile.{}.new_product'.format(route_prefix),
-                            permission='{}.new_product'.format(permission_prefix))
-
-
-    @classmethod
-    def defaults(cls, config):
-        cls._purchasing_defaults(config)
-        cls._batch_defaults(config)
-        cls._defaults(config)
+        # new batch using workflow X
+        config.add_route(f'{route_prefix}.create_workflow',
+                         f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}')
+        config.add_view(cls, attr='create',
+                        route_name=f'{route_prefix}.create_workflow',
+                        permission=f'{permission_prefix}.create')
 
 
 class NewProduct(colander.Schema):
diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py
new file mode 100644
index 00000000..ec4e3ee3
--- /dev/null
+++ b/tailbone/views/purchasing/costing.py
@@ -0,0 +1,371 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Views for 'costing' (purchasing) batches
+"""
+
+import colander
+from deform import widget as dfwidget
+
+from tailbone import forms
+from tailbone.views.purchasing import PurchasingBatchView
+
+
+class CostingBatchView(PurchasingBatchView):
+    """
+    Master view for costing batches
+    """
+    route_prefix = 'invoice_costing'
+    url_prefix = '/invoice-costing'
+    model_title = "Invoice Costing Batch"
+    model_title_plural = "Invoice Costing Batches"
+    index_title = "Invoice Costing"
+    downloadable = True
+    bulk_deletable = True
+
+    labels = {
+        'invoice_parser_key': "Invoice Parser",
+    }
+
+    grid_columns = [
+        'id',
+        'vendor',
+        'description',
+        'department',
+        'buyer',
+        'date_ordered',
+        'created',
+        'created_by',
+        'rowcount',
+        'invoice_total',
+        'status_code',
+        'executed',
+    ]
+
+    form_fields = [
+        'id',
+        'store',
+        'buyer',
+        'vendor',
+        'costing_workflow',
+        'invoice_file',
+        'invoice_parser_key',
+        'department',
+        'purchase',
+        'vendor_email',
+        'vendor_fax',
+        'vendor_contact',
+        'vendor_phone',
+        'date_ordered',
+        'date_received',
+        'po_number',
+        'po_total',
+        'invoice_date',
+        'invoice_number',
+        'invoice_total',
+        'invoice_total_calculated',
+        'notes',
+        'created',
+        'created_by',
+        'status_code',
+        'complete',
+        'executed',
+        'executed_by',
+    ]
+
+    row_grid_columns = [
+        'sequence',
+        'upc',
+        # 'item_id',
+        'vendor_code',
+        'brand_name',
+        'description',
+        'size',
+        'department_name',
+        'cases_received',
+        'units_received',
+        'case_quantity',
+        'catalog_unit_cost',
+        'invoice_unit_cost',
+        # 'invoice_total_calculated',
+        'invoice_total',
+        'status_code',
+    ]
+
+    row_form_fields = [
+        'sequence',
+        'upc',
+        'item_id',
+        'product',
+        'vendor_code',
+        'brand_name',
+        'description',
+        'size',
+        'department_name',
+        'case_quantity',
+        'cases_ordered',
+        'units_ordered',
+        'cases_shipped',
+        'units_shipped',
+        'cases_received',
+        'units_received',
+        'po_line_number',
+        'po_unit_cost',
+        'po_total',
+        'invoice_line_number',
+        'invoice_unit_cost',
+        'invoice_total',
+        'invoice_total_calculated',
+        'catalog_unit_cost',
+        'status_code',
+    ]
+
+    @property
+    def batch_mode(self):
+        return self.enum.PURCHASE_BATCH_MODE_COSTING
+
+    def create(self, form=None, **kwargs):
+        """
+        Custom view for creating a new costing batch.  We split the
+        process into two steps, 1) choose workflow and 2) create
+        batch.  This is because the specific form details for creating
+        a batch will depend on which "type" of batch creation is to be
+        done, and it's much easier to keep conditional logic for that
+        in the server instead of client-side etc.
+
+        See also
+        :meth:`tailbone.views.purchasing.receiving:ReceivingBatchView.create()`
+        which uses similar logic.
+        """
+        route_prefix = self.get_route_prefix()
+        workflows = self.handler.supported_costing_workflows()
+        valid_workflows = [workflow['workflow_key']
+                           for workflow in workflows]
+
+        # if user has already identified their desired workflow, then we can
+        # just farm out to the default logic.  we will of course configure our
+        # form differently, based on workflow, but this create() method at
+        # least will not need customization for that.
+        if self.request.matched_route.name.endswith('create_workflow'):
+
+            # however we do have one more thing to check - the workflow
+            # requested must of course be valid!
+            workflow_key = self.request.matchdict['workflow_key']
+            if workflow_key not in valid_workflows:
+                self.request.session.flash(
+                    "Not a supported workflow: {}".format(workflow_key),
+                    'error')
+                raise self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
+
+            # okay now do the normal thing, per workflow
+            return super(CostingBatchView, self).create(**kwargs)
+
+        # okay, at this point we need the user to select a vendor and workflow
+        self.creating = True
+        model = self.model
+        context = {}
+
+        # form to accept user choice of vendor/workflow
+        schema = NewCostingBatch().bind(valid_workflows=valid_workflows)
+        form = forms.Form(schema=schema, request=self.request)
+        if len(valid_workflows) == 1:
+            form.set_default('workflow', valid_workflows[0])
+
+        # configure vendor field
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+        use_dropdown = vendor_handler.choice_uses_dropdown()
+        if use_dropdown:
+            vendors = self.Session.query(model.Vendor)\
+                                  .order_by(model.Vendor.id)
+            vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
+                             for vendor in vendors]
+            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+        else:
+            vendor_display = ""
+            if self.request.method == 'POST':
+                if self.request.POST.get('vendor'):
+                    vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
+                    if vendor:
+                        vendor_display = str(vendor)
+            vendors_url = self.request.route_url('vendors.autocomplete')
+            form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
+                field_display=vendor_display, service_url=vendors_url))
+
+        # configure workflow field
+        values = [(workflow['workflow_key'], workflow['display'])
+                  for workflow in workflows]
+        form.set_widget('workflow',
+                        dfwidget.SelectWidget(values=values))
+
+        form.submit_label = "Continue"
+        form.cancel_url = self.get_index_url()
+
+        # if form validates, that means user has chosen a creation type, so we
+        # just redirect to the appropriate "new batch of type X" page
+        if form.validate():
+            workflow_key = form.validated['workflow']
+            vendor_uuid = form.validated['vendor']
+            url = self.request.route_url('{}.create_workflow'.format(route_prefix),
+                                         workflow_key=workflow_key,
+                                         vendor_uuid=vendor_uuid)
+            raise self.redirect(url)
+
+        context['form'] = form
+        if hasattr(form, 'make_deform_form'):
+            context['dform'] = form.make_deform_form()
+        return self.render_to_response('create', context)
+
+    def configure_form(self, f):
+        super(CostingBatchView, self).configure_form(f)
+        route_prefix = self.get_route_prefix()
+        model = self.model
+        workflow = self.request.matchdict.get('workflow_key')
+
+        if self.creating:
+            f.set_fields([
+                'vendor_uuid',
+                'costing_workflow',
+                'invoice_file',
+                'invoice_parser_key',
+                'purchase',
+            ])
+            f.set_required('invoice_file')
+
+        # tweak some things if we are in "step 2" of creating new batch
+        if self.creating and workflow:
+
+            # display vendor but do not allow changing
+            vendor = self.Session.get(model.Vendor,
+                                      self.request.matchdict['vendor_uuid'])
+            assert vendor
+
+            f.set_hidden('vendor_uuid')
+            f.set_default('vendor_uuid', vendor.uuid)
+            f.set_widget('vendor_uuid', dfwidget.HiddenWidget())
+
+            f.insert_after('vendor_uuid', 'vendor_name')
+            f.set_readonly('vendor_name')
+            f.set_default('vendor_name', vendor.name)
+            f.set_label('vendor_name', "Vendor")
+
+            # cancel should take us back to choosing a workflow
+            f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
+
+        # costing_workflow
+        if self.creating and workflow:
+            f.set_readonly('costing_workflow')
+            f.set_renderer('costing_workflow', self.render_costing_workflow)
+        else:
+            f.remove('costing_workflow')
+
+        # batch_type
+        if self.creating:
+            f.set_widget('batch_type', dfwidget.HiddenWidget())
+            f.set_default('batch_type', workflow)
+            f.set_hidden('batch_type')
+        else:
+            f.remove_field('batch_type')
+
+        # purchase
+        field = self.batch_handler.get_purchase_order_fieldname()
+        if (self.creating and workflow == 'invoice_with_po'
+            and field == 'purchase'):
+            f.replace('purchase', 'purchase_uuid')
+            purchases = self.handler.get_eligible_purchases(
+                vendor, self.enum.PURCHASE_BATCH_MODE_COSTING)
+            values = [(p.uuid, self.handler.render_eligible_purchase(p))
+                      for p in purchases]
+            f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values))
+            f.set_label('purchase_uuid', "Purchase Order")
+            f.set_required('purchase_uuid')
+
+    def render_costing_workflow(self, batch, field):
+        key = self.request.matchdict['workflow_key']
+        info = self.handler.costing_workflow_info(key)
+        if info:
+            return info['display']
+
+    def configure_row_grid(self, g):
+        super(CostingBatchView, self).configure_row_grid(g)
+
+        g.set_label('case_quantity', "Case Qty")
+        g.filters['case_quantity'].label = "Case Quantity"
+        g.set_type('case_quantity', 'quantity')
+
+    @classmethod
+    def defaults(cls, config):
+        cls._costing_defaults(config)
+        cls._batch_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _costing_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+
+        # new costing batch using workflow X
+        config.add_route('{}.create_workflow'.format(route_prefix),
+                         '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix))
+        config.add_view(cls, attr='create',
+                        route_name='{}.create_workflow'.format(route_prefix),
+                        permission='{}.create'.format(permission_prefix))
+
+
+@colander.deferred
+def valid_workflow(node, kw):
+    """
+    Deferred validator for ``workflow`` field, for new batches.
+    """
+    valid_workflows = kw['valid_workflows']
+
+    def validate(node, value):
+        # we just need to provide possible values, and let stock
+        # validator handle the rest
+        oneof = colander.OneOf(valid_workflows)
+        return oneof(node, value)
+
+    return validate
+
+
+class NewCostingBatch(colander.Schema):
+    """
+    Schema for choosing which "type" of new receiving batch should be created.
+    """
+    vendor = colander.SchemaNode(colander.String(),
+                                 label="Vendor")
+
+    workflow = colander.SchemaNode(colander.String(),
+                                   validator=valid_workflow)
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    CostingBatchView = kwargs.get('CostingBatchView', base['CostingBatchView'])
+    CostingBatchView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index ef37b3b3..c7cc7bfc 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,21 +24,14 @@
 Views for 'ordering' (purchasing) batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import json
 
-import six
 import openpyxl
-from sqlalchemy import orm
 
-from rattail.db import model, api
 from rattail.core import Object
-from rattail.time import localtime
-
-from webhelpers2.html import tags
 
+from tailbone.db import Session
 from tailbone.views.purchasing import PurchasingBatchView
 
 
@@ -51,13 +44,11 @@ class OrderingBatchView(PurchasingBatchView):
     model_title = "Ordering Batch"
     model_title_plural = "Ordering Batches"
     index_title = "Ordering"
-    mobile_creatable = True
     rows_editable = True
-    mobile_rows_creatable = True
-    mobile_rows_quickable = True
-    mobile_rows_editable = True
-    mobile_rows_deletable = True
     has_worksheet = True
+    default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html'
+    downloadable = True
+    configurable = True
 
     labels = {
         'po_total_calculated': "PO Total",
@@ -66,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView):
     form_fields = [
         'id',
         'store',
-        'buyer',
         'vendor',
+        'description',
+        'workflow',
+        'order_file',
+        'order_parser_key',
+        'buyer',
         'department',
+        'params',
         'purchase',
         'vendor_email',
         'vendor_fax',
@@ -86,21 +82,6 @@ class OrderingBatchView(PurchasingBatchView):
         'executed_by',
     ]
 
-    mobile_form_fields = [
-        'vendor',
-        'department',
-        'date_ordered',
-        'po_number',
-        'po_total',
-        'created',
-        'created_by',
-        'notes',
-        'status_code',
-        'complete',
-        'executed',
-        'executed_by',
-    ]
-
     row_labels = {
         'po_total_calculated': "PO Total",
     }
@@ -154,13 +135,26 @@ class OrderingBatchView(PurchasingBatchView):
         return self.enum.PURCHASE_BATCH_MODE_ORDERING
 
     def configure_form(self, f):
-        super(OrderingBatchView, self).configure_form(f)
+        super().configure_form(f)
+        batch = f.model_instance
+        workflow = self.request.matchdict.get('workflow_key')
 
         # purchase
-        f.remove_field('purchase')
+        if self.creating or not batch.executed or not batch.purchase:
+            f.remove_field('purchase')
 
-    def get_batch_kwargs(self, batch, mobile=False):
-        kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
+        # now that all fields are setup, some final tweaks based on workflow
+        if self.creating and workflow:
+
+            if workflow == 'from_scratch':
+                f.remove('order_file',
+                         'order_parser_key')
+
+            elif workflow == 'from_file':
+                f.set_required('order_file')
+
+    def get_batch_kwargs(self, batch, **kwargs):
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
         kwargs['ship_method'] = batch.ship_method
         kwargs['notes_to_vendor'] = batch.notes_to_vendor
         return kwargs
@@ -175,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView):
         * ``cases_ordered``
         * ``units_ordered``
         """
-        super(OrderingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # when editing, only certain fields should allow changes
         if self.editing:
@@ -187,6 +181,89 @@ class OrderingBatchView(PurchasingBatchView):
                 if field not in editable_fields:
                     f.set_readonly(field)
 
+    def scanning_entry(self):
+        """
+        AJAX view to handle user entry/fetch input for "scanning" feature.
+        """
+        data = self.request.json_body
+        app = self.get_rattail_app()
+        prodder = app.get_products_handler()
+
+        batch = self.get_instance()
+        entry = data['entry']
+        row = self.handler.quick_entry(self.Session(), batch, entry)
+
+        uom = self.enum.UNIT_OF_MEASURE_EACH
+        if row.product and row.product.weighed:
+            uom = self.enum.UNIT_OF_MEASURE_POUND
+
+        cases_ordered = None
+        if row.cases_ordered:
+            cases_ordered = float(row.cases_ordered)
+
+        units_ordered = None
+        if row.units_ordered:
+            units_ordered = float(row.units_ordered)
+
+        po_case_cost = None
+        if row.po_unit_cost is not None:
+            po_case_cost = row.po_unit_cost * (row.case_quantity or 1)
+
+        product_url = None
+        if row.product_uuid:
+            product_url = self.request.route_url('products.view', uuid=row.product_uuid)
+
+        product_price = None
+        if row.product and row.product.regular_price:
+            product_price = row.product.regular_price.price
+
+        product_price_display = None
+        if product_price is not None:
+            product_price_display = app.render_currency(product_price)
+
+        return {
+            'ok': True,
+            'entry': entry,
+            'row': {
+                'uuid': row.uuid,
+                'item_id': row.item_id,
+                'upc_display': row.upc.pretty() if row.upc else None,
+                'brand_name': row.brand_name,
+                'description': row.description,
+                'size': row.size,
+                'unit_of_measure_display': self.enum.UNIT_OF_MEASURE[uom],
+                'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None,
+                'cases_ordered': cases_ordered,
+                'units_ordered': units_ordered,
+                'po_unit_cost': float(row.po_unit_cost) if row.po_unit_cost is not None else None,
+                'po_unit_cost_display': app.render_currency(row.po_unit_cost),
+                'po_case_cost': float(po_case_cost) if po_case_cost is not None else None,
+                'po_case_cost_display': app.render_currency(po_case_cost),
+                'image_url': prodder.get_image_url(upc=row.upc),
+                'product_url': product_url,
+                'product_price_display': product_price_display,
+            },
+        }
+
+    def scanning_update(self):
+        """
+        AJAX view to handle row data updates for "scanning" feature.
+        """
+        data = self.request.json_body
+        batch = self.get_instance()
+        assert batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING
+        assert not (batch.executed or batch.complete)
+
+        uuid = data.get('row_uuid')
+        row = self.Session.get(self.model_row_class, uuid) if uuid else None
+        if not row:
+            return {'error': "Row not found"}
+        if row.batch is not batch or row.removed:
+            return {'error': "Row is not active for batch"}
+
+        self.handler.update_row_quantity(row, **data)
+        return {'ok': True}
+
     def worksheet(self):
         """
         View for editing batch row data as an order form worksheet.
@@ -245,11 +322,7 @@ class OrderingBatchView(PurchasingBatchView):
         title = self.get_instance_title(batch)
         order_date = batch.date_ordered
         if not order_date:
-            order_date = localtime(self.rattail_config).date()
-
-        buefy_data = None
-        if self.get_use_buefy():
-            buefy_data = self.get_worksheet_buefy_data(departments)
+            order_date = self.app.today()
 
         return self.render_to_response('worksheet', {
             'batch': batch,
@@ -263,13 +336,13 @@ class OrderingBatchView(PurchasingBatchView):
             'get_upc': lambda p: p.upc.pretty() if p.upc else '',
             'header_columns': self.order_form_header_columns,
             'ignore_cases': not self.handler.allow_cases(),
-            'worksheet_data': buefy_data,
+            'worksheet_data': self.get_worksheet_data(departments),
         })
 
-    def get_worksheet_buefy_data(self, departments):
+    def get_worksheet_data(self, departments):
         data = {}
-        for department in six.itervalues(departments):
-            for subdepartment in six.itervalues(department._order_subdepartments):
+        for department in departments.values():
+            for subdepartment in department._order_subdepartments.values():
                 for i, cost in enumerate(subdepartment._order_costs, 1):
                     cases = int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None
                     units = int(cost._batchrow.units_ordered or 0) if cost._batchrow else None
@@ -310,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView):
         of being updated.  If a matching row is not found, it will not be
         created.
         """
+        model = self.app.model
         batch = self.get_instance()
 
         try:
@@ -324,7 +398,7 @@ class OrderingBatchView(PurchasingBatchView):
             if cases_ordered == '':
                 cases_ordered = 0
             else:
-                cases_ordered = int(cases_ordered)
+                cases_ordered = int(float(cases_ordered))
         if cases_ordered >= 100000: # TODO: really this depends on underlying column
             return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)}
 
@@ -335,12 +409,12 @@ class OrderingBatchView(PurchasingBatchView):
             if units_ordered == '':
                 units_ordered = 0
             else:
-                units_ordered = int(units_ordered)
+                units_ordered = int(float(units_ordered))
         if units_ordered >= 100000: # TODO: really this depends on underlying column
             return {'error': "Invalid value for units ordered: {}".format(units_ordered)}
 
         uuid = data.get('product_uuid')
-        product = self.Session.query(model.Product).get(uuid) if uuid else None
+        product = self.Session.get(model.Product, uuid) if uuid else None
         if not product:
             return {'error': "Product not found"}
 
@@ -363,8 +437,11 @@ class OrderingBatchView(PurchasingBatchView):
                 self.handler.add_row(batch, row)
 
             # update row quantities
-            self.handler.update_row_quantity(row, cases_ordered=cases_ordered,
-                                             units_ordered=units_ordered)
+            try:
+                self.handler.update_row_quantity(row, cases_ordered=cases_ordered,
+                                                 units_ordered=units_ordered)
+            except Exception as error:
+                return {'error': str(error)}
 
         else: # empty order quantities
 
@@ -385,60 +462,6 @@ class OrderingBatchView(PurchasingBatchView):
             'batch_po_total_display': '${:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0),
         }
 
-    def render_mobile_listitem(self, batch, i):
-        return "({}) {} on {} for ${:0,.2f}".format(batch.id_str, batch.vendor,
-                                                    batch.date_ordered, batch.po_total or 0)
-
-    def mobile_create(self):
-        """
-        Mobile view for creating a new ordering batch
-        """
-        mode = self.batch_mode
-        data = {'mode': mode}
-
-        vendor = None
-        if self.request.method == 'POST' and self.request.POST.get('vendor'):
-            vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor'])
-            if vendor:
-
-                # fetch first to avoid flush below
-                store = self.rattail_config.get_store(self.Session())
-
-                batch = self.model_class()
-                batch.mode = mode
-                batch.vendor = vendor
-                batch.store = store
-                batch.buyer = self.request.user.employee
-                batch.created_by = self.request.user
-                batch.po_total = 0
-                kwargs = self.get_batch_kwargs(batch, mobile=True)
-                batch = self.handler.make_batch(self.Session(), **kwargs)
-                if self.handler.should_populate(batch):
-                    self.handler.populate(batch)
-                return self.redirect(self.request.route_url('mobile.ordering.view', uuid=batch.uuid))
-
-        data['index_title'] = self.get_index_title()
-        data['index_url'] = self.get_index_url(mobile=True)
-        data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize()
-
-        data['vendor_use_autocomplete'] = self.rattail_config.getbool(
-            'rattail', 'vendor.use_autocomplete', default=True)
-        if not data['vendor_use_autocomplete']:
-            vendors = self.Session.query(model.Vendor)\
-                                  .order_by(model.Vendor.name)
-            options = [(tags.Option(vendor.name, vendor.uuid))
-                       for vendor in vendors]
-            options.insert(0, tags.Option("(please choose)", ''))
-            data['vendor_options'] = options
-
-        return self.render_to_response('create', data, mobile=True)
-
-    def configure_mobile_row_form(self, f):
-        super(OrderingBatchView, self).configure_mobile_row_form(f)
-        if self.editing:
-            # TODO: probably should take `allow_cases` into account here...
-            f.focus_spec = '[name="units_ordered"]'
-
     def download_excel(self):
         """
         Download ordering batch as Excel spreadsheet.
@@ -450,11 +473,12 @@ class OrderingBatchView(PurchasingBatchView):
         worksheet = workbook.active
         worksheet.title = "Purchase Order"
         worksheet.append(["Store", "Vendor", "Date ordered"])
-        worksheet.append([batch.store.name, batch.vendor.name, batch.date_ordered.strftime('%m/%d/%Y')])
+        date_ordered = batch.date_ordered.strftime('%m/%d/%Y') if batch.date_ordered else None
+        worksheet.append([batch.store.name, batch.vendor.name, date_ordered])
         worksheet.append([])
         worksheet.append(['vendor_code', 'upc', 'brand_name', 'description', 'cases_ordered', 'units_ordered'])
         for row in batch.active_rows():
-            worksheet.append([row.vendor_code, six.text_type(row.upc), row.brand_name,
+            worksheet.append([row.vendor_code, str(row.upc), row.brand_name,
                               '{} {}'.format(row.description, row.size),
                               row.cases_ordered, row.units_ordered])
 
@@ -468,31 +492,117 @@ class OrderingBatchView(PurchasingBatchView):
 
         return self.file_response(path)
 
+    def get_execute_success_url(self, batch, result, **kwargs):
+        model = self.app.model
+        if isinstance(result, model.Purchase):
+            return self.request.route_url('purchases.view', uuid=result.uuid)
+        return super().get_execute_success_url(batch, result, **kwargs)
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # workflows
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_from_scratch',
+             'type': bool,
+             'default': True},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_from_file',
+             'type': bool,
+             'default': True},
+
+            # vendors
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_any_vendor',
+             'type': bool,
+             'default': True,
+             },
+        ]
+
+    def configure_get_context(self):
+        context = super().configure_get_context()
+        vendor_handler = self.app.get_vendor_handler()
+
+        Parsers = vendor_handler.get_all_order_parsers()
+        Supported = vendor_handler.get_supported_order_parsers()
+        context['order_parsers'] = Parsers
+        context['order_parsers_data'] = dict([(Parser.key, Parser in Supported)
+                                                for Parser in Parsers])
+
+        return context
+
+    def configure_gather_settings(self, data):
+        settings = super().configure_gather_settings(data)
+        vendor_handler = self.app.get_vendor_handler()
+
+        supported = []
+        for Parser in vendor_handler.get_all_order_parsers():
+            name = f'order_parser_{Parser.key}'
+            if data.get(name) == 'true':
+                supported.append(Parser.key)
+        settings.append({'name': 'rattail.vendors.supported_order_parsers',
+                         'value': ', '.join(supported)})
+
+        return settings
+
+    def configure_remove_settings(self):
+        super().configure_remove_settings()
+
+        names = [
+            'rattail.vendors.supported_order_parsers',
+        ]
+
+        # nb. using thread-local session here; we do not use
+        # self.Session b/c it may not point to Rattail
+        session = Session()
+        for name in names:
+            self.app.delete_setting(session, name)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._ordering_defaults(config)
+        cls._purchase_batch_defaults(config)
+        cls._batch_defaults(config)
+        cls._defaults(config)
+
     @classmethod
     def _ordering_defaults(cls, config):
         route_prefix = cls.get_route_prefix()
-        url_prefix = cls.get_url_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
         permission_prefix = cls.get_permission_prefix()
         model_title = cls.get_model_title()
         model_title_plural = cls.get_model_title_plural()
 
         # fix permission group label
-        config.add_tailbone_permission_group(permission_prefix, model_title_plural)
+        config.add_tailbone_permission_group(permission_prefix, model_title_plural,
+                                             overwrite=False)
+
+        # scanning entry
+        config.add_route('{}.scanning_entry'.format(route_prefix), '{}/scanning-entry'.format(instance_url_prefix))
+        config.add_view(cls, attr='scanning_entry', route_name='{}.scanning_entry'.format(route_prefix),
+                        permission='{}.edit_row'.format(permission_prefix),
+                        renderer='json')
+
+        # scanning update
+        config.add_route('{}.scanning_update'.format(route_prefix), '{}/scanning-update'.format(instance_url_prefix))
+        config.add_view(cls, attr='scanning_update', route_name='{}.scanning_update'.format(route_prefix),
+                        permission='{}.edit_row'.format(permission_prefix),
+                        renderer='json')
 
         # download as Excel
-        config.add_route('{}.download_excel'.format(route_prefix), '{}/{{uuid}}/excel'.format(url_prefix))
+        config.add_route('{}.download_excel'.format(route_prefix), '{}/excel'.format(instance_url_prefix))
         config.add_view(cls, attr='download_excel', route_name='{}.download_excel'.format(route_prefix),
                         permission='{}.download_excel'.format(permission_prefix))
         config.add_tailbone_permission(permission_prefix, '{}.download_excel'.format(permission_prefix),
                                        "Download {} as Excel".format(model_title))
 
-    @classmethod
-    def defaults(cls, config):
-        cls._ordering_defaults(config)
-        cls._purchasing_defaults(config)
-        cls._batch_defaults(config)
-        cls._defaults(config)
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    OrderingBatchView = kwargs.get('OrderingBatchView', base['OrderingBatchView'])
+    OrderingBatchView.defaults(config)
 
 
 def includeme(config):
-    OrderingBatchView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 17d2eddf..01858c98 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,100 +24,42 @@
 Views for 'receiving' (purchasing) batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import re
+import os
 import decimal
 import logging
+from collections import OrderedDict
 
-import six
-import humanize
-import sqlalchemy as sa
+# import humanize
 
 from rattail import pod
-from rattail.db import model, Session as RattailSession
-from rattail.time import localtime, make_utc
-from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error
-from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
-from rattail.threads import Thread
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
-from pyramid import httpexceptions
 from webhelpers2.html import tags, HTML
 
-from tailbone import forms, grids
+from wuttaweb.util import get_form_data
+
+from tailbone import forms
 from tailbone.views.purchasing import PurchasingBatchView
-from tailbone.forms.receiving import ReceiveRow as MobileReceivingForm
 
 
 log = logging.getLogger(__name__)
 
+POSSIBLE_RECEIVING_MODES = [
+    'received',
+    'damaged',
+    'expired',
+    # 'mispick',
+    'missing',
+]
 
-class MobileItemStatusFilter(grids.filters.MobileFilter):
-
-    value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
-
-    def filter_equal(self, query, value):
-
-        # NOTE: this is only relevant for truck dump or "from scratch"
-        if value == 'received':
-            return query.filter(sa.or_(
-                model.PurchaseBatchRow.cases_received != 0,
-                model.PurchaseBatchRow.units_received != 0))
-
-        if value == 'incomplete':
-            # looking for any rows with "ordered" quantity, but where the
-            # status does *not* signify a "settled" row so to speak
-            # TODO: would be nice if we had a simple flag to leverage?
-            return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0,
-                                       model.PurchaseBatchRow.units_ordered != 0))\
-                        .filter(~model.PurchaseBatchRow.status_code.in_((
-                            model.PurchaseBatchRow.STATUS_OK,
-                            model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
-                            model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS)))
-
-        if value == 'invalid':
-            return query.filter(model.PurchaseBatchRow.status_code.in_((
-                model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
-                model.PurchaseBatchRow.STATUS_COST_NOT_FOUND,
-                model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN,
-                model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
-            )))
-
-        if value == 'unexpected':
-            # looking for any rows which have "received" quantity but which
-            # do *not* have any "ordered" quantity
-            return query.filter(sa.and_(
-                sa.or_(
-                    model.PurchaseBatchRow.cases_ordered == None,
-                    model.PurchaseBatchRow.cases_ordered == 0),
-                sa.or_(
-                    model.PurchaseBatchRow.units_ordered == None,
-                    model.PurchaseBatchRow.units_ordered == 0),
-                sa.or_(
-                    model.PurchaseBatchRow.cases_received != 0,
-                    model.PurchaseBatchRow.units_received != 0,
-                    model.PurchaseBatchRow.cases_damaged != 0,
-                    model.PurchaseBatchRow.units_damaged != 0,
-                    model.PurchaseBatchRow.cases_expired != 0,
-                    model.PurchaseBatchRow.units_expired != 0)))
-
-        if value == 'damaged':
-            return query.filter(sa.or_(
-                model.PurchaseBatchRow.cases_damaged != 0,
-                model.PurchaseBatchRow.units_damaged != 0))
-
-        if value == 'expired':
-            return query.filter(sa.or_(
-                model.PurchaseBatchRow.cases_expired != 0,
-                model.PurchaseBatchRow.units_expired != 0))
-
-        return query
-
-    def iter_choices(self):
-        for value in self.value_choices:
-            yield value, prettify(value)
+POSSIBLE_CREDIT_TYPES = [
+    'damaged',
+    'expired',
+    # 'mispick',
+    'missing',
+]
 
 
 class ReceivingBatchView(PurchasingBatchView):
@@ -131,21 +73,15 @@ class ReceivingBatchView(PurchasingBatchView):
     index_title = "Receiving"
     downloadable = True
     bulk_deletable = True
-    rows_editable = True
-    mobile_creatable = True
-    mobile_rows_filterable = True
-    mobile_rows_creatable = True
-    mobile_rows_quickable = True
-    mobile_rows_deletable = True
+    configurable = True
+    config_title = "Receiving"
+    default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/receiving/index.html'
 
-    allow_from_po = False
-    allow_from_scratch = True
-    allow_truck_dump = False
+    rows_editable = False
+    rows_editable_but_not_directly = True
 
     default_uom_is_case = True
 
-    purchase_order_fieldname = 'purchase'
-
     labels = {
         'truck_dump_batch': "Truck Dump Parent",
         'invoice_parser_key': "Invoice Parser",
@@ -157,7 +93,6 @@ class ReceivingBatchView(PurchasingBatchView):
         'truck_dump',
         'description',
         'department',
-        'buyer',
         'date_ordered',
         'created',
         'created_by',
@@ -169,10 +104,11 @@ class ReceivingBatchView(PurchasingBatchView):
 
     form_fields = [
         'id',
-        'batch_type',
+        'batch_type',           # TODO: ideally would get rid of this one
         'store',
         'vendor',
         'description',
+        'workflow',
         'truck_dump',
         'truck_dump_children_first',
         'truck_dump_children',
@@ -182,14 +118,15 @@ class ReceivingBatchView(PurchasingBatchView):
         'invoice_parser_key',
         'department',
         'purchase',
+        'params',
         'vendor_email',
         'vendor_fax',
         'vendor_contact',
         'vendor_phone',
         'date_ordered',
-        'date_received',
         'po_number',
         'po_total',
+        'date_received',
         'invoice_date',
         'invoice_number',
         'invoice_total',
@@ -207,20 +144,16 @@ class ReceivingBatchView(PurchasingBatchView):
         'executed_by',
     ]
 
-    mobile_form_fields = [
-        'vendor',
-        'department',
-    ]
-
     row_grid_columns = [
         'sequence',
-        'upc',
-        # 'item_id',
+        '_product_key_',
         'vendor_code',
         'brand_name',
         'description',
         'size',
         'department_name',
+        'cases_ordered',
+        'units_ordered',
         'cases_shipped',
         'units_shipped',
         'cases_received',
@@ -234,33 +167,46 @@ class ReceivingBatchView(PurchasingBatchView):
     ]
 
     row_form_fields = [
+        'sequence',
         'item_entry',
-        'upc',
-        'item_id',
+        '_product_key_',
         'vendor_code',
         'product',
         'brand_name',
         'description',
         'size',
         'case_quantity',
+        'ordered',
         'cases_ordered',
         'units_ordered',
+        'shipped',
         'cases_shipped',
         'units_shipped',
+        'received',
         'cases_received',
         'units_received',
+        'damaged',
         'cases_damaged',
         'units_damaged',
+        'expired',
         'cases_expired',
         'units_expired',
+        'mispick',
         'cases_mispick',
         'units_mispick',
+        'missing',
+        'cases_missing',
+        'units_missing',
+        'catalog_unit_cost',
         'po_line_number',
         'po_unit_cost',
+        'po_case_size',
         'po_total',
+        'invoice_number',
         'invoice_line_number',
         'invoice_unit_cost',
         'invoice_cost_confirmed',
+        'invoice_case_size',
         'invoice_total',
         'invoice_total_calculated',
         'status_code',
@@ -283,48 +229,37 @@ class ReceivingBatchView(PurchasingBatchView):
     def batch_mode(self):
         return self.enum.PURCHASE_BATCH_MODE_RECEIVING
 
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        if not self.handler.allow_truck_dump_receiving():
+            g.remove('truck_dump')
+
+    def get_supported_vendors(self):
+        """ """
+        vendor_handler = self.app.get_vendor_handler()
+        vendors = {}
+        for parser in self.batch_handler.get_supported_invoice_parsers():
+            if parser.vendor_key:
+                vendor = vendor_handler.get_vendor(self.Session(),
+                                                   parser.vendor_key)
+                if vendor:
+                    vendors[vendor.uuid] = vendor
+        vendors = sorted(vendors.values(), key=lambda v: v.name)
+        return vendors
+
     def row_deletable(self, row):
-        batch = row.batch
 
-        # don't allow if master view has disabled that entirely
-        if not self.rows_deletable:
+        # first run it through the normal logic, if that doesn't like
+        # it then we won't either
+        if not super().row_deletable(row):
             return False
 
-        # can never delete rows for complete/executed batches
-        # TODO: not so sure about the 'complete' part though..?
-        if batch.executed or batch.complete:
-            return False
-
-        # can "always" delete rows from truck dump parent...
-        if batch.is_truck_dump_parent():
-
-            # ...but only on desktop!
-            if not self.mobile:
-                return True
-
-            # ...for mobile we only allow deletion of rows which did *not* come
-            # from a child batch, i.e. can delete ad-hoc rows only
-            # TODO: should have a better way to detect this; for now we rely on
-            # the fact that only rows from an invoice or similar would have
-            # order quantities
-            if not (row.cases_ordered or row.units_ordered):
-                return True
-
-        # can always delete rows from truck dump child
-        elif batch.is_truck_dump_child():
-            return True
-
-        else: # okay, normal batch
-            if batch.order_quantities_known:
-                return False
-            else: # allow delete if receiving rom scratch
-                return True
-
-        # cannot delete row by default
-        return False
+        # otherwise let handler decide
+        return self.batch_handler.is_row_deletable(row)
 
     def get_instance_title(self, batch):
-        title = super(ReceivingBatchView, self).get_instance_title(batch)
+        title = super().get_instance_title(batch)
         if batch.is_truck_dump_parent():
             title = "{} (TRUCK DUMP PARENT)".format(title)
         elif batch.is_truck_dump_child():
@@ -332,25 +267,37 @@ class ReceivingBatchView(PurchasingBatchView):
         return title
 
     def configure_form(self, f):
-        super(ReceivingBatchView, self).configure_form(f)
+        super().configure_form(f)
+        model = self.model
         batch = f.model_instance
+        allow_truck_dump = self.batch_handler.allow_truck_dump_receiving()
+        workflow = self.request.matchdict.get('workflow_key')
+        route_prefix = self.get_route_prefix()
 
+        # tweak some things if we are in "step 2" of creating new batch
+        if self.creating and workflow:
+
+            # display vendor but do not allow changing
+            vendor = self.Session.get(model.Vendor,
+                                      self.request.matchdict['vendor_uuid'])
+            assert vendor
+            f.set_readonly('vendor_uuid')
+            f.set_default('vendor_uuid', str(vendor))
+
+            # cancel should take us back to choosing a workflow
+            f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
+
+        # TODO: remove this
         # batch_type
         if self.creating:
-            batch_types = OrderedDict()
-            if self.allow_from_scratch:
-                batch_types['from_scratch'] = "From Scratch"
-            if self.allow_from_po:
-                batch_types['from_po'] = "From PO"
-            if self.allow_truck_dump:
-                batch_types['truck_dump_children_first'] = "Truck Dump (children FIRST)"
-                batch_types['truck_dump_children_last'] = "Truck Dump (children LAST)"
-            f.set_enum('batch_type', batch_types)
+            f.set_widget('batch_type', dfwidget.HiddenWidget())
+            f.set_default('batch_type', workflow)
+            f.set_hidden('batch_type')
         else:
             f.remove_field('batch_type')
 
         # truck_dump*
-        if self.allow_truck_dump:
+        if allow_truck_dump:
 
             # truck_dump
             if self.creating or not batch.is_truck_dump_parent():
@@ -413,22 +360,6 @@ class ReceivingBatchView(PurchasingBatchView):
                             'truck_dump_status',
                             'truck_dump_batch')
 
-        # invoice_file
-        if self.creating:
-            f.set_type('invoice_file', 'file', required=False)
-        else:
-            f.set_readonly('invoice_file')
-            f.set_renderer('invoice_file', self.render_downloadable_file)
-
-        # invoice_parser_key
-        if self.creating:
-            parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display)
-            parser_values = [(p.key, p.display) for p in parsers]
-            parser_values.insert(0, ('', "(please choose)"))
-            f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values))
-        else:
-            f.remove_field('invoice_parser_key')
-
         # store
         if self.creating:
             store = self.rattail_config.get_store(self.Session())
@@ -438,8 +369,28 @@ class ReceivingBatchView(PurchasingBatchView):
             f.set_widget('store_uuid', dfwidget.HiddenWidget())
 
         # purchase
-        if self.creating:
+        field = self.batch_handler.get_purchase_order_fieldname()
+        if field == 'purchase':
+            field = 'purchase_uuid'
+        # TODO: workflow "invoice_with_po" is for costing mode, should rename?
+        if self.creating and workflow in (
+                'from_po', 'from_po_with_invoice', 'invoice_with_po'):
+            f.replace('purchase', field)
+            purchases = self.batch_handler.get_eligible_purchases(
+                vendor, self.batch_mode)
+            values = [(self.batch_handler.get_eligible_purchase_key(p),
+                       self.batch_handler.render_eligible_purchase(p))
+                      for p in purchases]
+            f.set_widget(field, dfwidget.SelectWidget(values=values))
+            if field == 'purchase_uuid':
+                f.set_label(field, "Purchase Order")
+            f.set_required(field)
+        elif self.creating:
             f.remove_field('purchase')
+        else: # not creating
+            if field != 'purchase_uuid':
+                f.replace('purchase', field)
+            f.set_renderer(field, self.render_purchase)
 
         # department
         if self.creating:
@@ -449,13 +400,119 @@ class ReceivingBatchView(PurchasingBatchView):
         if not self.editing:
             f.remove_field('order_quantities_known')
 
+        # multiple invoice files (if applicable)
+        if (not self.creating
+            and batch.get_param('workflow') == 'from_multi_invoice'):
+
+            if 'invoice_files' not in f:
+                f.insert_before('invoice_file', 'invoice_files')
+            f.set_renderer('invoice_files', self.render_invoice_files)
+            f.set_readonly('invoice_files', True)
+            f.remove('invoice_file')
+
         # invoice totals
         f.set_label('invoice_total', "Invoice Total (Orig.)")
         f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
+        if self.creating:
+            f.remove('invoice_total_calculated')
+
+        # hide all invoice fields if batch does not have invoice file
+        if not self.creating and not self.batch_handler.has_invoice_file(batch):
+            f.remove('invoice_file',
+                     'invoice_date',
+                     'invoice_number',
+                     'invoice_total')
+
+        # receiving_complete
+        if self.creating:
+            f.remove('receiving_complete')
+
+        # now that all fields are setup, some final tweaks based on workflow
+        if self.creating and workflow:
+
+            if workflow == 'from_scratch':
+                f.remove('truck_dump_batch_uuid',
+                         'invoice_file',
+                         'invoice_parser_key')
+
+            elif workflow == 'from_invoice':
+                f.set_required('invoice_file')
+                f.set_required('invoice_parser_key')
+                f.remove('truck_dump_batch_uuid',
+                         'po_number',
+                         'invoice_date',
+                         'invoice_number')
+
+            elif workflow == 'from_multi_invoice':
+                if 'invoice_files' not in f:
+                    f.insert_before('invoice_file', 'invoice_files')
+                f.set_type('invoice_files', 'multi_file', validate_unique=True)
+                f.set_required('invoice_parser_key')
+                f.remove('truck_dump_batch_uuid',
+                         'po_number',
+                         'invoice_file',
+                         'invoice_date',
+                         'invoice_number')
+
+            elif workflow == 'from_po':
+                f.remove('truck_dump_batch_uuid',
+                         'date_ordered',
+                         'po_number',
+                         'invoice_file',
+                         'invoice_parser_key',
+                         'invoice_date',
+                         'invoice_number')
+
+            elif workflow == 'from_po_with_invoice':
+                f.set_required('invoice_file')
+                f.set_required('invoice_parser_key')
+                f.remove('truck_dump_batch_uuid',
+                         'date_ordered',
+                         'po_number',
+                         'invoice_date',
+                         'invoice_number')
+
+            elif workflow == 'truck_dump_children_first':
+                f.remove('truck_dump_batch_uuid',
+                         'invoice_file',
+                         'invoice_parser_key',
+                         'date_ordered',
+                         'po_number',
+                         'invoice_date',
+                         'invoice_number')
+
+            elif workflow == 'truck_dump_children_last':
+                f.remove('truck_dump_batch_uuid',
+                         'invoice_file',
+                         'invoice_parser_key',
+                         'date_ordered',
+                         'po_number',
+                         'invoice_date',
+                         'invoice_number')
+
+    def render_invoice_files(self, batch, field):
+        datadir = self.batch_handler.datadir(batch)
+        items = []
+        for filename in batch.get_param('invoice_files', []):
+            path = os.path.join(datadir, filename)
+            url = self.get_action_url('download', batch,
+                                      _query={'filename': filename})
+            link = self.render_file_field(path, url)
+            items.append(HTML.tag('li', c=[link]))
+        return HTML.tag('ul', c=items)
+
+    def get_visible_params(self, batch):
+        params = super().get_visible_params(batch)
+
+        # remove this since we show it separately
+        params.pop('invoice_files', None)
+
+        return params
 
     def template_kwargs_create(self, **kwargs):
-        kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
-        if self.allow_truck_dump:
+        kwargs = super().template_kwargs_create(**kwargs)
+        model = self.model
+        if self.handler.allow_truck_dump_receiving():
             vmap = {}
             batches = self.Session.query(model.PurchaseBatch)\
                                   .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\
@@ -466,33 +523,193 @@ class ReceivingBatchView(PurchasingBatchView):
             kwargs['batch_vendor_map'] = vmap
         return kwargs
 
-    def get_batch_kwargs(self, batch, mobile=False):
-        kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
-        if not mobile:
-            batch_type = self.request.POST['batch_type']
-            if batch_type == 'from_scratch':
-                kwargs.pop('truck_dump_batch', None)
-                kwargs.pop('truck_dump_batch_uuid', None)
-            elif batch_type == 'truck_dump_children_first':
-                kwargs['truck_dump'] = True
-                kwargs['truck_dump_children_first'] = True
-                kwargs['order_quantities_known'] = True
-                # TODO: this makes sense in some cases, but all?
-                # (should just omit that field when not relevant)
-                kwargs['date_ordered'] = None
-            elif batch_type == 'truck_dump_children_last':
-                kwargs['truck_dump'] = True
-                kwargs['truck_dump_ready'] = True
-                # TODO: this makes sense in some cases, but all?
-                # (should just omit that field when not relevant)
-                kwargs['date_ordered'] = None
-            elif batch_type.startswith('truck_dump_child'):
-                truck_dump = self.get_instance()
-                kwargs['store'] = truck_dump.store
-                kwargs['vendor'] = truck_dump.vendor
-                kwargs['truck_dump_batch'] = truck_dump
+    def get_batch_kwargs(self, batch, **kwargs):
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
+
+        # must pull vendor from URL if it was not in form data
+        if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
+            if 'vendor_uuid' in self.request.matchdict:
+                kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
+
+        workflow = kwargs['workflow']
+        if workflow == 'from_scratch':
+            kwargs.pop('truck_dump_batch', None)
+            kwargs.pop('truck_dump_batch_uuid', None)
+        elif workflow == 'from_invoice':
+            pass
+        elif workflow == 'from_multi_invoice':
+            pass
+        elif workflow == 'from_po':
+            # TODO: how to best handle this field?  this doesn't seem flexible
+            kwargs['purchase_key'] = batch.purchase_uuid
+        elif workflow == 'from_po_with_invoice':
+            # TODO: how to best handle this field?  this doesn't seem flexible
+            kwargs['purchase_key'] = batch.purchase_uuid
+        elif workflow == 'truck_dump_children_first':
+            kwargs['truck_dump'] = True
+            kwargs['truck_dump_children_first'] = True
+            kwargs['order_quantities_known'] = True
+            # TODO: this makes sense in some cases, but all?
+            # (should just omit that field when not relevant)
+            kwargs['date_ordered'] = None
+        elif workflow == 'truck_dump_children_last':
+            kwargs['truck_dump'] = True
+            kwargs['truck_dump_ready'] = True
+            # TODO: this makes sense in some cases, but all?
+            # (should just omit that field when not relevant)
+            kwargs['date_ordered'] = None
+        elif workflow.startswith('truck_dump_child'):
+            truck_dump = self.get_instance()
+            kwargs['store'] = truck_dump.store
+            kwargs['vendor'] = truck_dump.vendor
+            kwargs['truck_dump_batch'] = truck_dump
+        else:
+            raise NotImplementedError
+        return kwargs
+
+    def make_po_vs_invoice_breakdown(self, batch):
+        """
+        Returns a simple breakdown as list of 2-tuples, each of which
+        has the display title as first member, and number of rows as
+        second member.
+        """
+        grouped = {}
+        labels = OrderedDict([
+            ('both', "Found in both PO and Invoice"),
+            ('po_not_invoice', "Found in PO but not Invoice"),
+            ('invoice_not_po', "Found in Invoice but not PO"),
+            ('neither', "Not found in PO nor Invoice"),
+        ])
+
+        for row in batch.active_rows():
+            if row.po_line_number and not row.invoice_line_number:
+                grouped.setdefault('po_not_invoice', []).append(row)
+            elif row.invoice_line_number and not row.po_line_number:
+                grouped.setdefault('invoice_not_po', []).append(row)
+            elif row.po_line_number and row.invoice_line_number:
+                grouped.setdefault('both', []).append(row)
             else:
-                raise NotImplementedError
+                grouped.setdefault('neither', []).append(row)
+
+        breakdown = []
+
+        for key, label in labels.items():
+            if key in grouped:
+                breakdown.append({
+                    'key': key,
+                    'title': label,
+                    'count': len(grouped[key]),
+                })
+
+        return breakdown
+
+    def allow_edit_catalog_unit_cost(self, batch):
+
+        # batch must not yet be frozen
+        if batch.executed or batch.complete:
+            return False
+
+        # user must have edit_row perm
+        if not self.has_perm('edit_row'):
+            return False
+
+        # config must allow this generally
+        if not self.batch_handler.allow_receiving_edit_catalog_unit_cost():
+            return False
+
+        return True
+
+    def allow_edit_invoice_unit_cost(self, batch):
+
+        # batch must not yet be frozen
+        if batch.executed or batch.complete:
+            return False
+
+        # user must have edit_row perm
+        if not self.has_perm('edit_row'):
+            return False
+
+        # config must allow this generally
+        if not self.batch_handler.allow_receiving_edit_invoice_unit_cost():
+            return False
+
+        return True
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        batch = kwargs['instance']
+
+        if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch):
+            breakdown = self.make_po_vs_invoice_breakdown(batch)
+            factory = self.get_grid_factory()
+
+            g = factory(self.request,
+                        key='batch_po_vs_invoice_breakdown',
+                        data=[],
+                        columns=['title', 'count'])
+            g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)")
+            kwargs['po_vs_invoice_breakdown_data'] = breakdown
+            kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal(
+                g.render_table_element(data_prop='poVsInvoiceBreakdownData',
+                                       empty_labels=True))
+
+        kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch)
+        kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch)
+
+        if (kwargs['allow_edit_catalog_unit_cost']
+            and kwargs['allow_edit_invoice_unit_cost']
+            and not batch.get_param('confirmed_all_costs')):
+            kwargs['allow_confirm_all_costs'] = True
+        else:
+            kwargs['allow_confirm_all_costs'] = False
+
+        return kwargs
+
+    def get_context_credits(self, row):
+        app = self.get_rattail_app()
+        credits_data = []
+        for credit in row.credits:
+            credits_data.append({
+                'uuid': credit.uuid,
+                'credit_type': credit.credit_type,
+                'expiration_date': str(credit.expiration_date) if credit.expiration_date else None,
+                'cases_shorted': app.render_quantity(credit.cases_shorted),
+                'units_shorted': app.render_quantity(credit.units_shorted),
+                'shorted': app.render_cases_units(credit.cases_shorted,
+                                                  credit.units_shorted),
+                'credit_total': app.render_currency(credit.credit_total),
+                'mispick_upc': '-',
+                'mispick_brand_name': '-',
+                'mispick_description': '-',
+                'mispick_size': '-',
+            })
+        return credits_data
+
+    def template_kwargs_view_row(self, **kwargs):
+        kwargs = super().template_kwargs_view_row(**kwargs)
+        app = self.get_rattail_app()
+        products_handler = app.get_products_handler()
+        row = kwargs['instance']
+
+        kwargs['allow_cases'] = self.batch_handler.allow_cases()
+
+        if row.product:
+            kwargs['image_url'] = products_handler.get_image_url(row.product)
+        elif row.upc:
+            kwargs['image_url'] = products_handler.get_image_url(upc=row.upc)
+
+        kwargs['row_context'] = self.get_context_row(row)
+
+        modes = list(POSSIBLE_RECEIVING_MODES)
+        types = list(POSSIBLE_CREDIT_TYPES)
+        if not self.batch_handler.allow_expired_credits():
+            if 'expired' in modes:
+                modes.remove('expired')
+            if 'expired' in types:
+                types.remove('expired')
+        kwargs['possible_receiving_modes'] = modes
+        kwargs['possible_credit_types'] = types
+
         return kwargs
 
     def department_for_purchase(self, purchase):
@@ -506,7 +723,7 @@ class ReceivingBatchView(PurchasingBatchView):
         if batch.is_truck_dump_parent():
             for child in batch.truck_dump_children:
                 self.delete_instance(child)
-        super(ReceivingBatchView, self).delete_instance(batch)
+        super().delete_instance(batch)
         if truck_dump:
             self.handler.refresh(truck_dump)
 
@@ -580,6 +797,9 @@ class ReceivingBatchView(PurchasingBatchView):
 
         self.configure_form(f)
 
+        # cancel should go back to truck dump parent
+        f.cancel_url = self.get_action_url('view', truck_dump)
+
         f.set_fields([
             'batch_type',
             'truck_dump_parent',
@@ -594,6 +814,7 @@ class ReceivingBatchView(PurchasingBatchView):
         # batch_type
         f.set_widget('batch_type', forms.widgets.ReadonlyWidget())
         f.set_default('batch_type', 'truck_dump_child_from_invoice')
+        f.set_hidden('batch_type', False)
 
         # truck_dump_batch_uuid
         f.set_readonly('truck_dump_parent')
@@ -604,238 +825,133 @@ class ReceivingBatchView(PurchasingBatchView):
 
     def render_truck_dump_parent(self, batch, field):
         truck_dump = self.get_instance()
-        text = six.text_type(truck_dump)
+        text = str(truck_dump)
         url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
         return tags.link_to(text, url)
 
-    def render_mobile_listitem(self, batch, i):
-        title = "({}) {} for ${:0,.2f} - {}, {}".format(
-            batch.id_str,
-            batch.vendor,
-            batch.invoice_total or batch.po_total or 0,
-            batch.department,
-            batch.created_by)
-        return title
-
-    def make_mobile_row_filters(self):
-        """
-        Returns a set of filters for the mobile row grid.
-        """
-        batch = self.get_instance()
-        filters = grids.filters.GridFilterSet()
-
-        # visible filter options will depend on whether batch came from purchase
-        if batch.order_quantities_known:
-            value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'invalid', 'all']
-            default_status = 'incomplete'
-        else:
-            value_choices = ['received', 'damaged', 'expired', 'invalid', 'all']
-            default_status = 'all'
-
-        # remove 'expired' filter option if not relevant
-        if 'expired' in value_choices and not self.handler.allow_expired_credits():
-            value_choices.remove('expired')
-
-        filters['status'] = MobileItemStatusFilter('status',
-                                                   value_choices=value_choices,
-                                                   default_value=default_status)
-        return filters
-
-    def mobile_create(self):
-        """
-        Mobile view for creating a new receiving batch
-        """
-        mode = self.batch_mode
-        data = {'mode': mode}
-        phase = 1
-
-        schema = MobileNewReceivingBatch().bind(session=self.Session())
-        form = forms.Form(schema=schema, request=self.request)
-        if form.validate(newstyle=True):
-            phase = form.validated['phase']
-
-            if form.validated['workflow'] == 'from_scratch':
-                if not self.allow_from_scratch:
-                    raise NotImplementedError("Requested workflow not supported: from_scratch")
-                batch = self.model_class()
-                batch.store = self.rattail_config.get_store(self.Session())
-                batch.mode = mode
-                batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
-                batch.created_by = self.request.user
-                batch.date_received = localtime(self.rattail_config).date()
-                kwargs = self.get_batch_kwargs(batch, mobile=True)
-                batch = self.handler.make_batch(self.Session(), **kwargs)
-                return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-            elif form.validated['workflow'] == 'truck_dump':
-                if not self.allow_truck_dump:
-                    raise NotImplementedError("Requested workflow not supported: truck_dump")
-                batch = self.model_class()
-                batch.store = self.rattail_config.get_store(self.Session())
-                batch.mode = mode
-                batch.truck_dump = True
-                batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
-                batch.created_by = self.request.user
-                batch.date_received = localtime(self.rattail_config).date()
-                kwargs = self.get_batch_kwargs(batch, mobile=True)
-                batch = self.handler.make_batch(self.Session(), **kwargs)
-                return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-            elif form.validated['workflow'] == 'from_po':
-                if not self.allow_from_po:
-                    raise NotImplementedError("Requested workflow not supported: from_po")
-
-                vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
-                data['vendor'] = vendor
-
-                schema = self.make_mobile_receiving_from_po_schema()
-                po_form = forms.Form(schema=schema, request=self.request)
-                if phase == 2:
-                    if po_form.validate(newstyle=True):
-                        batch = self.model_class()
-                        batch.store = self.rattail_config.get_store(self.Session())
-                        batch.mode = mode
-                        batch.vendor = vendor
-                        batch.buyer = self.request.user.employee
-                        batch.created_by = self.request.user
-                        batch.date_received = localtime(self.rattail_config).date()
-                        self.assign_purchase_order(batch, po_form)
-                        kwargs = self.get_batch_kwargs(batch, mobile=True)
-                        batch = self.handler.make_batch(self.Session(), **kwargs)
-                        if self.handler.should_populate(batch):
-                            self.handler.populate(batch)
-                        return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-                else:
-                    phase = 2
-
-            else:
-                raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow']))
-
-        data['form'] = form
-        data['dform'] = form.make_deform_form()
-        data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize()
-        data['phase'] = phase
-
-        if phase == 1:
-            data['vendor_use_autocomplete'] = self.rattail_config.getbool(
-                'rattail', 'vendor.use_autocomplete', default=True)
-            if not data['vendor_use_autocomplete']:
-                vendors = self.Session.query(model.Vendor)\
-                                      .order_by(model.Vendor.name)
-                options = [(tags.Option(vendor.name, vendor.uuid))
-                           for vendor in vendors]
-                options.insert(0, tags.Option("(please choose)", ''))
-                data['vendor_options'] = options
-
-        elif phase == 2:
-            purchases = self.eligible_purchases(vendor.uuid, mode=mode)
-            data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']]
-            data['purchase_order_fieldname'] = self.purchase_order_fieldname
-
-        return self.render_to_response('create', data, mobile=True)
-
-    def make_mobile_receiving_from_po_schema(self):
-        schema = colander.MappingSchema()
-        schema.add(colander.SchemaNode(colander.String(),
-                                       name=self.purchase_order_fieldname,
-                                       validator=self.validate_purchase))
-        return schema.bind(session=self.Session())
-
-    @staticmethod
-    @colander.deferred
-    def validate_purchase(node, kw):
-        session = kw['session']
-        def validate(node, value):
-            purchase = session.query(model.Purchase).get(value)
-            if not purchase:
-                raise colander.Invalid(node, "Purchase not found")
-            return purchase.uuid
-        return validate
+    # TODO: is this actually used?  wait to see if something breaks..
+    # @staticmethod
+    # @colander.deferred
+    # def validate_purchase(node, kw):
+    #     session = kw['session']
+    #     def validate(node, value):
+    #         purchase = session.get(model.Purchase, value)
+    #         if not purchase:
+    #             raise colander.Invalid(node, "Purchase not found")
+    #         return purchase.uuid
+    #     return validate
 
     def assign_purchase_order(self, batch, po_form):
         """
         Assign the original purchase order to the given batch.  Default
         behavior assumes a Rattail Purchase object is what we're after.
         """
+        field = self.batch_handler.get_purchase_order_fieldname()
         purchase = self.handler.assign_purchase_order(
-            batch, po_form.validated[self.purchase_order_fieldname],
+            batch, po_form.validated[field],
             session=self.Session())
 
         department = self.department_for_purchase(purchase)
         if department:
             batch.department_uuid = department.uuid
 
-    def configure_mobile_form(self, f):
-        super(ReceivingBatchView, self).configure_mobile_form(f)
-        batch = f.model_instance
-
-        # truck_dump
-        if not self.creating:
-            if not batch.is_truck_dump_parent():
-                f.remove_field('truck_dump')
-
-        # department
-        if not self.creating:
-            if batch.is_truck_dump_parent():
-                f.remove_field('department')
-
     def configure_row_grid(self, g):
-        super(ReceivingBatchView, self).configure_row_grid(g)
-        g.set_label('department_name', "Department")
+        super().configure_row_grid(g)
+        model = self.model
+        batch = self.get_instance()
 
         # vendor_code
         g.filters['vendor_code'].default_active = True
         g.filters['vendor_code'].default_verb = 'contains'
 
         # catalog_unit_cost
-        g.set_renderer('catalog_unit_cost', self.render_row_grid_cost)
-        g.set_label('catalog_unit_cost', "Catalog Cost")
-        g.filters['catalog_unit_cost'].label = "Catalog Unit Cost"
+        g.set_renderer('catalog_unit_cost', self.render_simple_unit_cost)
+        if self.allow_edit_catalog_unit_cost(batch):
+            g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost)
+            g.set_click_handler('catalog_unit_cost',
+                                'this.catalogUnitCostClicked')
 
         # invoice_unit_cost
-        g.set_renderer('invoice_unit_cost', self.render_row_grid_cost)
-        g.set_label('invoice_unit_cost', "Invoice Cost")
-        g.filters['invoice_unit_cost'].label = "Invoice Unit Cost"
+        g.set_renderer('invoice_unit_cost', self.render_simple_unit_cost)
+        if self.allow_edit_invoice_unit_cost(batch):
+            g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost)
+            g.set_click_handler('invoice_unit_cost',
+                                'this.invoiceUnitCostClicked')
 
-        # credits
-        # note that sorting by credits involves a subquery with group by clause.
-        # seems likely there may be a better way? but this seems to work fine
-        Credits = self.Session.query(model.PurchaseBatchCredit.row_uuid,
-                                     sa.func.count().label('credit_count'))\
-                              .group_by(model.PurchaseBatchCredit.row_uuid)\
-                              .subquery()
-        g.set_joiner('credits', lambda q: q.outerjoin(Credits))
-        g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)())
+        show_ordered = self.rattail_config.getbool(
+            'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid',
+            default=False)
+        if not show_ordered:
+            g.remove('cases_ordered',
+                     'units_ordered')
+
+        show_shipped = self.rattail_config.getbool(
+            'rattail.batch', 'purchase.receiving.show_shipped_column_in_grid',
+            default=False)
+        if not show_shipped:
+            g.remove('cases_shipped',
+                     'units_shipped')
 
         # hide 'ordered' columns for truck dump parent, if its "children first"
         # flag is set, since that batch type is only concerned with receiving
-        batch = self.get_instance()
         if batch.is_truck_dump_parent() and not batch.truck_dump_children_first:
-            g.hide_column('cases_ordered')
-            g.hide_column('units_ordered')
+            g.remove('cases_ordered',
+                     'units_ordered')
 
         # add "Transform to Unit" action, if appropriate
         if batch.is_truck_dump_parent():
             permission_prefix = self.get_permission_prefix()
             if self.request.has_perm('{}.edit_row'.format(permission_prefix)):
-                transform = grids.GridAction('transform',
+                transform = self.make_action('transform',
                                              icon='shuffle',
                                              label="Transform to Unit",
                                              url=self.transform_unit_url)
-                g.more_actions.append(transform)
-                if g.main_actions and g.main_actions[-1].key == 'delete':
-                    delete = g.main_actions.pop()
-                    g.more_actions.append(delete)
+                if g.actions and g.actions[-1].key == 'delete':
+                    delete = g.actions.pop()
+                    g.actions.append(transform)
+                    g.actions.append(delete)
+                else:
+                    g.actions.append(transform)
 
         # truck_dump_status
         if not batch.is_truck_dump_parent():
-            g.hide_column('truck_dump_status')
+            g.remove('truck_dump_status')
         else:
             g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
 
+    def render_simple_unit_cost(self, row, field):
+        value = getattr(row, field)
+        if value is None:
+            return
+
+        # TODO: if anyone ever wants to see "raw" costs displayed,
+        # should make this configurable, b/c some folks already wanted
+        # the shorter 2-decimal display
+        #return str(value)
+
+        app = self.get_rattail_app()
+        return app.render_currency(value)
+
+    def render_catalog_unit_cost(self):
+        return HTML.tag('receiving-cost-editor', **{
+            'field': 'catalog_unit_cost',
+            'v-model': 'props.row.catalog_unit_cost',
+            ':ref': "'catalogUnitCost_' + props.row.uuid",
+            ':row': 'props.row',
+            '@input': 'catalogCostConfirmed',
+        })
+
+    def render_invoice_unit_cost(self):
+        return HTML.tag('receiving-cost-editor', **{
+            'field': 'invoice_unit_cost',
+            'v-model': 'props.row.invoice_unit_cost',
+            ':ref': "'invoiceUnitCost_' + props.row.uuid",
+            ':row': 'props.row',
+            '@input': 'invoiceCostConfirmed',
+        })
+
     def row_grid_extra_class(self, row, i):
-        css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i)
+        css_class = super().row_grid_extra_class(row, i)
 
         if row.catalog_cost_confirmed:
             css_class = '{} catalog_cost_confirmed'.format(css_class or '')
@@ -845,11 +961,12 @@ class ReceivingBatchView(PurchasingBatchView):
 
         return css_class
 
-    def render_row_grid_cost(self, row, field):
-        cost = getattr(row, field)
-        if cost is None:
-            return ""
-        return "{:0,.3f}".format(cost)
+    def get_row_instance_title(self, row):
+        if row.product:
+            return str(row.product)
+        if row.upc:
+            return row.upc.pretty()
+        return super().get_row_instance_title(row)
 
     def transform_unit_url(self, row, i):
         # grid action is shown only when we return a URL here
@@ -858,17 +975,92 @@ class ReceivingBatchView(PurchasingBatchView):
                 if row.product and row.product.is_pack_item():
                     return self.get_row_action_url('transform_unit', row)
 
-    def receive_row(self, mobile=False):
+    def make_row_credits_grid(self, row):
+
+        # first make grid like normal
+        g = super().make_row_credits_grid(row)
+
+        if (self.has_perm('edit_row')
+            and self.row_editable(row)):
+
+            # add the Un-Declare action
+            g.actions.append(self.make_action(
+                'remove', label="Un-Declare",
+                url='#', icon='trash',
+                link_class='has-text-danger',
+                click_handler='removeCreditInit(props.row)'))
+
+        return g
+
+    def vuejs_convert_quantity(self, cstruct):
+        result = dict(cstruct)
+        if result['cases'] is colander.null:
+            result['cases'] = None
+        elif isinstance(result['cases'], decimal.Decimal):
+            result['cases'] = float(result['cases'])
+        if result['units'] is colander.null:
+            result['units'] = None
+        elif isinstance(result['units'], decimal.Decimal):
+            result['units'] = float(result['units'])
+        return result
+
+    def receive_row(self, **kwargs):
         """
         Primary desktop view for row-level receiving.
         """
+        app = self.get_rattail_app()
         # TODO: this code was largely copied from mobile_receive_row() but it
         # tries to pave the way for shared logic, i.e. where the latter would
         # simply invoke this method and return the result.  however we're not
         # there yet...for now it's only tested for desktop
-        self.mobile = mobile
         self.viewing = True
         row = self.get_row_instance()
+
+        # don't even bother showing this page if that's all the
+        # request was about
+        if self.request.method == 'GET':
+            return self.redirect(self.get_row_action_url('view', row))
+
+        # make sure edit is allowed
+        if not (self.has_perm('edit_row') and self.row_editable(row)):
+            raise self.forbidden()
+
+        # check for JSON POST, which is submitted via AJAX from
+        # the "view row" page
+        if self.request.method == 'POST' and not self.request.POST:
+            data = self.request.json_body
+            kwargs = dict(data)
+
+            # TODO: for some reason quantities can come through as strings?
+            cases = kwargs['quantity']['cases']
+            if cases is not None:
+                if cases == '':
+                    cases = None
+                else:
+                    cases = decimal.Decimal(cases)
+            kwargs['cases'] = cases
+            units = kwargs['quantity']['units']
+            if units is not None:
+                if units == '':
+                    units = None
+                else:
+                    units = decimal.Decimal(units)
+            kwargs['units'] = units
+            del kwargs['quantity']
+
+            # handler takes care of the receiving logic for us
+            try:
+                self.batch_handler.receive_row(row, **kwargs)
+
+            except Exception as error:
+                return self.json_response({'error': str(error)})
+
+            self.Session.flush()
+            self.Session.refresh(row)
+            return self.json_response({
+                'ok': True,
+                'row': self.get_context_row(row)})
+
         batch = row.batch
         permission_prefix = self.get_permission_prefix()
         possible_modes = [
@@ -890,25 +1082,27 @@ class ReceivingBatchView(PurchasingBatchView):
             'quick_receive_all': False,
         }
 
-        if mobile:
-            context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
-                                                                   default=True)
-            if batch.order_quantities_known:
-                context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
-                                                                           default=False)
-
         schema = ReceiveRowForm().bind(session=self.Session())
         form = forms.Form(schema=schema, request=self.request)
-        form.cancel_url = self.get_row_action_url('view', row, mobile=mobile)
-        form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=[(m, m) for m in possible_modes]))
+        form.cancel_url = self.get_row_action_url('view', row)
+
+        # mode
+        mode_values = [(mode, mode) for mode in possible_modes]
+        mode_widget = dfwidget.SelectWidget(values=mode_values)
+        form.set_widget('mode', mode_widget)
+
+        # quantity
         form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True,
                                                                    one_amount_only=True))
+        form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity)
+
+        # expiration_date
         form.set_type('expiration_date', 'date_jquery')
 
-        if not mobile:
-            form.remove_field('quick_receive')
+        # TODO: what is this one about again?
+        form.remove_field('quick_receive')
 
-        if form.validate(newstyle=True):
+        if form.validate():
 
             # handler takes care of the row receiving logic for us
             kwargs = dict(form.validated)
@@ -921,20 +1115,17 @@ class ReceivingBatchView(PurchasingBatchView):
             # whether or not it was 'CS' since the unit_uom can vary
             # TODO: should this be done for desktop too somehow?
             sticky_case = None
-            if mobile and not form.validated['quick_receive']:
-                cases = form.validated['cases']
-                units = form.validated['units']
-                if cases and not units:
-                    sticky_case = True
-                elif units and not cases:
-                    sticky_case = False
+            # if mobile and not form.validated['quick_receive']:
+            #     cases = form.validated['cases']
+            #     units = form.validated['units']
+            #     if cases and not units:
+            #         sticky_case = True
+            #     elif units and not cases:
+            #         sticky_case = False
             if sticky_case is not None:
                 self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
 
-            if mobile:
-                return self.redirect(self.get_action_url('view', batch, mobile=True))
-            else:
-                return self.redirect(self.get_row_action_url('view', row))
+            return self.redirect(self.get_row_action_url('view', row))
 
         # unit_uom can vary by product
         context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
@@ -951,7 +1142,7 @@ class ReceivingBatchView(PurchasingBatchView):
                 if accounted_for:
                     # some product accounted for; button should receive "remainder" only
                     if remainder:
-                        remainder = pretty_quantity(remainder)
+                        remainder = app.render_quantity(remainder)
                         context['quick_receive_quantity'] = remainder
                         context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
                     else:
@@ -961,16 +1152,16 @@ class ReceivingBatchView(PurchasingBatchView):
                 else: # nothing yet accounted for, button should receive "all"
                     if not remainder:
                         raise ValueError("why is remainder empty?")
-                    remainder = pretty_quantity(remainder)
+                    remainder = app.render_quantity(remainder)
                     context['quick_receive_quantity'] = remainder
                     context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
 
         # effective uom can vary in a few ways...the basic default is 'CS' if
         # self.default_uom_is_case is true, otherwise whatever unit_uom is.
         sticky_case = None
-        if mobile:
-            # TODO: should do this for desktop also, but rename the session variable
-            sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
+        # if mobile:
+        #     # TODO: should do this for desktop also, but rename the session variable
+        #     sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
         if sticky_case is None:
             context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
         elif sticky_case:
@@ -980,37 +1171,37 @@ class ReceivingBatchView(PurchasingBatchView):
         if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
             context['uom'] = context['unit_uom']
 
-        # TODO: should do this for desktop in addition to mobile?
-        if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
-            warn = True
-            if batch.is_truck_dump_parent() and row.product:
-                uuids = [child.uuid for child in batch.truck_dump_children]
-                if uuids:
-                    count = self.Session.query(model.PurchaseBatchRow)\
-                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
-                                        .filter(model.PurchaseBatchRow.product == row.product)\
-                                        .count()
-                    if count:
-                        warn = False
-            if warn:
-                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
+        # # TODO: should do this for desktop in addition to mobile?
+        # if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
+        #     warn = True
+        #     if batch.is_truck_dump_parent() and row.product:
+        #         uuids = [child.uuid for child in batch.truck_dump_children]
+        #         if uuids:
+        #             count = self.Session.query(model.PurchaseBatchRow)\
+        #                                 .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
+        #                                 .filter(model.PurchaseBatchRow.product == row.product)\
+        #                                 .count()
+        #             if count:
+        #                 warn = False
+        #     if warn:
+        #         self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
 
-        # TODO: should do this for desktop in addition to mobile?
-        if mobile:
-            # maybe alert user if they've already received some of this product
-            alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
-                                                         default=False)
-            if alert_received:
-                if self.handler.get_units_confirmed(row):
-                    msg = "You have already received some of this product; last update was {}.".format(
-                        humanize.naturaltime(make_utc() - row.modified))
-                    self.request.session.flash(msg, 'receiving-warning')
+        # # TODO: should do this for desktop in addition to mobile?
+        # if mobile:
+        #     # maybe alert user if they've already received some of this product
+        #     alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
+        #                                                  default=False)
+        #     if alert_received:
+        #         if self.handler.get_units_confirmed(row):
+        #             msg = "You have already received some of this product; last update was {}.".format(
+        #                 humanize.naturaltime(make_utc() - row.modified))
+        #             self.request.session.flash(msg, 'receiving-warning')
 
         context['form'] = form
         context['dform'] = form.make_deform_form()
-        context['parent_url'] = self.get_action_url('view', batch, mobile=mobile)
+        context['parent_url'] = self.get_action_url('view', batch)
         context['parent_title'] = self.get_instance_title(batch)
-        return self.render_to_response('receive_row', context, mobile=mobile)
+        return self.render_to_response('receive_row', context)
 
     def declare_credit(self):
         """
@@ -1018,11 +1209,56 @@ class ReceivingBatchView(PurchasingBatchView):
         quantity, to a credit of some sort.
         """
         row = self.get_row_instance()
+
+        # don't even bother showing this page if that's all the
+        # request was about
+        if self.request.method == 'GET':
+            return self.redirect(self.get_row_action_url('view', row))
+
+        # make sure edit is allowed
+        if not (self.has_perm('edit_row') and self.row_editable(row)):
+            raise self.forbidden()
+
+        # check for JSON POST, which is submitted via AJAX from
+        # the "view row" page
+        if self.request.method == 'POST' and not self.request.POST:
+            data = self.request.json_body
+            kwargs = dict(data)
+
+            # TODO: for some reason quantities can come through as strings?
+            if kwargs['cases'] is not None:
+                if kwargs['cases'] == '':
+                    kwargs['cases'] = None
+                else:
+                    kwargs['cases'] = decimal.Decimal(kwargs['cases'])
+            if kwargs['units'] is not None:
+                if kwargs['units'] == '':
+                    kwargs['units'] = None
+                else:
+                    kwargs['units'] = decimal.Decimal(kwargs['units'])
+
+            try:
+                result = self.handler.can_declare_credit(row, **kwargs)
+
+            except Exception as error:
+                return self.json_response({'error': str(error)})
+
+            else:
+                if result:
+                    self.handler.declare_credit(row, **kwargs)
+
+                else:
+                    return self.json_response({
+                        'error': "Handler says you can't declare that credit; "
+                        "not sure why"})
+
+            self.Session.flush()
+            self.Session.refresh(row)
+            return self.json_response({
+                'ok': True,
+                'row': self.get_context_row(row)})
+
         batch = row.batch
-        possible_credit_types = [
-            'damaged',
-            'expired',
-        ]
         context = {
             'row': row,
             'batch': batch,
@@ -1037,13 +1273,22 @@ class ReceivingBatchView(PurchasingBatchView):
 
         schema = DeclareCreditForm()
         form = forms.Form(schema=schema, request=self.request)
-        form.set_widget('credit_type', forms.widgets.JQuerySelectWidget(
-            values=[(m, m) for m in possible_credit_types]))
+        form.cancel_url = self.get_row_action_url('view', row)
+
+        # credit_type
+        values = [(m, m) for m in POSSIBLE_CREDIT_TYPES]
+        widget = dfwidget.SelectWidget(values=values)
+        form.set_widget('credit_type', widget)
+
+        # quantity
         form.set_widget('quantity', forms.widgets.CasesUnitsWidget(
             amount_required=True, one_amount_only=True))
+        form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity)
+
+        # expiration_date
         form.set_type('expiration_date', 'date_jquery')
 
-        if form.validate(newstyle=True):
+        if form.validate():
 
             # handler takes care of the row receiving logic for us
             kwargs = dict(form.validated)
@@ -1067,16 +1312,65 @@ class ReceivingBatchView(PurchasingBatchView):
         context['parent_title'] = self.get_instance_title(batch)
         return self.render_to_response('declare_credit', context)
 
+    def undeclare_credit(self):
+        """
+        View for un-declaring a credit, i.e. moving the credit amounts
+        back into the "received" tally.
+        """
+        model = self.model
+        row = self.get_row_instance()
+        data = self.request.json_body
+
+        # make sure edit is allowed
+        if not (self.has_perm('edit_row') and self.row_editable(row)):
+            raise self.forbidden()
+
+        # figure out which credit to un-declare
+        credit = None
+        uuid = data.get('uuid')
+        if uuid:
+            credit = self.Session.get(model.PurchaseBatchCredit, uuid)
+        if not credit:
+            return {'error': "Credit not found"}
+
+        # un-declare it
+        self.batch_handler.undeclare_credit(row, credit)
+        self.Session.flush()
+        self.Session.refresh(row)
+
+        return {'ok': True,
+                'row': self.get_context_row(row)}
+
+    def get_context_row(self, row):
+        app = self.get_rattail_app()
+        return {
+            'sequence': row.sequence,
+            'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None,
+            'ordered': self.render_row_quantity(row, 'ordered'),
+            'shipped': self.render_row_quantity(row, 'shipped'),
+            'received': self.render_row_quantity(row, 'received'),
+            'cases_received': float(row.cases_received) if row.cases_received is not None else None,
+            'units_received': float(row.units_received) if row.units_received is not None else None,
+            'damaged': self.render_row_quantity(row, 'damaged'),
+            'expired': self.render_row_quantity(row, 'expired'),
+            'mispick': self.render_row_quantity(row, 'mispick'),
+            'missing': self.render_row_quantity(row, 'missing'),
+            'credits': self.get_context_credits(row),
+            'invoice_total_calculated': app.render_currency(row.invoice_total_calculated),
+            'status': row.STATUS[row.status_code],
+        }
+
     def transform_unit_row(self):
         """
         View which transforms the given row, which is assumed to associate with
         a "pack" item, such that it instead associates with the "unit" item,
         with quantities adjusted accordingly.
         """
+        model = self.model
         batch = self.get_instance()
 
         row_uuid = self.request.params.get('row_uuid')
-        row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None
+        row = self.Session.get(model.PurchaseBatchRow, row_uuid) if row_uuid else None
         if row and row.batch is batch and not row.removed:
             pass # we're good
         else:
@@ -1117,9 +1411,18 @@ class ReceivingBatchView(PurchasingBatchView):
         })
 
     def configure_row_form(self, f):
-        super(ReceivingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
+        model = self.model
         batch = self.get_instance()
 
+        # when viewing a row which has no product reference, enable
+        # the 'upc' field to help with troubleshooting
+        # TODO: this maybe should be optional..?
+        if self.viewing and 'upc' not in f:
+            row = self.get_row_instance()
+            if not row.product:
+                f.append('upc')
+
         # allow input for certain fields only; all others are readonly
         mutable = [
             'invoice_unit_cost',
@@ -1182,7 +1485,7 @@ class ReceivingBatchView(PurchasingBatchView):
     def validate_row_form(self, form):
 
         # if normal validation fails, stop there
-        if not super(ReceivingBatchView, self).validate_row_form(form):
+        if not super().validate_row_form(form):
             return False
 
         # if user is editing row from truck dump child, then we must further
@@ -1286,6 +1589,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return True
 
     def save_edit_row_form(self, form):
+        model = self.model
         batch = self.get_instance()
         row = self.objectify(form)
 
@@ -1418,19 +1722,21 @@ class ReceivingBatchView(PurchasingBatchView):
         self.Session.flush()
         return row
 
-    def redirect_after_edit_row(self, row, mobile=False):
-        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
+    def redirect_after_edit_row(self, row, **kwargs):
+        return self.redirect(self.get_row_action_url('view', row))
 
     def update_row_cost(self):
         """
-        AJAX view for updating the invoice (actual) unit cost for a row.
+        AJAX view for updating various cost fields in a data row.
         """
+        app = self.get_rattail_app()
+        model = self.model
         batch = self.get_instance()
-        data = dict(self.request.POST)
+        data = dict(get_form_data(self.request))
 
         # validate row
         uuid = data.get('row_uuid')
-        row = self.Session.query(model.PurchaseBatchRow).get(uuid) if uuid else None
+        row = self.Session.get(model.PurchaseBatchRow, uuid) if uuid else None
         if not row or row.batch is not batch:
             return {'error': "Row not found"}
 
@@ -1441,7 +1747,7 @@ class ReceivingBatchView(PurchasingBatchView):
                 if cost == '':
                     return {'error': "You must specify a cost"}
                 try:
-                    cost = decimal.Decimal(six.text_type(cost))
+                    cost = decimal.Decimal(str(cost))
                 except decimal.InvalidOperation:
                     return {'error': "Cost is not valid!"}
                 else:
@@ -1450,341 +1756,69 @@ class ReceivingBatchView(PurchasingBatchView):
         # okay, update our row
         self.handler.update_row_cost(row, **data)
 
+        self.Session.flush()
+        self.Session.refresh(row)
         return {
             'row': {
-                'catalog_unit_cost': '{:0.3f}'.format(row.catalog_unit_cost),
+                'catalog_unit_cost': self.render_simple_unit_cost(row, 'catalog_unit_cost'),
                 'catalog_cost_confirmed': row.catalog_cost_confirmed,
-                'invoice_unit_cost': '{:0.3f}'.format(row.invoice_unit_cost),
+                'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'),
                 'invoice_cost_confirmed': row.invoice_cost_confirmed,
-                'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated),
+                'invoice_total_calculated': app.render_currency(row.invoice_total_calculated),
             },
             'batch': {
-                'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated),
+                'invoice_total_calculated': app.render_currency(batch.invoice_total_calculated),
             },
         }
 
-    def render_mobile_row_listitem(self, row, i):
-        key = self.render_product_key_value(row)
-        description = row.product.full_description if row.product else row.description
-        return "({}) {}".format(key, description)
-
-    def make_mobile_row_grid_kwargs(self, **kwargs):
-        kwargs = super(ReceivingBatchView, self).make_mobile_row_grid_kwargs(**kwargs)
-
-        # use custom `receive_row` instead of `view_row`
-        # TODO: should still use `view_row` in some cases? e.g. executed batch
-        kwargs['url'] = lambda obj: self.get_row_action_url('receive', obj, mobile=True)
-
-        return kwargs
-
     def save_quick_row_form(self, form):
         batch = self.get_instance()
         entry = form.validated['quick_entry']
         row = self.handler.quick_entry(self.Session(), batch, entry)
         return row
 
-    def redirect_after_quick_row(self, row, mobile=False):
-        if mobile:
-            return self.redirect(self.get_row_action_url('receive', row, mobile=mobile))
-        return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile)
-
     def get_row_image_url(self, row):
         if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
             return pod.get_image_url(self.rattail_config, row.upc)
 
-    def get_mobile_data(self, session=None):
-        query = super(ReceivingBatchView, self).get_mobile_data(session=session)
-
-        # do not expose truck dump child batches on mobile
-        # TODO: is there any case where we *would* want to?
-        query = query.filter(model.PurchaseBatch.truck_dump_batch == None)
-
-        return query
-
-    def mobile_view_row(self):
-        """
-        Mobile view for receiving batch row items.  Note that this also handles
-        updating a row.
-        """
-        self.mobile = True
-        self.viewing = True
-        row = self.get_row_instance()
-        batch = row.batch
-        permission_prefix = self.get_permission_prefix()
-        form = self.make_mobile_row_form(row)
-        context = {
-            'row': row,
-            'batch': batch,
-            'parent_instance': batch,
-            'instance': row,
-            'instance_title': self.get_row_instance_title(row),
-            'parent_model_title': self.get_model_title(),
-            'product_image_url': self.get_row_image_url(row),
-            'form': form,
-            'allow_expired': self.handler.allow_expired_credits(),
-            'allow_cases': self.handler.allow_cases(),
-            'quick_receive': False,
-            'quick_receive_all': False,
-        }
-
-        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
-                                                               default=True)
-        if batch.order_quantities_known:
-            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
-                                                                       default=False)
-
-        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
-            schema = MobileReceivingForm().bind(session=self.Session())
-            update_form = forms.Form(schema=schema, request=self.request)
-            # TODO: this seems hacky, but avoids "complex" date value parsing
-            update_form.set_widget('expiration_date', dfwidget.TextInputWidget())
-            if update_form.validate(newstyle=True):
-                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
-                mode = update_form.validated['mode']
-                cases = update_form.validated['cases']
-                units = update_form.validated['units']
-
-                # handler takes care of the row receiving logic for us
-                kwargs = dict(update_form.validated)
-                del kwargs['row']
-                self.handler.receive_row(row, **kwargs)
-
-                # keep track of last-used uom, although we just track
-                # whether or not it was 'CS' since the unit_uom can vary
-                sticky_case = None
-                if not update_form.validated['quick_receive']:
-                    if cases and not units:
-                        sticky_case = True
-                    elif units and not cases:
-                        sticky_case = False
-                if sticky_case is not None:
-                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
-
-                return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-        # unit_uom can vary by product
-        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
-
-        if context['quick_receive'] and context['quick_receive_all']:
-            if context['allow_cases']:
-                context['quick_receive_uom'] = 'CS'
-                raise NotImplementedError("TODO: add CS support for quick_receive_all")
-            else:
-                context['quick_receive_uom'] = context['unit_uom']
-                accounted_for = self.handler.get_units_accounted_for(row)
-                remainder = self.handler.get_units_ordered(row) - accounted_for
-
-                if accounted_for:
-                    # some product accounted for; button should receive "remainder" only
-                    if remainder:
-                        remainder = pretty_quantity(remainder)
-                        context['quick_receive_quantity'] = remainder
-                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
-                    else:
-                        # unless there is no remainder, in which case disable it
-                        context['quick_receive'] = False
-
-                else: # nothing yet accounted for, button should receive "all"
-                    if not remainder:
-                        raise ValueError("why is remainder empty?")
-                    remainder = pretty_quantity(remainder)
-                    context['quick_receive_quantity'] = remainder
-                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
-
-        # effective uom can vary in a few ways...the basic default is 'CS' if
-        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
-        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
-        if sticky_case is None:
-            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
-        elif sticky_case:
-            context['uom'] = 'CS'
-        else:
-            context['uom'] = context['unit_uom']
-        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
-            context['uom'] = context['unit_uom']
-
-        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
-            warn = True
-            if batch.is_truck_dump_parent() and row.product:
-                uuids = [child.uuid for child in batch.truck_dump_children]
-                if uuids:
-                    count = self.Session.query(model.PurchaseBatchRow)\
-                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
-                                        .filter(model.PurchaseBatchRow.product == row.product)\
-                                        .count()
-                    if count:
-                        warn = False
-            if warn:
-                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
-        return self.render_to_response('view_row', context, mobile=True)
-
-    def mobile_receive_row(self):
-        """
-        Mobile view for row-level receiving.
-        """
-        self.mobile = True
-        self.viewing = True
-        row = self.get_row_instance()
-        batch = row.batch
-        permission_prefix = self.get_permission_prefix()
-        form = self.make_mobile_row_form(row)
-        context = {
-            'row': row,
-            'batch': batch,
-            'parent_instance': batch,
-            'instance': row,
-            'instance_title': self.get_row_instance_title(row),
-            'parent_model_title': self.get_model_title(),
-            'product_image_url': self.get_row_image_url(row),
-            'form': form,
-            'allow_expired': self.handler.allow_expired_credits(),
-            'allow_cases': self.handler.allow_cases(),
-            'quick_receive': False,
-            'quick_receive_all': False,
-        }
-
-        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
-                                                               default=True)
-        if batch.order_quantities_known:
-            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
-                                                                       default=False)
-
-        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
-            schema = MobileReceivingForm().bind(session=self.Session())
-            update_form = forms.Form(schema=schema, request=self.request)
-            # TODO: this seems hacky, but avoids "complex" date value parsing
-            update_form.set_widget('expiration_date', dfwidget.TextInputWidget())
-            if update_form.validate(newstyle=True):
-                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
-                mode = update_form.validated['mode']
-                cases = update_form.validated['cases']
-                units = update_form.validated['units']
-
-                # handler takes care of the row receiving logic for us
-                kwargs = dict(update_form.validated)
-                del kwargs['row']
-                self.handler.receive_row(row, **kwargs)
-
-                # keep track of last-used uom, although we just track
-                # whether or not it was 'CS' since the unit_uom can vary
-                sticky_case = None
-                if not update_form.validated['quick_receive']:
-                    if cases and not units:
-                        sticky_case = True
-                    elif units and not cases:
-                        sticky_case = False
-                if sticky_case is not None:
-                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
-
-                return self.redirect(self.get_action_url('view', batch, mobile=True))
-
-        # unit_uom can vary by product
-        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
-
-        if context['quick_receive'] and context['quick_receive_all']:
-            if context['allow_cases']:
-                context['quick_receive_uom'] = 'CS'
-                raise NotImplementedError("TODO: add CS support for quick_receive_all")
-            else:
-                context['quick_receive_uom'] = context['unit_uom']
-                accounted_for = self.handler.get_units_accounted_for(row)
-                remainder = self.handler.get_units_ordered(row) - accounted_for
-
-                if accounted_for:
-                    # some product accounted for; button should receive "remainder" only
-                    if remainder:
-                        remainder = pretty_quantity(remainder)
-                        context['quick_receive_quantity'] = remainder
-                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
-                    else:
-                        # unless there is no remainder, in which case disable it
-                        context['quick_receive'] = False
-
-                else: # nothing yet accounted for, button should receive "all"
-                    if not remainder:
-                        raise ValueError("why is remainder empty?")
-                    remainder = pretty_quantity(remainder)
-                    context['quick_receive_quantity'] = remainder
-                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
-
-        # effective uom can vary in a few ways...the basic default is 'CS' if
-        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
-        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
-        if sticky_case is None:
-            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
-        elif sticky_case:
-            context['uom'] = 'CS'
-        else:
-            context['uom'] = context['unit_uom']
-        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
-            context['uom'] = context['unit_uom']
-
-        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
-            warn = True
-            if batch.is_truck_dump_parent() and row.product:
-                uuids = [child.uuid for child in batch.truck_dump_children]
-                if uuids:
-                    count = self.Session.query(model.PurchaseBatchRow)\
-                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
-                                        .filter(model.PurchaseBatchRow.product == row.product)\
-                                        .count()
-                    if count:
-                        warn = False
-            if warn:
-                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
-
-        # maybe alert user if they've already received some of this product
-        alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
-                                                     default=False)
-        if alert_received:
-            if self.handler.get_units_confirmed(row):
-                msg = "You have already received some of this product; last update was {}.".format(
-                    humanize.naturaltime(make_utc() - row.modified))
-                self.request.session.flash(msg, 'receiving-warning')
-
-        return self.render_to_response('receive_row', context, mobile=True)
+    def can_auto_receive(self, batch):
+        return self.handler.can_auto_receive(batch)
 
     def auto_receive(self):
         """
-        View which can "auto-receive" all items in the batch.  Meant only as a
-        convenience for developers.
+        View which can "auto-receive" all items in the batch.
         """
         batch = self.get_instance()
-        key = '{}.receive_all'.format(self.get_grid_key())
-        progress = self.make_progress(key)
-        kwargs = {'progress': progress}
-        thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
-        thread.start()
+        return self.handler_action(batch, 'auto_receive')
 
-        return self.render_progress(progress, {
-            'instance': batch,
-            'cancel_url': self.get_action_url('view', batch),
-            'cancel_msg': "Auto-receive was canceled",
-        })
+    def confirm_all_costs(self):
+        """
+        View which can "confirm all costs" for the batch.
+        """
+        batch = self.get_instance()
+        return self.handler_action(batch, 'confirm_all_receiving_costs')
 
-    def auto_receive_thread(self, uuid, user_uuid, progress=None):
-        """
-        Thread target for receiving all items on the given batch.
-        """
-        session = RattailSession()
-        batch = session.query(model.PurchaseBatch).get(uuid)
-        user = session.query(model.User).get(user_uuid)
+    def confirm_all_receiving_costs_thread(self, uuid, user_uuid, progress=None):
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
+
+        batch = session.get(model.PurchaseBatch, uuid)
+        # user = session.query(model.User).get(user_uuid)
         try:
-            self.handler.auto_receive_all_items(batch, progress=progress)
+            self.handler.confirm_all_receiving_costs(batch, progress=progress)
 
         # if anything goes wrong, rollback and log the error etc.
         except Exception as error:
             session.rollback()
-            log.exception("auto-receive failed for: %s".format(batch))
+            log.exception("failed to confirm all costs for batch: %s", batch)
             session.close()
             if progress:
                 progress.session.load()
                 progress.session['error'] = True
-                progress.session['error_msg'] = "Auto-receive failed: {}".format(
-                    simple_error(error))
+                progress.session['error_msg'] = f"Failed to confirm costs: {simple_error(error)}"
                 progress.session.save()
 
-        # if no error, check result flag (false means user canceled)
         else:
             session.commit()
             session.refresh(batch)
@@ -1796,107 +1830,157 @@ class ReceivingBatchView(PurchasingBatchView):
                 progress.session['success_url'] = success_url
                 progress.session.save()
 
+    def configure_get_simple_settings(self):
+        config = self.rattail_config
+        return [
+
+            # workflows
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_from_scratch',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_from_invoice',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_from_multi_invoice',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_from_purchase_order',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_from_purchase_order_with_invoice',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_truck_dump_receiving',
+             'type': bool},
+
+            # vendors
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_any_vendor',
+             'type': bool},
+            # TODO: deprecated; can remove this once all live config
+            # is updated.  but for now it remains so this setting is
+            # auto-deleted
+            {'section': 'rattail.batch',
+             'option': 'purchase.supported_vendors_only',
+             'type': bool},
+
+            # display
+            {'section': 'rattail.batch',
+             'option': 'purchase.receiving.show_ordered_column_in_grid',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.receiving.show_shipped_column_in_grid',
+             'type': bool},
+
+            # product handling
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_cases',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_decimal_quantities',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_expired_credits',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.receiving.should_autofix_invoice_case_vs_unit',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.receiving.allow_edit_catalog_unit_cost',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.receiving.allow_edit_invoice_unit_cost',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.receiving.auto_missing_credits',
+             'type': bool},
+
+            # mobile interface
+            {'section': 'rattail.batch',
+             'option': 'purchase.mobile_images',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.mobile_quick_receive',
+             'type': bool},
+            {'section': 'rattail.batch',
+             'option': 'purchase.mobile_quick_receive_all',
+             'type': bool},
+        ]
+
+    @classmethod
+    def defaults(cls, config):
+        cls._receiving_defaults(config)
+        cls._purchase_batch_defaults(config)
+        cls._batch_defaults(config)
+        cls._defaults(config)
+
     @classmethod
     def _receiving_defaults(cls, config):
         rattail_config = config.registry.settings.get('rattail_config')
         route_prefix = cls.get_route_prefix()
-        url_prefix = cls.get_url_prefix()
         instance_url_prefix = cls.get_instance_url_prefix()
         model_key = cls.get_model_key()
+        model_title = cls.get_model_title()
         permission_prefix = cls.get_permission_prefix()
-        legacy_mobile = cls.legacy_mobile_enabled(rattail_config)
 
         # row-level receiving
-        config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
+        config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
         config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
                         permission='{}.edit_row'.format(permission_prefix))
-        if legacy_mobile:
-            config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
-            config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
-                            permission='{}.edit_row'.format(permission_prefix))
 
         # declare credit for row
-        config.add_route('{}.declare_credit'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/declare-credit'.format(url_prefix))
+        config.add_route('{}.declare_credit'.format(route_prefix), '{}/rows/{{row_uuid}}/declare-credit'.format(instance_url_prefix))
         config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix),
                         permission='{}.edit_row'.format(permission_prefix))
 
+        # un-declare credit
+        config.add_route('{}.undeclare_credit'.format(route_prefix),
+                         '{}/rows/{{row_uuid}}/undeclare-credit'.format(instance_url_prefix))
+        config.add_view(cls, attr='undeclare_credit',
+                        route_name='{}.undeclare_credit'.format(route_prefix),
+                        permission='{}.edit_row'.format(permission_prefix),
+                        renderer='json')
+
         # update row cost
         config.add_route('{}.update_row_cost'.format(route_prefix), '{}/update-row-cost'.format(instance_url_prefix))
         config.add_view(cls, attr='update_row_cost', route_name='{}.update_row_cost'.format(route_prefix),
                         permission='{}.edit_row'.format(permission_prefix),
                         renderer='json')
 
-        if cls.allow_truck_dump:
+        # add TD child batch, from invoice file
+        config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/add-child-from-invoice'.format(instance_url_prefix))
+        config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
+                        permission='{}.create'.format(permission_prefix))
 
-            # add TD child batch, from invoice file
-            config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key))
-            config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
-                            permission='{}.create'.format(permission_prefix))
+        # transform TD parent row from "pack" to "unit" item
+        config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/transform-unit'.format(instance_url_prefix))
+        config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
+                        permission='{}.edit_row'.format(permission_prefix), renderer='json')
 
-            # transform TD parent row from "pack" to "unit" item
-            config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key))
-            config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
-                            permission='{}.edit_row'.format(permission_prefix), renderer='json')
+        # confirm all costs
+        config.add_route(f'{route_prefix}.confirm_all_costs',
+                         f'{instance_url_prefix}/confirm-all-costs',
+                         request_method='POST')
+        config.add_view(cls, attr='confirm_all_costs',
+                        route_name=f'{route_prefix}.confirm_all_costs',
+                        permission=f'{permission_prefix}.edit_row')
 
-            # auto-receive all items
-            if not rattail_config.production():
-                config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key),
-                                 request_method='POST')
-                config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix),
-                                permission='admin')
-
-
-    @classmethod
-    def defaults(cls, config):
-        cls._receiving_defaults(config)
-        cls._purchasing_defaults(config)
-        cls._batch_defaults(config)
-        cls._defaults(config)
-
-
-# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
-# session is not provided by the view at runtime (i.e. when it was instead
-# being provided by the type instance, which was created upon app startup).
-@colander.deferred
-def valid_vendor(node, kw):
-    session = kw['session']
-    def validate(node, value):
-        vendor = session.query(model.Vendor).get(value)
-        if not vendor:
-            raise colander.Invalid(node, "Vendor not found")
-        return vendor.uuid
-    return validate
-
-
-class MobileNewReceivingBatch(colander.MappingSchema):
-
-    vendor = colander.SchemaNode(colander.String(),
-                                 validator=valid_vendor)
-
-    workflow = colander.SchemaNode(colander.String(),
-                                   validator=colander.OneOf([
-                                       'from_po',
-                                       'from_scratch',
-                                       'truck_dump',
-                                   ]))
-
-    phase = colander.SchemaNode(colander.Int())
-
-
-class MobileNewReceivingFromPO(colander.MappingSchema):
-
-    purchase = colander.SchemaNode(colander.String())
+        # auto-receive all items
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.auto_receive'.format(permission_prefix),
+                                       "Auto-receive all items for a {}".format(model_title))
+        config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix),
+                        permission='{}.auto_receive'.format(permission_prefix))
 
 
 class ReceiveRowForm(colander.MappingSchema):
 
     mode = colander.SchemaNode(colander.String(),
-                               validator=colander.OneOf([
-                                   'received',
-                                   'damaged',
-                                   'expired',
-                                   # 'mispick',
-                               ]))
+                               validator=colander.OneOf(
+                                   POSSIBLE_RECEIVING_MODES))
 
     quantity = forms.types.ProductQuantity()
 
@@ -1906,15 +1990,21 @@ class ReceiveRowForm(colander.MappingSchema):
 
     quick_receive = colander.SchemaNode(colander.Boolean())
 
+    def deserialize(self, *args):
+        result = super().deserialize(*args)
+
+        if result['mode'] == 'expired' and not result['expiration_date']:
+            msg = "Expiration date is required for items with 'expired' mode."
+            self.raise_invalid(msg, node=self.get('expiration_date'))
+
+        return result
+
 
 class DeclareCreditForm(colander.MappingSchema):
 
     credit_type = colander.SchemaNode(colander.String(),
-                                      validator=colander.OneOf([
-                                          'damaged',
-                                          'expired',
-                                          # 'mispick',
-                                      ]))
+                                      validator=colander.OneOf(
+                                          POSSIBLE_CREDIT_TYPES))
 
     quantity = forms.types.ProductQuantity()
 
@@ -1923,5 +2013,12 @@ class DeclareCreditForm(colander.MappingSchema):
                                           missing=colander.null)
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    ReceivingBatchView = kwargs.get('ReceivingBatchView', base['ReceivingBatchView'])
     ReceivingBatchView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py
index 63044c3b..ef090c22 100644
--- a/tailbone/views/reportcodes.py
+++ b/tailbone/views/reportcodes.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -31,13 +31,14 @@ from rattail.db import model
 from tailbone.views import MasterView
 
 
-class ReportCodesView(MasterView):
+class ReportCodeView(MasterView):
     """
     Master view for the ReportCode class.
     """
     model_class = model.ReportCode
     model_title = "Report Code"
     has_versions = True
+    touchable = True
     results_downloadable_xlsx = True
 
     grid_columns = [
@@ -50,14 +51,68 @@ class ReportCodesView(MasterView):
         'name',
     ]
 
+    has_rows = True
+    model_row_class = model.Product
+
+    row_labels = {
+        'upc': "UPC",
+    }
+
+    row_grid_columns = [
+        'upc',
+        'brand',
+        'description',
+        'size',
+        'department',
+        'vendor',
+        'regular_price',
+        'current_price',
+    ]
+
     def configure_grid(self, g):
-        super(ReportCodesView, self).configure_grid(g)
+        super(ReportCodeView, self).configure_grid(g)
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
         g.set_sort_defaults('code')
         g.set_link('code')
         g.set_link('name')
 
+    def get_row_data(self, reportcode):
+        return self.Session.query(model.Product)\
+                           .filter(model.Product.report_code == reportcode)
+
+    def get_parent(self, product):
+        return product.report_code
+
+    def configure_row_grid(self, g):
+        super(ReportCodeView, self).configure_row_grid(g)
+
+        app = self.get_rattail_app()
+        self.handler = app.get_products_handler()
+        g.set_renderer('regular_price', self.render_price)
+        g.set_renderer('current_price', self.render_price)
+
+        g.set_sort_defaults('upc')
+
+    def render_price(self, product, field):
+        if not product.not_for_sale:
+            price = product[field]
+            if price:
+                return self.handler.render_price(price)
+
+    def row_view_action_url(self, product, i):
+        return self.request.route_url('products.view', uuid=product.uuid)
+
+# TODO: deprecate / remove this
+ReportCodesView = ReportCodeView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ReportCodeView = kwargs.get('ReportCodeView', base['ReportCodeView'])
+    ReportCodeView.defaults(config)
+
 
 def includeme(config):
-    ReportCodesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index 6f6b1660..099224be 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,32 +24,29 @@
 Reporting views
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import calendar
+import json
 import re
 import datetime
 import logging
-
-import six
+from collections import OrderedDict
 
 import rattail
-from rattail.db import model, Session as RattailSession
+from rattail.db.model import ReportOutput
 from rattail.files import resource_path
-from rattail.time import localtime
-from rattail.reporting import get_report_handler
 from rattail.threads import Thread
-from rattail.util import simple_error, OrderedDict
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
 from mako.template import Template
 from pyramid.response import Response
-from webhelpers2.html import HTML
+from webhelpers2.html import HTML, tags
 
-from tailbone import forms, grids
+from tailbone import forms
 from tailbone.db import Session
 from tailbone.views import View
-from tailbone.views.exports import ExportMasterView
+from tailbone.views.exports import ExportMasterView, MasterView
 
 
 plu_upc_pattern = re.compile(r'^000000000(\d{5})$')
@@ -63,13 +60,13 @@ def get_upc(product):
     UPC formatter.  Strips PLUs to bare number, and adds "minus check digit"
     for non-PLU UPCs.
     """
-    upc = six.text_type(product.upc)
+    upc = str(product.upc)
     m = plu_upc_pattern.match(upc)
     if m:
-        return six.text_type(int(m.group(1)))
+        return str(int(m.group(1)))
     m = weighted_upc_pattern.match(upc)
     if m:
-        return six.text_type(int(m.group(1)))
+        return str(int(m.group(1)))
     return '{0}-{1}'.format(upc[:-1], upc[-1])
 
 
@@ -83,14 +80,15 @@ class OrderingWorksheet(View):
     upc_getter = staticmethod(get_upc)
 
     def __call__(self):
+        model = self.model
         if self.request.params.get('vendor'):
-            vendor = Session.query(model.Vendor).get(self.request.params['vendor'])
+            vendor = Session.get(model.Vendor, self.request.params['vendor'])
             if vendor:
                 departments = []
                 uuids = self.request.params.get('departments')
                 if uuids:
                     for uuid in uuids.split(','):
-                        dept = Session.query(model.Department).get(uuid)
+                        dept = Session.get(model.Department, uuid)
                         if dept:
                             departments.append(dept)
                 preferred_only = self.request.params.get('preferred_only') == '1'
@@ -106,7 +104,8 @@ class OrderingWorksheet(View):
         """
         Rendering engine for the ordering worksheet report.
         """
-
+        app = self.get_rattail_app()
+        model = self.model
         q = Session.query(model.ProductCost)
         q = q.join(model.Product)
         q = q.filter(model.Product.deleted == False)
@@ -129,7 +128,7 @@ class OrderingWorksheet(View):
             key = '{0} {1}'.format(brand, product.description)
             return key
 
-        now = localtime(self.request.rattail_config)
+        now = app.localtime()
         data = dict(
             vendor=vendor,
             costs=costs,
@@ -138,7 +137,8 @@ class OrderingWorksheet(View):
             time=now.strftime('%I:%M %p'),
             get_upc=self.upc_getter,
             rattail=rattail,
-            )
+            app=self.get_rattail_app(),
+        )
 
         template_path = resource_path(self.report_template_path)
         template = Template(filename=template_path)
@@ -158,7 +158,7 @@ class InventoryWorksheet(View):
         """
         This is the "Inventory Worksheet" report.
         """
-
+        model = self.model
         departments = Session.query(model.Department)
 
         if self.request.params.get('department'):
@@ -179,6 +179,8 @@ class InventoryWorksheet(View):
         """
         Generates the Inventory Worksheet report.
         """
+        app = self.get_rattail_app()
+        model = self.model
 
         def get_products(subdepartment):
             q = Session.query(model.Product)
@@ -192,7 +194,7 @@ class InventoryWorksheet(View):
             q = q.order_by(model.Brand.name, model.Product.description)
             return q.all()
 
-        now = localtime(self.request.rattail_config)
+        now = app.localtime()
         data = dict(
             date=now.strftime('%a %d %b %Y'),
             time=now.strftime('%I:%M %p'),
@@ -210,10 +212,15 @@ class ReportOutputView(ExportMasterView):
     """
     Master view for report output
     """
-    model_class = model.ReportOutput
+    model_class = ReportOutput
     route_prefix = 'report_output'
     url_prefix = '/reports/generated'
+    creatable = True
     downloadable = True
+    bulk_deletable = True
+    configurable = True
+    config_title = "Reporting"
+    config_url = '/reports/configure'
 
     grid_columns = [
         'id',
@@ -233,19 +240,60 @@ class ReportOutputView(ExportMasterView):
         'created_by',
     ]
 
+    def __init__(self, request):
+        super().__init__(request)
+        self.report_handler = self.get_report_handler()
+
+    def get_report_handler(self):
+        app = self.get_rattail_app()
+        return app.get_report_handler()
+
     def configure_grid(self, g):
-        super(ReportOutputView, self).configure_grid(g)
+        super().configure_grid(g)
+
+        g.filters['report_name'].default_active = True
+        g.filters['report_name'].default_verb = 'contains'
+
         g.set_link('filename')
 
     def configure_form(self, f):
-        super(ReportOutputView, self).configure_form(f)
+        super().configure_form(f)
+
+        # report_type
+        f.set_renderer('report_type', self.render_report_type)
 
         # params
         f.set_renderer('params', self.render_params)
 
-        # filename
-        if self.viewing:
-            f.set_renderer('filename', self.render_download)
+    def render_report_type(self, output, field):
+        type_key = getattr(output, field)
+
+        # just show type key by default
+        rendered = type_key
+
+        # (try to) show link to poser report if applicable
+        if type_key and type_key.startswith('poser_'):
+            app = self.get_rattail_app()
+            poser_handler = app.get_poser_handler()
+            poser_key = type_key[6:]
+            report = poser_handler.normalize_report(poser_key)
+            if not report.get('error'):
+                url = self.request.route_url('poser_reports.view',
+                                             report_key=poser_key)
+                rendered = tags.link_to(type_key, url)
+
+        # add help button if report has a link
+        report = self.report_handler.get_report(type_key)
+        if report and report.help_url:
+            button = self.make_button("Help for this report",
+                                      url=report.help_url,
+                                      is_external=True,
+                                      icon_left='question-circle')
+            button = HTML.tag('div', class_='level-item', c=[button])
+            rendered = HTML.tag('div', class_='level-item', c=[rendered])
+            rendered = HTML.tag('div', class_='level-left', c=[rendered, button])
+
+        return rendered
 
     def render_params(self, report, field):
         params = report.params
@@ -258,45 +306,57 @@ class ReportOutputView(ExportMasterView):
         params.sort(key=lambda param: param['key'])
 
         route_prefix = self.get_route_prefix()
-        g = grids.Grid(
-            key='{}.params'.format(route_prefix),
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.params',
             data=params,
             columns=['key', 'value'],
+            labels={'key': "Name"},
         )
-        return HTML.literal(g.render_grid())
+        return HTML.literal(
+            g.render_table_element(data_prop='paramsData'))
 
-    def render_download(self, report, field):
-        path = report.filepath(self.rattail_config)
-        url = self.get_action_url('download', report)
-        return self.render_file_field(path, url=url)
+    def get_params_context(self, report):
+        params_data = []
+        for name, value in (report.params or {}).items():
+            params_data.append({
+                'key': name,
+                'value': value,
+            })
+        return params_data
 
-    def download(self):
-        report = self.get_instance()
-        path = report.filepath(self.rattail_config)
-        return self.file_response(path)
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        output = kwargs['instance']
 
+        kwargs['params_data'] = self.get_params_context(output)
 
-class GenerateReport(View):
-    """
-    View for generating a new report.
-    """
+        # build custom URL to re-build this report
+        url = None
+        if output.report_type:
+            url = self.request.route_url('generate_specific_report',
+                                         type_key=output.report_type,
+                                         _query=output.params)
+        kwargs['rerun_report_url'] = url
 
-    def __init__(self, request):
-        super(GenerateReport, self).__init__(request)
-        self.handler = self.get_handler()
+        return kwargs
 
-    def get_handler(self):
-        return get_report_handler(self.rattail_config)
+    def template_kwargs_delete(self, **kwargs):
+        kwargs = super().template_kwargs_delete(**kwargs)
 
-    def choose(self):
+        report = kwargs['instance']
+        kwargs['params_data'] = self.get_params_context(report)
+
+        return kwargs
+
+    def create(self):
         """
         View which allows user to choose which type of report they wish to
         generate.
         """
-        use_buefy = self.get_use_buefy()
-
         # handler is responsible for determining which report types are valid
-        reports = self.handler.get_reports()
+        reports = self.report_handler.get_reports()
         if isinstance(reports, OrderedDict):
             sorted_reports = list(reports)
         else:
@@ -304,7 +364,7 @@ class GenerateReport(View):
 
         # make form to accept user choice of report type
         schema = NewReport().bind(valid_report_types=sorted_reports)
-        form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy)
+        form = forms.Form(schema=schema, request=self.request)
         form.submit_label = "Continue"
         form.cancel_url = self.request.route_url('report_output')
 
@@ -312,30 +372,26 @@ class GenerateReport(View):
         # e.g. some for customers/membership, others for product movement etc.
         values = [(r.type_key, r.name) for r in reports.values()]
         values.sort(key=lambda r: r[1])
-        if use_buefy:
-            form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values, size=10))
-            form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged')
-        else:
-            form.set_widget('report_type', forms.widgets.PlainSelectWidget(values=values, size=10))
+        form.set_widget('report_type', forms.widgets.CustomSelectWidget(values=values))
+        form.widgets['report_type'].set_template_values(input_handler='reportTypeChanged')
 
         # if form validates, that means user has chosen a report type, so we
         # just redirect to the appropriate "new report" page
-        if form.validate(newstyle=True):
+        if form.validate():
             raise self.redirect(self.request.route_url('generate_specific_report',
                                                        type_key=form.validated['report_type']))
 
-        return {
-            'index_title': "Generate Report",
+        return self.render_to_response('choose', {
             'form': form,
             'dform': form.make_deform_form(),
             'reports': reports,
             'sorted_reports': sorted_reports,
             'report_descriptions': dict([(r.type_key, r.__doc__)
                                          for r in reports.values()]),
-            'use_form': self.rattail_config.getbool('tailbone', 'reporting.choosing_uses_form',
-                                                    default=True),
-            'use_buefy': use_buefy,
-        }
+            'use_form': self.rattail_config.getbool(
+                'tailbone', 'reporting.choosing_uses_form',
+                default=False),
+        })
 
     def generate(self):
         """
@@ -343,10 +399,13 @@ class GenerateReport(View):
         input parameters specific to the report type, then creates a new report
         and redirects user to view the output.
         """
-        use_buefy = self.get_use_buefy()
+        app = self.get_rattail_app()
         type_key = self.request.matchdict['type_key']
-        report = self.handler.get_report(type_key)
+        report = self.report_handler.get_report(type_key)
+        if not report:
+            return self.notfound()
         report_params = report.make_params(Session())
+        route_prefix = self.get_route_prefix()
 
         NODE_TYPES = {
             bool: colander.Boolean,
@@ -355,6 +414,7 @@ class GenerateReport(View):
         }
 
         schema = colander.Schema()
+        helptext = {}
         for param in report_params:
 
             # make a new node of appropriate schema type
@@ -368,18 +428,22 @@ class GenerateReport(View):
 
             # allow empty value if param is optional
             if not param.required:
-                node.missing = colander.null
+                node.missing = None
 
             # maybe set default value
             if hasattr(param, 'default'):
                 node.default = param.default
 
+            # set docstring
+            # nb. must avoid newlines, they cause some weird "blank page" error?!
+            helptext[param.name] = param.helptext.replace('\n', ' ')
+
             schema.add(node)
 
-        form = forms.Form(schema=schema, request=self.request,
-                          use_buefy=use_buefy)
+        form = forms.Form(schema=schema, request=self.request, helptext=helptext)
         form.submit_label = "Generate this Report"
-        form.cancel_url = self.request.route_url('generate_report')
+        form.cancel_url = self.request.get_referrer(
+            default=self.request.route_url('{}.create'.format(route_prefix)))
 
         # must declare jquery support for date fields, ugh
         # TODO: obviously would be nice for this to be automatic?
@@ -387,8 +451,26 @@ class GenerateReport(View):
             if param.type is datetime.date:
                 form.set_type(param.name, 'date_jquery')
 
+        # auto-select default choice for fields which have only one
+        for param in report_params:
+            if param.type == 'choice' and param.required:
+                values = form.schema[param.name].widget.values
+                if len(values) == 1:
+                    form.set_default(param.name, values[0][0])
+
+        # set default field values according to query string, if applicable
+        if self.request.GET:
+            for param in report_params:
+                if param.name in self.request.GET:
+                    value = self.request.GET[param.name]
+                    if param.type is datetime.date:
+                        value = app.parse_date(value)
+                    elif param.type is bool:
+                        value = self.rattail_config.parse_bool(value)
+                    form.set_default(param.name, value)
+
         # if form validates, start generating new report output; show progress page
-        if form.validate(newstyle=True):
+        if form.validate():
             key = 'report_output.generate'
             progress = self.make_progress(key)
             kwargs = {'progress': progress}
@@ -401,14 +483,16 @@ class GenerateReport(View):
                 'cancel_msg': "Report generation was canceled",
             })
 
-        return {
-            'index_title': "Generate Report",
-            'index_url': self.request.route_url('generate_report'),
+        # hide the "Create New" button for this page, b/c user is
+        # already in the process of creating new..
+        # TODO: this seems hacky, but works
+        self.show_create_link = False
+
+        return self.render_to_response('generate', {
             'report': report,
             'form': form,
             'dform': form.make_deform_form(),
-            'use_buefy': self.get_use_buefy(),
-        }
+        })
 
     def generate_thread(self, report, params, user_uuid, progress=None):
         """
@@ -416,10 +500,12 @@ class GenerateReport(View):
         resulting :class:`rattail:~rattail.db.model.reports.ReportOutput`
         object.
         """
-        session = RattailSession()
-        user = session.query(model.User).get(user_uuid)
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
+        user = session.get(model.User, user_uuid)
         try:
-            output = self.handler.generate_output(session, report, params, user, progress=progress)
+            output = self.report_handler.generate_output(session, report, params, user, progress=progress)
 
         # if anything goes wrong, rollback and log the error etc.
         except Exception as error:
@@ -444,24 +530,36 @@ class GenerateReport(View):
                 progress.session['success_url'] = success_url
                 progress.session.save()
 
+    def download(self):
+        report = self.get_instance()
+        path = report.filepath(self.rattail_config)
+        return self.file_response(path)
+
+    def configure_get_simple_settings(self):
+        config = self.rattail_config
+        return [
+
+            # generating
+            {'section': 'tailbone',
+             'option': 'reporting.choosing_uses_form',
+             'type': bool},
+        ]
+
     @classmethod
     def defaults(cls, config):
+        cls._defaults(config)
+        cls._report_output_defaults(config)
 
-        # note that we include this in the "Generated Reports" permissions group
-        config.add_tailbone_permission('report_output', 'report_output.generate',
-                                       "Generate new report (of any type)")
+    @classmethod
+    def _report_output_defaults(cls, config):
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
 
-        # "generate report" (which is really "choose")
-        config.add_route('generate_report', '/reports/generate')
-        config.add_view(cls, attr='choose', route_name='generate_report',
-                        permission='report_output.generate',
-                        renderer='/reports/choose.mako')
-
-        # "generate specific report" (accept custom params, truly generate)
-        config.add_route('generate_specific_report', '/reports/generate/{type_key}')
+        # generate report (accept custom params, truly create)
+        config.add_route('generate_specific_report',
+                         '{}/new/{{type_key}}'.format(url_prefix))
         config.add_view(cls, attr='generate', route_name='generate_specific_report',
-                        permission='report_output.generate',
-                        renderer='/reports/generate.mako')
+                        permission='{}.create'.format(permission_prefix))
 
 
 @colander.deferred
@@ -483,23 +581,274 @@ class NewReport(colander.Schema):
                                       validator=valid_report_type)
 
 
+class ProblemReportView(MasterView):
+    """
+    Master view for problem reports
+    """
+    model_title = "Problem Report"
+    model_key = ('system_key', 'problem_key')
+    route_prefix = 'problem_reports'
+    url_prefix = '/reports/problems'
+
+    creatable = False
+    deletable = False
+    filterable = False
+    pageable = False
+    executable = True
+
+    labels = {
+        'system_key': "System",
+        'days': "Schedule",
+    }
+
+    grid_columns = [
+        'system_key',
+        # 'problem_key',
+        'problem_title',
+        'email_recipients',
+    ]
+
+    def __init__(self, request):
+        super().__init__(request)
+
+        app = self.get_rattail_app()
+        self.problem_handler = app.get_problem_report_handler()
+        # TODO: deprecate / remove this
+        self.handler = self.problem_handler
+
+    def normalize(self, report, keep_report=True):
+        data = self.problem_handler.normalize_problem_report(
+            report, include_schedule=True, include_recipients=True)
+        if keep_report:
+            data['_report'] = report
+        return data
+
+    def get_data(self, session=None):
+        data = []
+
+        reports = self.handler.get_all_problem_reports()
+        organized = self.handler.organize_problem_reports(reports)
+
+        for system_key, reports in organized.items():
+            for report in reports.values():
+                data.append(self.normalize(report))
+
+        return data
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.set_searchable('system_key')
+
+        g.set_renderer('email_recipients', self.render_email_recipients)
+
+        g.set_searchable('problem_title')
+
+        g.set_link('problem_key')
+        g.set_link('problem_title')
+
+    def get_instance(self):
+        system_key = self.request.matchdict['system_key']
+        problem_key = self.request.matchdict['problem_key']
+        return self.get_instance_for_key((system_key, problem_key),
+                                         None)
+
+    def get_instance_for_key(self, key, session):
+        report = self.handler.get_problem_report(*key)
+        if report:
+            return self.normalize(report)
+        raise self.notfound()
+
+    def get_instance_title(self, report_info):
+        return report_info['problem_title']
+
+    def make_form_schema(self):
+        return ProblemReportSchema()
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        # email_*
+        if self.editing:
+            f.remove('email_key',
+                     'email_recipients')
+        else:
+            f.set_renderer('email_key', self.render_email_key)
+            f.set_renderer('email_recipients', self.render_email_recipients)
+
+        # enabled
+        f.set_type('enabled', 'boolean')
+
+        # days
+        f.set_renderer('days', self.render_days)
+        f.set_widget('days', DaysWidget())
+        f.set_vuejs_field_converter('days', self.convert_vuejs_days)
+        f.set_helptext('days', "NB. enabling a given day means you want the "
+                       "report to be available that morning (assuming that "
+                       "reports run overnight)")
+
+        # only allow edit of certain fields
+        if self.editing:
+            editable = ('enabled', 'days')
+            for field in f:
+                if field not in editable:
+                    f.set_readonly(field)
+
+    def convert_vuejs_days(self, days):
+        days = dict(days)
+        for key in days:
+            if days[key] is colander.null:
+                days[key] = 'null'
+        return days
+
+    def render_email_recipients(self, report_info, field):
+        recips = report_info['email_recipients']
+        return ', '.join(recips)
+
+    def render_days(self, report_info, field):
+        factory = self.get_grid_factory()
+        g = factory(self.request,
+                    key='days',
+                    data=[],
+                    columns=['weekday_name', 'enabled'],
+                    labels={'weekday_name': "Weekday"})
+        return HTML.literal(g.render_table_element(data_prop='weekdaysData'))
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        report_info = kwargs['instance']
+
+        data = []
+        for i in range(7):
+            data.append({
+                'weekday': i,
+                'weekday_name': calendar.day_name[i],
+                'enabled': "Yes" if report_info['day{}'.format(i)] else "No",
+            })
+        kwargs['weekdays_data'] = data
+
+        return kwargs
+
+    def save_edit_form(self, form):
+        app = self.get_rattail_app()
+        session = self.Session()
+        data = form.validated
+        report = self.get_instance()
+        key = '{}.{}'.format(report['system_key'],
+                             report['problem_key'])
+
+        app.save_setting(session, 'rattail.problems.{}.enabled'.format(key),
+                         str(data['enabled']).lower())
+
+        for i in range(7):
+            daykey = 'day{}'.format(i)
+            app.save_setting(session, 'rattail.problems.{}.{}'.format(key, daykey),
+                             str(data['days'][daykey]).lower())
+
+    def execute_instance(self, report_info, user, progress=None, **kwargs):
+        report = report_info['_report']
+        problems = self.handler.run_problem_report(report, progress=progress,
+                                                   force=True)
+        return "Report found {} problems".format(len(problems))
+
+
+class ProblemReportDays(colander.MappingSchema):
+
+    day0 = colander.SchemaNode(colander.Boolean(),
+                               title=calendar.day_abbr[0])
+    day1 = colander.SchemaNode(colander.Boolean(),
+                               title=calendar.day_abbr[1])
+    day2 = colander.SchemaNode(colander.Boolean(),
+                               title=calendar.day_abbr[2])
+    day3 = colander.SchemaNode(colander.Boolean(),
+                               title=calendar.day_abbr[3])
+    day4 = colander.SchemaNode(colander.Boolean(),
+                               title=calendar.day_abbr[4])
+    day5 = colander.SchemaNode(colander.Boolean(),
+                               title=calendar.day_abbr[5])
+    day6 = colander.SchemaNode(colander.Boolean(),
+                               title=calendar.day_abbr[6])
+
+
+class ProblemReportSchema(colander.MappingSchema):
+
+    system_key = colander.SchemaNode(colander.String(),
+                                     missing=colander.null)
+
+    problem_key = colander.SchemaNode(colander.String(),
+                                     missing=colander.null)
+
+    problem_title = colander.SchemaNode(colander.String(),
+                                     missing=colander.null)
+
+    description = colander.SchemaNode(colander.String(),
+                                     missing=colander.null)
+
+    email_key = colander.SchemaNode(colander.String(),
+                                     missing=colander.null)
+
+    email_recipients = colander.SchemaNode(colander.String(),
+                                           missing=colander.null)
+
+    enabled = colander.SchemaNode(colander.Boolean())
+
+    days = ProblemReportDays()
+
+
+class DaysWidget(dfwidget.Widget):
+    template = 'problem_report_days'
+
+    def serialize(self, field, cstruct, **kw):
+        if cstruct in (colander.null, None):
+            cstruct = ""
+        readonly = kw.get("readonly", self.readonly)
+        template = self.template
+        values = dict(kw)
+        if 'day_labels' not in values:
+            values['day_labels'] = self.get_day_labels()
+        values = self.get_template_values(field, cstruct, values)
+        return field.renderer(template, **values)
+
+    def get_day_labels(self):
+        labels = {}
+        for i in range(7):
+            labels[i] = {'name': calendar.day_name[i],
+                         'abbr': calendar.day_abbr[i]}
+        return labels
+
+    def deserialize(self, field, pstruct):
+        from deform.compat import string_types
+        if pstruct is colander.null:
+            return colander.null
+        elif not isinstance(pstruct, string_types):
+            raise colander.Invalid(field.schema, "Pstruct is not a string")
+        pstruct = json.loads(pstruct)
+        return pstruct
+
+
 def add_routes(config):
     config.add_route('reports.ordering',        '/reports/ordering')
     config.add_route('reports.inventory',       '/reports/inventory')
 
 
-def includeme(config):
-    add_routes(config)
+def defaults(config, **kwargs):
+    base = globals()
 
+    # TODO: not in love with this pattern, but works for now
+    add_routes(config)
+    OrderingWorksheet = kwargs.get('OrderingWorksheet', base['OrderingWorksheet'])
     config.add_view(OrderingWorksheet, route_name='reports.ordering',
                     renderer='/reports/ordering.mako')
-
+    InventoryWorksheet = kwargs.get('InventoryWorksheet', base['InventoryWorksheet'])
     config.add_view(InventoryWorksheet, route_name='reports.inventory',
                     renderer='/reports/inventory.mako')
 
-    # fix permission group
-    config.add_tailbone_permission_group('report_output', "Generated Reports")
-
-    # note that GenerateReport must come first, per route matching
-    GenerateReport.defaults(config)
+    ReportOutputView = kwargs.get('ReportOutputView', base['ReportOutputView'])
     ReportOutputView.defaults(config)
+
+    ProblemReportView = kwargs.get('ProblemReportView', base['ProblemReportView'])
+    ProblemReportView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index e30c38be..e8a6d8a2 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,17 +24,12 @@
 Role Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 
-import six
 from sqlalchemy import orm
 from openpyxl.styles import Font, PatternFill
 
-from rattail.db import model
-from rattail.db.auth import (has_permission, grant_permission, revoke_permission,
-                             administrator_role, guest_role, authenticated_role)
+from rattail.db.model import Role
 from rattail.excel import ExcelWriter
 
 import colander
@@ -46,28 +41,42 @@ from tailbone.db import Session
 from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
 
 
-class RolesView(PrincipalMasterView):
+class RoleView(PrincipalMasterView):
     """
     Master view for the Role model.
     """
-    model_class = model.Role
+    model_class = Role
     has_versions = True
+    touchable = True
+
+    labels = {
+        'adminish': "Admin-ish",
+        'sync_me': "Sync Attrs & Perms",
+    }
 
     grid_columns = [
         'name',
         'session_timeout',
+        'sync_me',
+        'sync_users',
+        'node_type',
         'notes',
     ]
 
     form_fields = [
         'name',
+        'adminish',
         'session_timeout',
         'notes',
+        'sync_me',
+        'sync_users',
+        'node_type',
+        'users',
         'permissions',
     ]
 
     def configure_grid(self, g):
-        super(RolesView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # name
         g.filters['name'].default_active = True
@@ -92,16 +101,28 @@ class RolesView(PrincipalMasterView):
         We must prevent edit for certain built-in roles etc., depending on
         current user's permissions.
         """
+        # role with node type specified, can only be edited from a
+        # node of the same type
+        if role.node_type and role.node_type != self.rattail_config.node_type():
+            return False
+
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
         # only "root" can edit Administrator
-        if role is administrator_role(self.Session()):
+        if role is auth.get_role_administrator(self.Session()):
             return self.request.is_root
 
+        # only "admin" can edit "admin-ish" roles
+        if role.adminish:
+            return self.request.is_admin
+
         # can edit Authenticated only if user has permission
-        if role is authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return self.has_perm('edit_authenticated')
 
         # can edit Guest only if user has permission
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return self.has_perm('edit_guest')
 
         # current user can edit their own roles, only if they have permission
@@ -115,12 +136,24 @@ class RolesView(PrincipalMasterView):
         """
         We must prevent deletion for all built-in roles.
         """
-        if role is administrator_role(self.Session()):
+        # role with node type specified, can only be edited from a
+        # node of the same type
+        if role.node_type and role.node_type != self.rattail_config.node_type():
             return False
-        if role is authenticated_role(self.Session()):
+
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
+        if role is auth.get_role_administrator(self.Session()):
             return False
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return False
+        if role is auth.get_role_anonymous(self.Session()):
+            return False
+
+        # only "admin" can delete "admin-ish" roles
+        if role.adminish:
+            return self.request.is_admin
 
         # current user can delete their own roles, only if they have permission
         user = self.request.user
@@ -130,6 +163,7 @@ class RolesView(PrincipalMasterView):
         return True
 
     def unique_name(self, node, value):
+        model = self.model
         query = self.Session.query(model.Role)\
                             .filter(model.Role.name == value)
         if self.editing:
@@ -139,37 +173,108 @@ class RolesView(PrincipalMasterView):
             raise colander.Invalid(node, "Name must be unique")
 
     def configure_form(self, f):
-        super(RolesView, self).configure_form(f)
+        super().configure_form(f)
         role = f.model_instance
-        use_buefy = self.get_use_buefy()
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
 
         # name
         f.set_validator('name', self.unique_name)
 
+        # adminish
+        if self.request.is_admin:
+            f.set_helptext('adminish',
+                           "If checked, only Administrators may add/remove "
+                           "users for the role.")
+        else:
+            f.remove('adminish')
+
+        # session_timeout
+        f.set_renderer('session_timeout', self.render_session_timeout)
+        if self.editing and role is auth.get_role_anonymous(self.Session()):
+            f.set_readonly('session_timeout')
+
+        # sync_me, node_type
+        if not self.creating:
+            include = True
+            if role is auth.get_role_administrator(self.Session()):
+                include = False
+            elif role is auth.get_role_authenticated(self.Session()):
+                include = False
+            elif role is auth.get_role_anonymous(self.Session()):
+                include = False
+            if not include:
+                f.remove('sync_me', 'sync_users', 'node_type')
+            else:
+                if not self.has_perm('edit_node_sync'):
+                    f.set_readonly('sync_me')
+                    f.set_readonly('sync_users')
+                    f.set_readonly('node_type')
+
         # notes
-        f.set_type('notes', 'text')
+        f.set_type('notes', 'text_wrapped')
+
+        # users
+        if self.viewing:
+            f.set_renderer('users', self.render_users)
+        else:
+            f.remove('users')
 
         # permissions
         self.tailbone_permissions = self.get_available_permissions()
-        f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions))
+        f.set_renderer('permissions', PermissionsRenderer(request=self.request,
+                                                          permissions=self.tailbone_permissions))
         f.set_node('permissions', colander.Set())
         f.set_widget('permissions', PermissionsWidget(
-            permissions=self.tailbone_permissions,
-            use_buefy=use_buefy))
+            permissions=self.tailbone_permissions))
         if self.editing:
             granted = []
             for groupkey in self.tailbone_permissions:
                 for key in self.tailbone_permissions[groupkey]['perms']:
-                    if has_permission(self.Session(), role, key, include_guest=False, include_authenticated=False):
+                    if auth.has_permission(self.Session(), role, key,
+                                           include_anonymous=False,
+                                           include_authenticated=False):
                         granted.append(key)
             f.set_default('permissions', granted)
         elif self.deleting:
             f.remove_field('permissions')
 
-        # session_timeout
-        f.set_renderer('session_timeout', self.render_session_timeout)
-        if self.editing and role is guest_role(self.Session()):
-            f.set_readonly('session_timeout')
+    def render_users(self, role, field):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
+        if role is auth.get_role_anonymous(self.Session()):
+            return ("The guest role is implied for all anonymous users, "
+                    "i.e. when not logged in.")
+
+        if role is auth.get_role_authenticated(self.Session()):
+            return ("The authenticated role is implied for all users, "
+                    "but only when logged in.")
+
+        route_prefix = self.get_route_prefix()
+        permission_prefix = self.get_permission_prefix()
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.users',
+            data=[],
+            columns=[
+                'full_name',
+                'username',
+                'active',
+            ],
+            sortable=True,
+            sorters={'full_name': True, 'username': True, 'active': True},
+            default_sortkey='full_name',
+        )
+
+        if self.request.has_perm('users.view'):
+            g.actions.append(self.make_action('view', icon='eye'))
+        if self.request.has_perm('users.edit'):
+            g.actions.append(self.make_action('edit', icon='edit'))
+
+        return HTML.literal(
+            g.render_table_element(data_prop='usersData'))
 
     def get_available_permissions(self):
         """
@@ -182,8 +287,8 @@ class RolesView(PrincipalMasterView):
         if the current user is an admin; otherwise it will be the "subset" of
         permissions which the current user has been granted.
         """
-        # fetch full set of permissions registered in the app
-        permissions = self.request.registry.settings.get('tailbone_permissions', {})
+        # get all known permissions from settings cache
+        permissions = self.request.registry.settings.get('wutta_permissions', {})
 
         # admin user gets to manage all permissions
         if self.request.is_admin:
@@ -197,8 +302,8 @@ class RolesView(PrincipalMasterView):
         # TODO: it seems a bit ugly, to "rebuild" permission groups like this,
         # but not sure if there's a better way?
         available = {}
-        for gkey, group in six.iteritems(permissions):
-            for pkey, perm in six.iteritems(group['perms']):
+        for gkey, group in permissions.items():
+            for pkey, perm in group['perms'].items():
                 if self.request.has_perm(pkey):
                     if gkey not in available:
                         available[gkey] = {
@@ -210,11 +315,13 @@ class RolesView(PrincipalMasterView):
         return available
 
     def render_session_timeout(self, role, field):
-        if role is guest_role(self.Session()):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        if role is auth.get_role_anonymous(self.Session()):
             return "(not applicable)"
         if role.session_timeout is None:
             return ""
-        return six.text_type(role.session_timeout)
+        return str(role.session_timeout)
 
     def objectify(self, form, data=None):
         """
@@ -226,7 +333,7 @@ class RolesView(PrincipalMasterView):
         """
         if data is None:
             data = form.validated
-        role = super(RolesView, self).objectify(form, data)
+        role = super().objectify(form, data)
         self.update_permissions(role, data['permissions'])
         return role
 
@@ -237,56 +344,102 @@ class RolesView(PrincipalMasterView):
         permissions, but rather each "available" permission (depends on current
         user) will be examined individually, and updated as needed.
         """
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+
         available = self.tailbone_permissions
-        for gkey, group in six.iteritems(available):
-            for pkey, perm in six.iteritems(group['perms']):
+        for gkey, group in available.items():
+            for pkey, perm in group['perms'].items():
                 if pkey in permissions:
-                    grant_permission(role, pkey)
+                    auth.grant_permission(role, pkey)
                 else:
-                    revoke_permission(role, pkey)
+                    auth.revoke_permission(role, pkey)
 
     def template_kwargs_view(self, **kwargs):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = self.model
         role = kwargs['instance']
         if role.users:
             users = sorted(role.users, key=lambda u: u.username)
             actions = [
-                grids.GridAction('view', icon='zoomin',
+                self.make_action('view', icon='zoomin',
                                  url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid))
             ]
-            kwargs['users'] = grids.Grid(None, users, ['username', 'active'],
-                                         request=self.request,
+            kwargs['users'] = grids.Grid(self.request,
+                                         data=users,
+                                         columns=['username', 'active'],
                                          model_class=model.User,
-                                         main_actions=actions)
+                                         actions=actions)
         else:
             kwargs['users'] = None
-        kwargs['guest_role'] = guest_role(self.Session())
-        kwargs['authenticated_role'] = authenticated_role(self.Session())
+
+        kwargs['guest_role'] = auth.get_role_anonymous(self.Session())
+        kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session())
+
+        role = kwargs['instance']
+        if role not in (kwargs['guest_role'], kwargs['authenticated_role']):
+            users_data = []
+            for user in role.users:
+                users_data.append({
+                    'uuid': user.uuid,
+                    'full_name': user.display_name,
+                    'username': user.username,
+                    'active': "Yes" if user.active else "No",
+                    '_action_url_view': self.request.route_url('users.view',
+                                                               uuid=user.uuid),
+                    '_action_url_edit': self.request.route_url('users.edit',
+                                                               uuid=user.uuid),
+                })
+            kwargs['users_data'] = users_data
+
         return kwargs
 
     def before_delete(self, role):
-        admin = administrator_role(self.Session())
-        guest = guest_role(self.Session())
-        authenticated = authenticated_role(self.Session())
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        admin = auth.get_role_administrator(self.Session())
+        guest = auth.get_role_anonymous(self.Session())
+        authenticated = auth.get_role_authenticated(self.Session())
         if role in (admin, guest, authenticated):
             self.request.session.flash("You may not delete the {} role.".format(role.name), 'error')
             return self.redirect(self.request.get_referrer(default=self.request.route_url('roles')))
 
     def find_principals_with_permission(self, session, permission):
+        app = self.get_rattail_app()
+        model = self.model
+        auth = app.get_auth_handler()
+
         # TODO: this should search Permission table instead, and work backward to Role?
         all_roles = session.query(model.Role)\
                            .order_by(model.Role.name)\
                            .options(orm.joinedload(model.Role._permissions))
         roles = []
         for role in all_roles:
-            if has_permission(session, role, permission, include_guest=False):
+            if auth.has_permission(session, role, permission, include_anonymous=False):
                 roles.append(role)
         return roles
 
+    def find_by_perm_configure_results_grid(self, g):
+        g.append('name')
+        g.set_link('name')
+
+    def find_by_perm_normalize(self, role):
+        data = super().find_by_perm_normalize(role)
+
+        data['name'] = role.name
+
+        return data
+
     def download_permissions_matrix(self):
         """
         View which renders the complete role / permissions matrix data into an
         Excel spreadsheet, and returns that file.
         """
+        app = self.get_rattail_app()
+        model = self.model
+        auth = app.get_auth_handler()
+
         roles = self.Session.query(model.Role)\
                             .order_by(model.Role.name)\
                             .all()
@@ -335,7 +488,8 @@ class RolesView(PrincipalMasterView):
 
                 # and show an 'X' for any role which has this perm
                 for col, role in enumerate(roles, 2):
-                    if has_permission(self.Session(), role, key, include_guest=False):
+                    if auth.has_permission(self.Session(), role, key,
+                                           include_anonymous=False):
                         sheet.cell(row=writing_row, column=col, value="X")
 
                 writing_row += 1
@@ -356,6 +510,7 @@ class RolesView(PrincipalMasterView):
         route_prefix = cls.get_route_prefix()
         url_prefix = cls.get_url_prefix()
         permission_prefix = cls.get_permission_prefix()
+        model_title = cls.get_model_title()
 
         # extra permissions for editing built-in roles etc.
         config.add_tailbone_permission(permission_prefix, '{}.edit_authenticated'.format(permission_prefix),
@@ -364,6 +519,9 @@ class RolesView(PrincipalMasterView):
                                        "Edit the \"Guest\" Role")
         config.add_tailbone_permission(permission_prefix, '{}.edit_my'.format(permission_prefix),
                                        "Edit Role(s) to which current user belongs")
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.edit_node_sync'.format(permission_prefix),
+                                       "Edit the Node Type and Sync flags for a {}".format(model_title))
 
         # download permissions matrix
         config.add_tailbone_permission(permission_prefix, '{}.download_permissions_matrix'.format(permission_prefix),
@@ -373,6 +531,9 @@ class RolesView(PrincipalMasterView):
         config.add_view(cls, attr='download_permissions_matrix', route_name='{}.download_permissions_matrix'.format(route_prefix),
                         permission='{}.download_permissions_matrix'.format(permission_prefix))
 
+# TODO: deprecate / remove this
+RolesView = RoleView
+
 
 class PermissionsWidget(dfwidget.Widget):
     template = 'permissions'
@@ -395,5 +556,12 @@ class PermissionsWidget(dfwidget.Widget):
         return field.renderer(template, **values)
 
 
+def defaults(config, **kwargs):
+    base = globals()
+
+    RoleView = kwargs.get('RoleView', base['RoleView'])
+    RoleView.defaults(config)
+
+
 def includeme(config):
-    RolesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 83afb2ff..10a0c2eb 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,30 +24,174 @@
 Settings Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import json
 import re
 
-import six
-
-from rattail.db import model, api
-from rattail.settings import Setting
-from rattail.util import import_module_path
-from rattail.config import parse_bool
-
 import colander
-from webhelpers2.html import tags
 
-from tailbone import forms
+from rattail.db.model import Setting
+from rattail.settings import Setting as AppSetting
+from rattail.util import import_module_path
+
+from tailbone import forms, grids
 from tailbone.db import Session
 from tailbone.views import MasterView, View
+from wuttaweb.util import get_libver, get_liburl
+from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView
 
 
-class SettingsView(MasterView):
+class AppInfoView(WuttaAppInfoView):
+    """ """
+    Session = Session
+    weblib_config_prefix = 'tailbone'
+
+    # TODO: for now we override to get tailbone searchable grid
+    def make_grid(self, **kwargs):
+        """ """
+        return grids.Grid(self.request, **kwargs)
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+
+        # name
+        g.set_searchable('name')
+
+        # editable_project_location
+        g.set_searchable('editable_project_location')
+
+    def configure_get_context(self, **kwargs):
+        """ """
+        context = super().configure_get_context(**kwargs)
+        simple_settings = context['simple_settings']
+        weblibs = context['weblibs']
+
+        for weblib in weblibs:
+            key = weblib['key']
+
+            # TODO: this is only needed to migrate legacy settings to
+            # use the newer wuttaweb setting names
+            url = simple_settings[f'wuttaweb.liburl.{key}']
+            if not url and weblib['configured_url']:
+                simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url']
+
+        return context
+
+    # nb. these email settings require special handling below
+    configure_profile_key_mismatches = [
+        'default.subject',
+        'default.to',
+        'default.cc',
+        'default.bcc',
+        'feedback.subject',
+        'feedback.to',
+    ]
+
+    def configure_get_simple_settings(self):
+        """ """
+        simple_settings = super().configure_get_simple_settings()
+
+        # TODO:
+        # there are several email config keys which differ between
+        # wuttjamaican and rattail.  basically all of the "profile" keys
+        # have a different prefix.
+
+        # after wuttaweb has declared its settings, we examine each and
+        # overwrite the value if one is defined with rattail config key.
+        # (nb. this happens even if wuttjamaican key has a value!)
+
+        # note that we *do* declare the profile mismatch keys for
+        # rattail, as part of simple settings.  this ensures the
+        # parent logic will always remove them when saving.  however
+        # we must also include them in gather_settings() to ensure
+        # they are saved to match wuttjamaican values.
+
+        # there are also a couple of flags where rattail's default is the
+        # opposite of wuttjamaican.  so we overwrite those too as needed.
+
+        for setting in simple_settings:
+
+            # nb. the update home page redirect setting is off by
+            # default for wuttaweb, but on for tailbone
+            if setting['name'] == 'wuttaweb.home_redirect_to_login':
+                value = self.config.get_bool('wuttaweb.home_redirect_to_login')
+                if value is None:
+                    value = self.config.get_bool('tailbone.login_is_home', default=True)
+                setting['value'] = value
+
+            # nb. sending email is off by default for wuttjamaican,
+            # but on for rattail
+            elif setting['name'] == 'rattail.mail.send_emails':
+                value = self.config.get_bool('rattail.mail.send_emails', default=True)
+                setting['value'] = value
+
+            # nb. this one is even more special, key is entirely different
+            elif setting['name'] == 'rattail.email.default.sender':
+                value = self.config.get('rattail.email.default.sender')
+                if value is None:
+                    value = self.config.get('rattail.mail.default.from')
+                setting['value'] = value
+
+            else:
+
+                # nb. fetch alternate value for profile key mismatch
+                for key in self.configure_profile_key_mismatches:
+                    if setting['name'] == f'rattail.email.{key}':
+                        value = self.config.get(f'rattail.email.{key}')
+                        if value is None:
+                            value = self.config.get(f'rattail.mail.{key}')
+                        setting['value'] = value
+                        break
+
+        # nb. these are no longer used (deprecated), but we keep
+        # them defined here so the tool auto-deletes them
+
+        simple_settings.extend([
+            {'name': 'tailbone.login_is_home'},
+            {'name': 'tailbone.buefy_version'},
+            {'name': 'tailbone.vue_version'},
+        ])
+
+        simple_settings.append({'name': 'rattail.mail.default.from'})
+        for key in self.configure_profile_key_mismatches:
+            simple_settings.append({'name': f'rattail.mail.{key}'})
+
+        for key in self.get_weblibs():
+            simple_settings.extend([
+                {'name': f'tailbone.libver.{key}'},
+                {'name': f'tailbone.liburl.{key}'},
+            ])
+
+        return simple_settings
+
+    def configure_gather_settings(self, data, simple_settings=None):
+        """ """
+        settings = super().configure_gather_settings(data, simple_settings=simple_settings)
+
+        # nb. must add legacy rattail profile settings to match new ones
+        for setting in list(settings):
+
+            if setting['name'] == 'rattail.email.default.sender':
+                value = setting['value']
+                settings.append({'name': 'rattail.mail.default.from',
+                                 'value': value})
+
+            else:
+                for key in self.configure_profile_key_mismatches:
+                    if setting['name'] == f'rattail.email.{key}':
+                        value = setting['value']
+                        settings.append({'name': f'rattail.mail.{key}',
+                                         'value': value})
+                        break
+
+        return settings
+
+
+class SettingView(MasterView):
     """
     Master view for the settings model.
     """
-    model_class = model.Setting
+    model_class = Setting
     model_title = "Raw Setting"
     model_title_plural = "Raw Settings"
     bulk_deletable = True
@@ -59,19 +203,20 @@ class SettingsView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(SettingsView, self).configure_grid(g)
+        super().configure_grid(g)
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
         g.set_sort_defaults('name')
         g.set_link('name')
 
     def configure_form(self, f):
-        super(SettingsView, self).configure_form(f)
+        super().configure_form(f)
         if self.creating:
             f.set_validator('name', self.unique_name)
 
     def unique_name(self, node, value):
-        setting = self.Session.query(model.Setting).get(value)
+        model = self.model
+        setting = self.Session.get(model.Setting, value)
         if setting:
             raise colander.Invalid(node, "Setting name must be unique")
 
@@ -80,11 +225,29 @@ class SettingsView(MasterView):
             return not bool(self.feedback.match(setting.name))
         return True
 
+    def after_edit(self, setting):
+        # nb. force cache invalidation - normally this happens when a
+        # setting is saved via app handler, but here that is being
+        # bypassed and it is saved directly via standard ORM calls
+        self.rattail_config.beaker_invalidate_setting(setting.name)
+
     def deletable_instance(self, setting):
         if self.rattail_config.demo():
             return not bool(self.feedback.match(setting.name))
         return True
 
+    def delete_instance(self, setting):
+
+        # nb. force cache invalidation
+        self.rattail_config.beaker_invalidate_setting(setting.name)
+
+        # otherwise delete like normal
+        super().delete_instance(setting)
+
+
+# TODO: deprecate / remove this
+SettingsView = SettingView
+
 
 class AppSettingsForm(forms.Form):
 
@@ -108,7 +271,7 @@ class AppSettingsView(View):
 
         form = self.make_form(settings)
         form.cancel_url = self.request.current_route_url()
-        if form.validate(newstyle=True):
+        if form.validate():
             self.save_form(form)
             group = self.request.POST.get('settings-group')
             if group is not None:
@@ -122,28 +285,33 @@ class AppSettingsView(View):
         if not current_group:
             current_group = self.request.session.get('appsettings.current_group')
 
-        use_buefy = self.get_use_buefy()
+        possible_config_options = sorted(
+            self.request.registry.settings['tailbone_config_pages'],
+            key=lambda p: p['label'])
+
+        config_options = []
+        for option in possible_config_options:
+            perm = option.get('perm', option['route'])
+            if self.request.has_perm(perm):
+                option['url'] = self.request.route_url(option['route'])
+                config_options.append(option)
+
         context = {
             'index_title': "App Settings",
             'form': form,
             'dform': form.make_deform_form(),
             'groups': groups,
             'settings': settings,
-            'use_buefy': use_buefy,
+            'config_options': config_options,
         }
-        if use_buefy:
-            context['buefy_data'] = self.get_buefy_data(form, groups, settings)
-            # TODO: this seems hacky, and probably only needed if theme changes?
-            if current_group == '(All)':
-                current_group = ''
-        else:
-            group_options = [tags.Option(group, group) for group in groups]
-            group_options.insert(0, tags.Option("(All)", "(All)"))
-            context['group_options'] = group_options
+        context['settings_data'] = self.get_settings_data(form, groups, settings)
+        # TODO: this seems hacky, and probably only needed if theme changes?
+        if current_group == '(All)':
+            current_group = ''
         context['current_group'] = current_group
         return context
 
-    def get_buefy_data(self, form, groups, settings):
+    def get_settings_data(self, form, groups, settings):
         dform = form.make_deform_form()
         grouped = dict([(label, [])
                         for label in groups])
@@ -156,14 +324,27 @@ class AppSettingsView(View):
                 'data_type': setting.data_type.__name__,
                 'choices': setting.choices,
                 'helptext': form.render_helptext(field.name) if form.has_helptext(field.name) else None,
-                'error': field.error,
+                'error': False, # nb. may set to True below
             }
-            value = self.get_setting_value(setting)
-            if setting.data_type is bool:
-                value = parse_bool(value)
+
+            # we want the value from the form, i.e. in case of a POST
+            # request with validation errors.  we also want to make
+            # sure value is JSON-compatible, but we must represent it
+            # as Python value here, and it will be JSON-encoded later.
+            value = form.get_vuejs_model_value(field)
+            value = json.loads(value)
             s['value'] = value
+
+            # specify error / message if applicable
+            # TODO: not entirely clear to me why some field errors are
+            # represented differently?
             if field.error:
-                s['error_messages'] = field.error_messages()
+                s['error'] = True
+                if isinstance(field.error, colander.Invalid):
+                    s['error_messages'] = [field.errormsg]
+                else:
+                    s['error_messages'] = field.error_messages()
+
             grouped[setting.group].append(s)
 
         data = []
@@ -211,11 +392,20 @@ class AppSettingsView(View):
         """
         Iterate over all known settings.
         """
-        for module in self.rattail_config.getlist('rattail', 'settings', default=['rattail.settings']):
+        modules = self.rattail_config.getlist('rattail', 'settings')
+        if modules:
+            core_only = False
+        else:
+            modules = ['rattail.settings']
+            core_only = True
+
+        for module in modules:
             module = import_module_path(module)
             for name in dir(module):
                 obj = getattr(module, name)
-                if isinstance(obj, type) and issubclass(obj, Setting) and obj is not Setting:
+                if isinstance(obj, type) and issubclass(obj, AppSetting) and obj is not AppSetting:
+                    if core_only and not obj.core:
+                        continue
                     # NOTE: we set this here, and reference it elsewhere
                     obj.node_name = self.get_node_name(obj)
                     yield obj
@@ -226,6 +416,10 @@ class AppSettingsView(View):
     def get_setting_value(self, setting):
         if setting.data_type is bool:
             return self.rattail_config.getbool(setting.namespace, setting.name)
+        if setting.data_type is list:
+            return '\n'.join(
+                self.rattail_config.getlist(setting.namespace, setting.name,
+                                            default=[]))
         return self.rattail_config.get(setting.namespace, setting.name)
 
     def save_setting_value(self, setting, value):
@@ -234,9 +428,25 @@ class AppSettingsView(View):
             legacy_name = '{}.{}'.format(setting.namespace, setting.name)
             if setting.data_type is bool:
                 value = 'true' if value else 'false'
+            elif setting.data_type is list:
+                entries = [self.clean_list_entry(entry)
+                           for entry in value.split('\n')]
+                value = ', '.join(entries)
             else:
-                value = six.text_type(value)
-            api.save_setting(Session(), legacy_name, value)
+                value = str(value)
+            app = self.get_rattail_app()
+            app.save_setting(Session(), legacy_name, value)
+
+    def clean_list_entry(self, value):
+        value = value.strip()
+        if '"' in value and "'" in value:
+            raise NotImplementedError("don't know how to handle escaping 2 "
+                                      "different types of quotes!")
+        if '"' in value:
+            return "'{}'".format(value)
+        if "'" in value:
+            return '"{}"'.format(value)
+        return value
 
     @classmethod
     def defaults(cls, config):
@@ -246,6 +456,18 @@ class AppSettingsView(View):
                         permission='settings.edit')
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
+    AppInfoView.defaults(config)
+
+    AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView'])
     AppSettingsView.defaults(config)
-    SettingsView.defaults(config)
+
+    SettingView = kwargs.get('SettingView', base['SettingView'])
+    SettingView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py
index 10ceba0b..53bfc446 100644
--- a/tailbone/views/shifts/core.py
+++ b/tailbone/views/shifts/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,31 +24,32 @@
 Views for employee shifts
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
-
 from rattail.db import model
 from rattail.time import localtime
-from rattail.util import pretty_hours, hours_as_decimal
+from rattail.util import hours_as_decimal
 
 from webhelpers2.html import tags, HTML
 
 from tailbone.views import MasterView
 
 
-def render_shift_length(shift, field):
-    if not shift.start_time or not shift.end_time:
-        return ""
-    if shift.end_time < shift.start_time:
-        return "??"
-    length = shift.end_time - shift.start_time
-    return HTML.tag('span', title="{} hrs".format(hours_as_decimal(length)), c=[pretty_hours(length)])
+class ShiftViewMixin:
+
+    def render_shift_length(self, shift, field):
+        if not shift.start_time or not shift.end_time:
+            return ""
+        if shift.end_time < shift.start_time:
+            return "??"
+        app = self.get_rattail_app()
+        length = shift.end_time - shift.start_time
+        return HTML.tag('span',
+                        title="{} hrs".format(hours_as_decimal(length)),
+                        c=[app.render_duration(delta=length)])
 
 
-class ScheduledShiftsView(MasterView):
+class ScheduledShiftView(MasterView, ShiftViewMixin):
     """
     Master view for employee scheduled shifts.
     """
@@ -78,17 +79,20 @@ class ScheduledShiftsView(MasterView):
 
         g.set_sort_defaults('start_time', 'desc')
 
-        g.set_renderer('length', render_shift_length)
+        g.set_renderer('length', self.render_shift_length)
 
         g.set_label('employee', "Employee Name")
 
     def configure_form(self, f):
-        super(ScheduledShiftsView, self).configure_form(f)
+        super().configure_form(f)
 
-        f.set_renderer('length', render_shift_length)
+        f.set_renderer('length', self.render_shift_length)
+
+# TODO: deprecate / remove this
+ScheduledShiftsView = ScheduledShiftView
 
 
-class WorkedShiftsView(MasterView):
+class WorkedShiftView(MasterView, ShiftViewMixin):
     """
     Master view for employee worked shifts.
     """
@@ -114,26 +118,29 @@ class WorkedShiftsView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(WorkedShiftsView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
-        g.joiners['employee'] = lambda q: q.join(model.Employee).join(model.Person)
-        g.filters['employee'] = g.make_filter('employee', model.Person.display_name)
-        g.sorters['employee'] = g.make_sorter(model.Person.display_name)
+        # employee
+        g.set_joiner('employee', lambda q: q.join(model.Employee).join(model.Person))
+        g.set_sorter('employee', model.Person.display_name)
+        g.set_filter('employee', model.Person.display_name)
 
-        g.joiners['store'] = lambda q: q.join(model.Store)
-        g.filters['store'] = g.make_filter('store', model.Store.name)
-        g.sorters['store'] = g.make_sorter(model.Store.name)
+        # store
+        g.set_joiner('store', lambda q: q.join(model.Store))
+        g.set_sorter('store', model.Store.name)
+        g.set_filter('store', model.Store.name)
 
         # TODO: these sorters should be automatic once we fix the schema
-        g.sorters['start_time'] = g.make_sorter(model.WorkedShift.punch_in)
-        g.sorters['end_time'] = g.make_sorter(model.WorkedShift.punch_out)
+        g.set_sorter('start_time', model.WorkedShift.punch_in)
+        g.set_sorter('end_time', model.WorkedShift.punch_out)
         # TODO: same goes for these renderers
         g.set_type('start_time', 'datetime')
         g.set_type('end_time', 'datetime')
         # (but we'll still have to set this)
         g.set_sort_defaults('start_time', 'desc')
 
-        g.set_renderer('length', render_shift_length)
+        g.set_renderer('length', self.render_shift_length)
 
         g.set_label('employee', "Employee Name")
         g.set_label('store', "Store Name")
@@ -146,12 +153,12 @@ class WorkedShiftsView(MasterView):
         return "WorkedShift: {}, {}".format(shift.employee, date)
 
     def configure_form(self, f):
-        super(WorkedShiftsView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_readonly('employee')
         f.set_renderer('employee', self.render_employee)
 
-        f.set_renderer('length', render_shift_length)
+        f.set_renderer('length', self.render_shift_length)
         if self.editing:
             f.remove('length')
 
@@ -159,12 +166,12 @@ class WorkedShiftsView(MasterView):
         employee = shift.employee
         if not employee:
             return ""
-        text = six.text_type(employee)
+        text = str(employee)
         url = self.request.route_url('employees.view', uuid=employee.uuid)
         return tags.link_to(text, url)
 
     def get_xlsx_fields(self):
-        fields = super(WorkedShiftsView, self).get_xlsx_fields()
+        fields = super().get_xlsx_fields()
 
         # add employee name
         i = fields.index('employee_uuid')
@@ -176,7 +183,7 @@ class WorkedShiftsView(MasterView):
         return fields
 
     def get_xlsx_row(self, shift, fields):
-        row = super(WorkedShiftsView, self).get_xlsx_row(shift, fields)
+        row = super().get_xlsx_row(shift, fields)
 
         # localize start and end times (Excel requires time with no zone)
         if shift.punch_in:
@@ -200,7 +207,19 @@ class WorkedShiftsView(MasterView):
 
         return row
 
+# TODO: deprecate / remove this
+WorkedShiftsView = WorkedShiftView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ScheduledShiftView = kwargs.get('ScheduledShiftView', base['ScheduledShiftView'])
+    ScheduledShiftView.defaults(config)
+
+    WorkedShiftView = kwargs.get('WorkedShiftView', base['WorkedShiftView'])
+    WorkedShiftView.defaults(config)
+
 
 def includeme(config):
-    ScheduledShiftsView.defaults(config)
-    WorkedShiftsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py
index 73d9603a..1827bee0 100644
--- a/tailbone/views/shifts/lib.py
+++ b/tailbone/views/shifts/lib.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,17 +24,13 @@
 Base views for time sheets
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
 import sqlalchemy as sa
 
-from rattail import enum
-from rattail.db import model, api
-from rattail.time import localtime, make_utc, get_sunday
-from rattail.util import pretty_hours, hours_as_decimal
+from rattail.db import api
+from rattail.time import get_sunday
+from rattail.util import hours_as_decimal
 
 import colander
 from deform import widget as dfwidget
@@ -86,6 +82,8 @@ class TimeSheetView(View):
         """
         Determine date/store/dept context from user's session and/or defaults.
         """
+        app = self.get_rattail_app()
+        model = self.model
         date = None
         date_key = 'timesheet.{}.date'.format(self.key)
         if date_key in self.request.session:
@@ -96,7 +94,7 @@ class TimeSheetView(View):
                 except ValueError:
                     pass
         if not date:
-            date = localtime(self.rattail_config).date()
+            date = app.today()
 
         store = None
         department = None
@@ -105,10 +103,10 @@ class TimeSheetView(View):
         if store_key in self.request.session or department_key in self.request.session:
             store_uuid = self.request.session.get(store_key)
             if store_uuid:
-                store = Session.query(model.Store).get(store_uuid) if store_uuid else None
+                store = Session.get(model.Store, store_uuid) if store_uuid else None
             department_uuid = self.request.session.get(department_key)
             if department_uuid:
-                department = Session.query(model.Department).get(department_uuid)
+                department = Session.get(model.Department, department_uuid)
         else: # no store/department in session
             if self.default_filter_store:
                 store = self.rattail_config.get('rattail', 'store')
@@ -116,7 +114,7 @@ class TimeSheetView(View):
                     store = api.get_store(Session(), store)
 
         employees = Session.query(model.Employee)\
-                           .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT)
+                           .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
         if store:
             employees = employees.join(model.EmployeeStore)\
                                  .filter(model.EmployeeStore.store == store)
@@ -135,6 +133,8 @@ class TimeSheetView(View):
         """
         Determine employee/date context from user's session and/or defaults
         """
+        app = self.get_rattail_app()
+        model = self.model
         date = None
         date_key = 'timesheet.{}.employee.date'.format(self.key)
         if date_key in self.request.session:
@@ -145,13 +145,13 @@ class TimeSheetView(View):
                 except ValueError:
                     pass
         if not date:
-            date = localtime(self.rattail_config).date()
+            date = app.today()
 
         employee = None
         employee_key = 'timesheet.{}.employee'.format(self.key)
         if employee_key in self.request.session:
             employee_uuid = self.request.session[employee_key]
-            employee = Session.query(model.Employee).get(employee_uuid) if employee_uuid else None
+            employee = Session.get(model.Employee, employee_uuid) if employee_uuid else None
         if not employee:
             employee = self.request.user.employee
 
@@ -167,7 +167,7 @@ class TimeSheetView(View):
         Process a "shift filter" form if one was in fact POST'ed.  If it was
         then we store new context in session and redirect to display as normal.
         """
-        if form.validate(newstyle=True):
+        if form.validate():
             store = form.validated['store']
             self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None
             department = form.validated['department']
@@ -181,7 +181,7 @@ class TimeSheetView(View):
         Process an "employee shift filter" form if one was in fact POST'ed.  If it
         was then we store new context in session and redirect to display as normal.
         """
-        if form.validate(newstyle=True):
+        if form.validate():
             employee = form.validated['employee']
             self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None
             date = form.validated['date']
@@ -194,7 +194,7 @@ class TimeSheetView(View):
         stores = self.get_stores()
         store_values = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores]
         store_values.insert(0, ('', "(all)"))
-        form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values))
+        form.set_widget('store', dfwidget.SelectWidget(values=store_values))
         if context['store']:
             form.set_default('store', context['store'].uuid)
         else:
@@ -206,7 +206,7 @@ class TimeSheetView(View):
         departments = self.get_departments()
         department_values = [(d.uuid, d.name) for d in departments]
         department_values.insert(0, ('', "(all)"))
-        form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values))
+        form.set_widget('department', dfwidget.SelectWidget(values=department_values))
         if context['department']:
             form.set_default('department', context['department'].uuid)
         else:
@@ -238,7 +238,7 @@ class TimeSheetView(View):
         form = forms.Form(schema=EmployeeShiftFilter(), request=self.request)
 
         if self.request.has_perm('{}.viewall'.format(permission_prefix)):
-            employee_display = six.text_type(context['employee'] or '')
+            employee_display = str(context['employee'] or '')
             employees_url = self.request.route_url('employees.autocomplete')
             form.set_widget('employee', forms.widgets.JQueryAutocompleteWidget(
                 field_display=employee_display, service_url=employees_url))
@@ -295,6 +295,7 @@ class TimeSheetView(View):
         self.request.session['timesheet.{}.{}'.format(mainkey, key)] = value
 
     def get_stores(self):
+        model = self.model
         return Session.query(model.Store).order_by(model.Store.id).all()
 
     def get_store_options(self, stores):
@@ -302,6 +303,7 @@ class TimeSheetView(View):
         return tags.Options(options, prompt="(all)")
 
     def get_departments(self):
+        model = self.model
         return Session.query(model.Department).order_by(model.Department.name).all()
 
     def get_department_options(self, departments):
@@ -404,6 +406,9 @@ class TimeSheetView(View):
         Fetch all shift data of the given model class (``cls``), according to
         the given params.  The cached shift data is attached to each employee.
         """
+        app = self.get_rattail_app()
+        model = self.model
+
         # TODO: a bit hacky, this?  display hours as HH:MM by default, but
         # check config in order to display as HH.HH for certain users
         hours_style = 'pretty'
@@ -414,19 +419,19 @@ class TimeSheetView(View):
             hours_style = 'pretty'
 
         shift_type = 'scheduled' if cls is model.ScheduledShift else 'worked'
-        min_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[0], datetime.time(0)))
-        max_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0)))
+        min_time = app.localtime(datetime.datetime.combine(weekdays[0], datetime.time(0)))
+        max_time = app.localtime(datetime.datetime.combine(weekdays[-1] + datetime.timedelta(days=1), datetime.time(0)))
         shifts = Session.query(cls)\
                         .filter(cls.employee_uuid.in_([e.uuid for e in employees]))\
                         .filter(sa.or_(
                             sa.and_(
-                                cls.start_time >= make_utc(min_time),
-                                cls.start_time < make_utc(max_time),
+                                cls.start_time >= app.make_utc(min_time),
+                                cls.start_time < app.make_utc(max_time),
                             ),
                             sa.and_(
                                 cls.start_time == None,
-                                cls.end_time >= make_utc(min_time),
-                                cls.end_time < make_utc(max_time),
+                                cls.end_time >= app.make_utc(min_time),
+                                cls.end_time < app.make_utc(max_time),
                             )))\
                         .all()
 
@@ -468,9 +473,9 @@ class TimeSheetView(View):
                 hours = empday['{}_hours'.format(shift_type)]
                 if hours:
                     if hours_style == 'pretty':
-                        display = pretty_hours(hours)
+                        display = app.render_duration(hours=hours)
                     else: # decimal
-                        display = six.text_type(hours_as_decimal(hours))
+                        display = str(hours_as_decimal(hours))
                     if empday['hours_incomplete']:
                         display = '{} ?'.format(display)
                     empday['{}_hours_display'.format(shift_type)] = display
@@ -479,9 +484,9 @@ class TimeSheetView(View):
             hours = getattr(employee, '{}_hours'.format(shift_type))
             if hours:
                 if hours_style == 'pretty':
-                    display = pretty_hours(hours)
+                    display = app.render_duration(hours=hours)
                 else: # decimal
-                    display = six.text_type(hours_as_decimal(hours))
+                    display = str(hours_as_decimal(hours))
                 if hours_incomplete:
                     display = '{} ?'.format(display)
                 setattr(employee, '{}_hours_display'.format(shift_type), display)
diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py
index efaf4e33..c8b82724 100644
--- a/tailbone/views/shifts/schedule.py
+++ b/tailbone/views/shifts/schedule.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Views for employee schedules
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
 from rattail.db import model
@@ -81,7 +79,7 @@ class ScheduleView(TimeSheetView):
             deleted = []
             for uuid, value in data['delete'].items():
                 if value == 'delete':
-                    shift = Session.query(model.ScheduledShift).get(uuid)
+                    shift = Session.get(model.ScheduledShift, uuid)
                     if shift:
                         Session.delete(shift)
                         deleted.append(uuid)
@@ -103,7 +101,7 @@ class ScheduleView(TimeSheetView):
                     Session.add(shift)
                     created[uuid] = shift
                 else:
-                    shift = Session.query(model.ScheduledShift).get(uuid)
+                    shift = Session.get(model.ScheduledShift, uuid)
                     assert shift
                     updated[uuid] = shift
                 start_time = datetime.datetime.strptime(data['start_time'][uuid], time_format)
@@ -212,5 +210,12 @@ class ScheduleView(TimeSheetView):
                         permission='schedule.print')
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    ScheduleView = kwargs.get('ScheduleView', base['ScheduleView'])
     ScheduleView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py
index 84d303e9..a8874127 100644
--- a/tailbone/views/shifts/timesheet.py
+++ b/tailbone/views/shifts/timesheet.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Views for employee time sheets
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
 from rattail.db import model
@@ -74,7 +72,7 @@ class TimeSheetView(BaseTimeSheetView):
             deleted = []
             for uuid, value in list(data['delete'].items()):
                 assert value == 'delete'
-                shift = Session.query(model.WorkedShift).get(uuid)
+                shift = Session.get(model.WorkedShift, uuid)
                 assert shift
                 Session.delete(shift)
                 deleted.append(uuid)
@@ -93,7 +91,7 @@ class TimeSheetView(BaseTimeSheetView):
                     Session.add(shift)
                     created[uuid] = shift
                 else:
-                    shift = Session.query(model.WorkedShift).get(uuid)
+                    shift = Session.get(model.WorkedShift, uuid)
                     assert shift
                     updated[uuid] = shift
 
@@ -133,5 +131,12 @@ class TimeSheetView(BaseTimeSheetView):
                         permission='timesheet.edit')
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    TimeSheetView = kwargs.get('TimeSheetView', base['TimeSheetView'])
     TimeSheetView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py
index fa94f92e..5d507745 100644
--- a/tailbone/views/stores.py
+++ b/tailbone/views/stores.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -36,12 +36,13 @@ from tailbone import grids
 from tailbone.views import MasterView
 
 
-class StoresView(MasterView):
+class StoreView(MasterView):
     """
     Master view for the Store class.
     """
     model_class = model.Store
     has_versions = True
+    touchable = True
 
     grid_columns = [
         'id',
@@ -56,6 +57,7 @@ class StoresView(MasterView):
         'phone',
         'email',
         'database_key',
+        'archived',
     ]
 
     labels = {
@@ -65,7 +67,7 @@ class StoresView(MasterView):
     }
 
     def configure_grid(self, g):
-        super(StoresView, self).configure_grid(g)
+        super(StoreView, self).configure_grid(g)
 
         g.set_joiner('email', lambda q: q.outerjoin(model.StoreEmailAddress, sa.and_(
             model.StoreEmailAddress.parent_uuid == model.Store.uuid,
@@ -80,6 +82,10 @@ class StoresView(MasterView):
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
 
+        # archived
+        g.filters['archived'].default_active = True
+        g.filters['archived'].default_verb = 'is_false_null'
+
         g.set_sorter('phone', model.StorePhoneNumber.number)
         g.set_sorter('email', model.StoreEmailAddress.address)
         g.set_sort_defaults('id')
@@ -87,8 +93,12 @@ class StoresView(MasterView):
         g.set_link('id')
         g.set_link('name')
 
+    def grid_extra_class(self, store, i):
+        if store.archived:
+            return 'warning'
+
     def configure_form(self, f):
-        super(StoresView, self).configure_form(f)
+        super(StoreView, self).configure_form(f)
 
         f.remove_field('employees')
         f.remove_field('phones')
@@ -107,6 +117,16 @@ class StoresView(MasterView):
             (model.StoreEmailAddress, 'parent_uuid'),
         ]
 
+# TODO: deprecate / remove this
+StoresView = StoreView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    StoreView = kwargs.get('StoreView', base['StoreView'])
+    StoreView.defaults(config)
+
 
 def includeme(config):
-    StoresView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py
index 1e65d56f..43648ea6 100644
--- a/tailbone/views/subdepartments.py
+++ b/tailbone/views/subdepartments.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,20 +24,24 @@
 Subdepartment Views
 """
 
-from __future__ import unicode_literals, absolute_import
+import sqlalchemy as sa
 
 from rattail.db import model
 
+from deform import widget as dfwidget
+
 from tailbone.db import Session
 from tailbone.views import MasterView
 
 
-class SubdepartmentsView(MasterView):
+class SubdepartmentView(MasterView):
     """
     Master view for the Subdepartment class.
     """
     model_class = model.Subdepartment
+    supports_autocomplete = True
     touchable = True
+    results_downloadable = True
     has_versions = True
 
     grid_columns = [
@@ -46,6 +50,12 @@ class SubdepartmentsView(MasterView):
         'department',
     ]
 
+    form_fields = [
+        'number',
+        'name',
+        'department',
+    ]
+
     mergeable = True
     merge_additive_fields = [
         'product_count',
@@ -57,8 +67,28 @@ class SubdepartmentsView(MasterView):
         'department_number',
     ]
 
+    has_rows = True
+    model_row_class = model.Product
+
+    row_labels = {
+        'upc': "UPC",
+    }
+
+    row_grid_columns = [
+        'upc',
+        'brand',
+        'description',
+        'size',
+        'vendor',
+        'regular_price',
+        'current_price',
+    ]
+
     def configure_grid(self, g):
-        super(SubdepartmentsView, self).configure_grid(g)
+        super(SubdepartmentView, self).configure_grid(g)
+
+        # number
+        g.set_link('number')
 
         # name
         g.filters['name'].default_active = True
@@ -70,16 +100,40 @@ class SubdepartmentsView(MasterView):
         g.set_sorter('department', model.Department.name)
         g.set_filter('department', model.Department.name)
 
-        g.set_link('number')
         g.set_link('name')
 
     def configure_form(self, f):
-        super(SubdepartmentsView, self).configure_form(f)
+        super(SubdepartmentView, self).configure_form(f)
         f.remove_field('products')
 
-        # TODO: figure out this dang department situation..
-        f.remove_field('department_uuid')
-        f.set_readonly('department')
+        # department
+        if self.creating or self.editing:
+            if 'department' in f.fields:
+                f.replace('department', 'department_uuid')
+                departments = self.get_departments()
+                dept_values = [(d.uuid, "{} {}".format(d.number, d.name))
+                               for d in departments]
+                require_department = False
+                if not require_department:
+                    dept_values.insert(0, ('', "(none)"))
+                f.set_widget('department_uuid',
+                             dfwidget.SelectWidget(values=dept_values))
+                f.set_label('department_uuid', "Department")
+        else:
+            f.set_readonly('department')
+            f.set_renderer('department', self.render_department)
+
+    def get_departments(self):
+        """
+        Returns the list of departments to be exposed in a drop-down.
+        """
+        model = self.model
+        return self.Session.query(model.Department)\
+                           .filter(sa.or_(
+                               model.Department.product == True,
+                               model.Department.product == None))\
+                           .order_by(model.Department.name)\
+                           .all()
 
     def get_merge_data(self, subdept):
         return {
@@ -98,6 +152,43 @@ class SubdepartmentsView(MasterView):
 
         Session.delete(removing)
 
+    def get_row_data(self, subdepartment):
+        return self.Session.query(model.Product)\
+                           .filter(model.Product.subdepartment == subdepartment)
+
+    def get_parent(self, product):
+        return product.subdepartment
+
+    def configure_row_grid(self, g):
+        super(SubdepartmentView, self).configure_row_grid(g)
+
+        app = self.get_rattail_app()
+        self.handler = app.get_products_handler()
+        g.set_renderer('regular_price', self.render_price)
+        g.set_renderer('current_price', self.render_price)
+
+        g.set_sort_defaults('upc')
+
+    def render_price(self, product, field):
+        if not product.not_for_sale:
+            price = product[field]
+            if price:
+                return self.handler.render_price(price)
+
+    def row_view_action_url(self, product, i):
+        return self.request.route_url('products.view', uuid=product.uuid)
+
+
+# TODO: deprecate / remove this
+SubdepartmentsView = SubdepartmentView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    SubdepartmentView = kwargs.get('SubdepartmentView', base['SubdepartmentView'])
+    SubdepartmentView.defaults(config)
+
 
 def includeme(config):
-    SubdepartmentsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py
index 78363f66..bfd52f2b 100644
--- a/tailbone/views/tables.py
+++ b/tailbone/views/tables.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,30 +24,66 @@
 Views with info about the underlying Rattail tables
 """
 
-from __future__ import unicode_literals, absolute_import
+import os
+import sys
+import warnings
+
+import sqlalchemy as sa
+from sqlalchemy_utils import get_mapper
+
+from rattail.util import simple_error
+
+import colander
+from deform import widget as dfwidget
+from webhelpers2.html import HTML
 
 from tailbone.views import MasterView
 
 
-class TablesView(MasterView):
+class TableView(MasterView):
     """
     Master view for tables
     """
     normalized_model_name = 'table'
-    model_key = 'name'
+    model_key = 'table_name'
     model_title = "Table"
     creatable = False
     editable = False
     deletable = False
-    viewable = False
     filterable = False
     pageable = False
 
+    labels = {
+        'branch_name': "Schema Branch",
+        'model_name': "Model Class",
+        'module_name': "Module",
+        'module_file': "File",
+    }
+
     grid_columns = [
-        'name',
+        'table_name',
         'row_count',
     ]
 
+    has_rows = True
+    rows_title = "Columns"
+    rows_pageable = False
+    rows_filterable = False
+    rows_viewable = False
+
+    row_grid_columns = [
+        'sequence',
+        'column_name',
+        'data_type',
+        'nullable',
+        'description',
+    ]
+
+    def __init__(self, request):
+        super().__init__(request)
+        app = self.get_rattail_app()
+        self.db_handler = app.get_db_handler()
+
     def get_data(self, **kwargs):
         """
         Fetch existing table names and estimate row counts via PG SQL
@@ -61,15 +97,363 @@ class TablesView(MasterView):
         where schemaname = 'public'
         order by n_live_tup desc;
         """
-        result = self.Session.execute(sql)
-        return [dict(name=row['relname'], row_count=row['n_live_tup'])
+        result = self.Session.execute(sa.text(sql))
+        return [dict(table_name=row.relname, row_count=row.n_live_tup)
                 for row in result]
 
     def configure_grid(self, g):
-        g.sorters['name'] = g.make_simple_sorter('name', foldcase=True)
+        super().configure_grid(g)
+
+        # table_name
+        g.sorters['table_name'] = g.make_simple_sorter('table_name', foldcase=True)
+        g.set_sort_defaults('table_name')
+        g.set_searchable('table_name')
+        g.set_link('table_name')
+
+        # row_count
         g.sorters['row_count'] = g.make_simple_sorter('row_count')
-        g.set_sort_defaults('name')
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        # TODO: should render this instead, by inspecting table
+        if not self.creating:
+            f.remove('versioned')
+
+    def get_instance(self):
+        model = self.model
+        table_name = self.request.matchdict['table_name']
+
+        sql = """
+        select n_live_tup
+        from pg_stat_user_tables
+        where schemaname = 'public' and relname = :table_name
+        order by n_live_tup desc;
+        """
+        result = self.Session.execute(sql, {'table_name': table_name})
+        row = result.fetchone()
+        if not row:
+            raise self.notfound()
+
+        data = {
+            'table_name': table_name,
+            'row_count': row['n_live_tup'],
+        }
+
+        table = model.Base.metadata.tables.get(table_name)
+        data['table'] = table
+        if table is not None:
+            try:
+                mapper = get_mapper(table)
+            except ValueError:
+                pass
+            else:
+                data['model_name'] = mapper.class_.__name__
+                data['model_title'] = mapper.class_.get_model_title()
+                data['model_title_plural'] = mapper.class_.get_model_title_plural()
+                data['description'] = mapper.class_.__doc__
+
+                # TODO: how to reliably get branch?  must walk all revisions?
+                module_parts = mapper.class_.__module__.split('.')
+                data['branch_name'] = module_parts[0]
+
+                data['module_name'] = mapper.class_.__module__
+                data['module_file'] = sys.modules[mapper.class_.__module__].__file__
+
+        return data
+
+    def get_instance_title(self, table):
+        return table['table_name']
+
+    def make_form_schema(self):
+        return TableSchema()
+
+    def get_xref_buttons(self, table):
+        buttons = super().get_xref_buttons(table)
+
+        if table.get('model_name'):
+            all_views = self.request.registry.settings['tailbone_model_views']
+            model_views = all_views.get(table['model_name'], [])
+            for view in model_views:
+                url = self.request.route_url(view['route_prefix'])
+                buttons.append(self.make_xref_button(url=url, text=view['label'],
+                                                     internal=True))
+
+            if self.request.has_perm('model_views.create'):
+                url = self.request.route_url('model_views.create',
+                                             _query={'model_name': table['model_name']})
+                buttons.append(self.make_button("New View",
+                                                is_primary=True,
+                                                url=url,
+                                                icon_left='plus'))
+
+        return buttons
+
+    def template_kwargs_create(self, **kwargs):
+        kwargs = super().template_kwargs_create(**kwargs)
+        app = self.get_rattail_app()
+        model = self.model
+
+        kwargs['alembic_current_head'] = self.db_handler.check_alembic_current_head()
+
+        kwargs['branch_name_options'] = self.db_handler.get_alembic_branch_names()
+
+        branch_name = app.get_table_prefix()
+        if branch_name not in kwargs['branch_name_options']:
+            branch_name = None
+        kwargs['branch_name'] = branch_name
+
+        kwargs['existing_tables'] = [{'name': table}
+                                     for table in sorted(model.Base.metadata.tables)]
+
+        kwargs['model_dir'] = (os.path.dirname(model.__file__)
+                               + os.sep)
+
+        return kwargs
+
+    def write_model_file(self):
+        data = self.request.json_body
+        path = data['module_file']
+        model = self.model
+
+        if os.path.exists(path):
+            if data['overwrite']:
+                os.remove(path)
+            else:
+                return {'error': "File already exists"}
+
+        for column in data['columns']:
+            if column['data_type']['type'] == '_fk_uuid_' and column['relationship']:
+                name = column['relationship']
+
+                table = model.Base.metadata.tables[column['data_type']['reference']]
+                try:
+                    mapper = get_mapper(table)
+                except ValueError:
+                    reference_model = table.name.capitalize()
+                else:
+                    reference_model = mapper.class_.__name__
+
+                column['relationship'] = {
+                    'name': name,
+                    'reference_model': reference_model,
+                }
+
+        self.db_handler.write_table_model(data, path)
+        return {'ok': True}
+
+    def check_model(self):
+        model = self.model
+        data = self.request.json_body
+        model_name = data['model_name']
+
+        if not hasattr(model, model_name):
+            return {'ok': True,
+                    'problem': "class not found in primary model contents",
+                    'model': self.model.__name__}
+
+        # TODO: probably should inspect closer before assuming ok..?
+
+        return {'ok': True}
+
+    def write_revision_script(self):
+        data = self.request.json_body
+        script = self.db_handler.generate_revision_script(data['branch'],
+                                                          message=data['message'])
+        return {'ok': True,
+                'script': script.path}
+
+    def upgrade_db(self):
+        self.db_handler.upgrade_db()
+        return {'ok': True}
+
+    def check_table(self):
+        model = self.model
+        data = self.request.json_body
+        table_name = data['table_name']
+
+        table = model.Base.metadata.tables.get(table_name)
+        if table is None:
+            return {'ok': True,
+                    'problem': "Table does not exist in model metadata!"}
+
+        try:
+            count = self.Session.query(table).count()
+        except Exception as error:
+            return {'ok': True,
+                    'problem': simple_error(error)}
+
+        url = self.request.route_url('{}.view'.format(self.get_route_prefix()),
+                                     table_name=table_name)
+        return {'ok': True, 'url': url}
+
+    def get_row_data(self, table):
+        data = []
+        for i, column in enumerate(table['table'].columns, 1):
+            data.append({
+                'column': column,
+                'sequence': i,
+                'column_name': column.name,
+                'data_type': str(repr(column.type)),
+                'nullable': column.nullable,
+                'description': column.doc,
+            })
+        return data
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+
+        g.sorters['sequence'] = g.make_simple_sorter('sequence')
+        g.set_sort_defaults('sequence')
+        g.set_label('sequence', "Seq.")
+
+        g.sorters['column_name'] = g.make_simple_sorter('column_name',
+                                                        foldcase=True)
+        g.set_searchable('column_name')
+
+        g.sorters['data_type'] = g.make_simple_sorter('data_type',
+                                                      foldcase=True)
+        g.set_searchable('data_type')
+
+        g.set_type('nullable', 'boolean')
+        g.sorters['nullable'] = g.make_simple_sorter('nullable')
+
+        g.set_renderer('description', self.render_column_description)
+        g.set_searchable('description')
+
+    def render_column_description(self, column, field):
+        text = column[field]
+        if not text:
+            return
+
+        max_length = 80
+
+        if len(text) < max_length:
+            return text
+
+        return HTML.tag('span', title=text, c="{} ...".format(text[:max_length]))
+
+    def migrations(self):
+        # TODO: allow alembic upgrade on POST
+        # TODO: pass current revisions to page context
+        return self.render_to_response('migrations', {})
+
+    @classmethod
+    def defaults(cls, config):
+        rattail_config = config.registry.settings.get('rattail_config')
+
+        # allow creating tables only if *not* production
+        if not rattail_config.production():
+            cls.creatable = True
+
+        cls._table_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _table_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+
+        # migrations
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.migrations'.format(permission_prefix),
+                                       "View / apply Alembic migrations")
+        config.add_route('{}.migrations'.format(route_prefix),
+                         '{}/migrations'.format(url_prefix))
+        config.add_view(cls, attr='migrations',
+                        route_name='{}.migrations'.format(route_prefix),
+                        renderer='json',
+                        permission='{}.migrations'.format(permission_prefix))
+
+        if cls.creatable:
+
+            # write model class to file
+            config.add_route('{}.write_model_file'.format(route_prefix),
+                             '{}/write-model-file'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='write_model_file',
+                            route_name='{}.write_model_file'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.create'.format(permission_prefix))
+
+            # check model
+            config.add_route('{}.check_model'.format(route_prefix),
+                             '{}/check-model'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='check_model',
+                            route_name='{}.check_model'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.create'.format(permission_prefix))
+
+            # generate revision script
+            config.add_route('{}.write_revision_script'.format(route_prefix),
+                             '{}/write-revision-script'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='write_revision_script',
+                            route_name='{}.write_revision_script'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.create'.format(permission_prefix))
+
+            # upgrade db
+            config.add_route('{}.upgrade_db'.format(route_prefix),
+                             '{}/upgrade-db'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='upgrade_db',
+                            route_name='{}.upgrade_db'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.create'.format(permission_prefix))
+
+            # check table
+            config.add_route('{}.check_table'.format(route_prefix),
+                             '{}/check-table'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='check_table',
+                            route_name='{}.check_table'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.create'.format(permission_prefix))
+
+
+class TablesView(TableView):
+
+    def __init__(self, request):
+        warnings.warn("TablesView is deprecated; please use TableView instead",
+                      DeprecationWarning, stacklevel=2)
+        super().__init__(request)
+
+
+class TableSchema(colander.Schema):
+
+    table_name = colander.SchemaNode(colander.String())
+
+    row_count = colander.SchemaNode(colander.Integer(),
+                                    missing=colander.null)
+
+    model_name = colander.SchemaNode(colander.String())
+
+    model_title = colander.SchemaNode(colander.String())
+
+    model_title_plural = colander.SchemaNode(colander.String())
+
+    description = colander.SchemaNode(colander.String())
+
+    branch_name = colander.SchemaNode(colander.String())
+
+    module_name = colander.SchemaNode(colander.String(),
+                                      missing=colander.null)
+
+    module_file = colander.SchemaNode(colander.String(),
+                                      missing=colander.null)
+
+    versioned = colander.SchemaNode(colander.Bool())
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    TableView = kwargs.get('TableView', base['TableView'])
+    TableView.defaults(config)
 
 
 def includeme(config):
-    TablesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py
index 99177dea..b2afaeb9 100644
--- a/tailbone/views/taxes.py
+++ b/tailbone/views/taxes.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,14 +24,12 @@
 Tax Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 from tailbone.views import MasterView
 
 
-class TaxesView(MasterView):
+class TaxView(MasterView):
     """
     Master view for taxes.
     """
@@ -53,13 +51,37 @@ class TaxesView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TaxesView, self).configure_grid(g)
-        g.filters['description'].default_active = True
-        g.filters['description'].default_verb = 'contains'
+        super().configure_grid(g)
+
+        # code
         g.set_sort_defaults('code')
         g.set_link('code')
+
+        # description
         g.set_link('description')
+        g.filters['description'].default_active = True
+        g.filters['description'].default_verb = 'contains'
+
+        # rate
+        g.set_type('rate', 'percent')
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        # rate
+        f.set_type('rate', 'percent')
+
+
+# TODO: deprecate / remove this
+TaxesView = TaxView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    TaxView = kwargs.get('TaxView', base['TaxView'])
+    TaxView.defaults(config)
 
 
 def includeme(config):
-    TaxesView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py
index eeb22882..4ce52009 100644
--- a/tailbone/views/tempmon/appliances.py
+++ b/tailbone/views/tempmon/appliances.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,11 +24,9 @@
 Views for tempmon appliances
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import io
 import os
 
-import six
 from PIL import Image
 
 from rattail_tempmon.db import model as tempmon
@@ -68,7 +66,7 @@ class TempmonApplianceView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TempmonApplianceView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # name
         g.set_sort_defaults('name')
@@ -94,7 +92,7 @@ class TempmonApplianceView(MasterView):
         return HTML.tag('div', class_='image-frame', c=[helper, image])
 
     def configure_form(self, f):
-        super(TempmonApplianceView, self).configure_form(f)
+        super().configure_form(f)
 
         # name
         f.set_validator('name', self.unique_name)
@@ -121,6 +119,14 @@ class TempmonApplianceView(MasterView):
         elif self.creating or self.editing:
             f.remove_field('probes')
 
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        appliance = kwargs['instance']
+
+        kwargs['probes_data'] = self.normalize_probes(appliance.probes)
+
+        return kwargs
+
     def unique_name(self, node, value):
         query = self.Session.query(tempmon.Appliance)\
                             .filter(tempmon.Appliance.name == value)
@@ -168,13 +174,13 @@ class TempmonApplianceView(MasterView):
                 im = Image.open(f)
 
                 im.thumbnail((600, 600), Image.ANTIALIAS)
-                data = six.BytesIO()
+                data = io.BytesIO()
                 im.save(data, 'JPEG')
                 appliance.image_normal = data.getvalue()
                 data.close()
 
                 im.thumbnail((150, 150), Image.ANTIALIAS)
-                data = six.BytesIO()
+                data = io.BytesIO()
                 im.save(data, 'JPEG')
                 appliance.image_thumbnail = data.getvalue()
                 data.close()
@@ -187,5 +193,12 @@ class TempmonApplianceView(MasterView):
             raise NotImplementedError("too many uploads?")
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    TempmonApplianceView = kwargs.get('TempmonApplianceView', base['TempmonApplianceView'])
     TempmonApplianceView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py
index 1952c56d..1b2d49d8 100644
--- a/tailbone/views/tempmon/clients.py
+++ b/tailbone/views/tempmon/clients.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Views for tempmon clients
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import subprocess
 
 from rattail.config import parse_list
@@ -51,6 +49,7 @@ class TempmonClientView(MasterView):
 
     has_rows = True
     model_row_class = tempmon.Reading
+    rows_title = "Readings"
 
     grid_columns = [
         'config_key',
@@ -83,7 +82,7 @@ class TempmonClientView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TempmonClientView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # config_key
         g.set_label('config_key', "Key")
@@ -116,7 +115,7 @@ class TempmonClientView(MasterView):
         return "No"
 
     def configure_form(self, f):
-        super(TempmonClientView, self).configure_form(f)
+        super().configure_form(f)
 
         # config_key
         f.set_validator('config_key', self.unique_config_key)
@@ -159,6 +158,14 @@ class TempmonClientView(MasterView):
         # archived
         f.set_helptext('archived', tempmon.Client.archived.__doc__)
 
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        client = kwargs['instance']
+
+        kwargs['probes_data'] = self.normalize_probes(client.probes)
+
+        return kwargs
+
     def objectify(self, form, data=None):
 
         # this is a hack to prevent updates to the 'enabled' timestamp, when
@@ -169,7 +176,7 @@ class TempmonClientView(MasterView):
             if data['enabled'] and form.model_instance.enabled:
                 data['enabled'] = form.model_instance.enabled
 
-        return super(TempmonClientView, self).objectify(form, data=data)
+        return super().objectify(form, data=data)
 
     def unique_config_key(self, node, value):
         query = self.Session.query(tempmon.Client)\
@@ -222,7 +229,7 @@ class TempmonClientView(MasterView):
         return reading.client
 
     def configure_row_grid(self, g):
-        super(TempmonClientView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         # probe
         g.set_filter('probe', tempmon.Probe.description)
@@ -276,5 +283,12 @@ class TempmonClientView(MasterView):
                         permission='{}.restart'.format(permission_prefix))
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    TempmonClientView = kwargs.get('TempmonClientView', base['TempmonClientView'])
     TempmonClientView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py
index 6665f50e..7540abbe 100644
--- a/tailbone/views/tempmon/core.py
+++ b/tailbone/views/tempmon/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Common stuff for tempmon views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from webhelpers2.html import HTML
 
 from tailbone import views, grids
@@ -42,6 +40,28 @@ class MasterView(views.MasterView):
         from rattail_tempmon.db import Session
         return Session()
 
+    def normalize_probes(self, probes):
+        data = []
+        for probe in probes:
+            view_url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid)
+            edit_url = self.request.route_url('tempmon.probes.edit', uuid=probe.uuid)
+            data.append({
+                'uuid': probe.uuid,
+                'url': view_url,
+                '_action_url_view': view_url,
+                '_action_url_edit': edit_url,
+                'description': probe.description,
+                'critical_temp_min': probe.critical_temp_min,
+                'good_temp_min': probe.good_temp_min,
+                'good_temp_max': probe.good_temp_max,
+                'critical_temp_max': probe.critical_temp_max,
+                'status': self.enum.TEMPMON_PROBE_STATUS.get(probe.status, '??'),
+                'enabled': "Yes" if probe.enabled else "No",
+            })
+        app = self.get_rattail_app()
+        data = app.json_friendly(data)
+        return data
+
     def render_probes(self, obj, field):
         """
         This method is used by Appliance and Client views.
@@ -50,17 +70,16 @@ class MasterView(views.MasterView):
             return ""
 
         route_prefix = self.get_route_prefix()
-        view_url = lambda p, i: self.request.route_url('tempmon.probes.view', uuid=p.uuid)
-        actions = [
-            grids.GridAction('view', icon='zoomin', url=view_url),
-        ]
-        if self.request.has_perm('tempmon.probes.edit'):
-            url = lambda p, i: self.request.route_url('tempmon.probes.edit', uuid=p.uuid)
-            actions.append(grids.GridAction('edit', icon='pencil', url=url))
 
-        g = grids.Grid(
-            key='{}.probes'.format(route_prefix),
-            data=obj.probes,
+        actions = [self.make_grid_action_view()]
+        if self.request.has_perm('tempmon.probes.edit'):
+            actions.append(self.make_grid_action_edit())
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.probes',
+            data=[],
             columns=[
                 'description',
                 'critical_temp_min',
@@ -76,10 +95,8 @@ class MasterView(views.MasterView):
                 'good_temp_max': "Good Max",
                 'critical_temp_max': "Crit. Max",
             },
-            url=lambda p: self.request.route_url('tempmon.probes.view', uuid=p.uuid),
             linked_columns=['description'],
-            main_actions=actions,
+            actions=actions,
         )
-        g.set_enum('status', self.enum.TEMPMON_PROBE_STATUS)
-        g.set_type('enabled', 'boolean')
-        return HTML.literal(g.render_grid())
+        return HTML.literal(
+            g.render_table_element(data_prop='probesData'))
diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py
index 321f8c83..515eabc9 100644
--- a/tailbone/views/tempmon/dashboard.py
+++ b/tailbone/views/tempmon/dashboard.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,11 @@
 Tempmon "Dashboard" View
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
 from rattail.time import localtime, make_utc
 from rattail_tempmon.db import model as tempmon
 
-from webhelpers2.html import tags
-
 from tailbone.views import View
 from tailbone.db import TempmonSession
 
@@ -44,13 +40,12 @@ class TempmonDashboardView(View):
     session_key = 'tempmon.dashboard.appliance_uuid'
 
     def dashboard(self):
-        use_buefy = self.get_use_buefy()
 
         if self.request.method == 'POST':
             appliance = None
             uuid = self.request.POST.get('appliance_uuid')
             if uuid:
-                appliance = TempmonSession.query(tempmon.Appliance).get(uuid)
+                appliance = TempmonSession.get(tempmon.Appliance, uuid)
                 if appliance:
                     self.request.session[self.session_key] = appliance.uuid
             if not appliance:
@@ -71,29 +66,23 @@ class TempmonDashboardView(View):
                     self.request.session[self.session_key] = selected_uuid
 
         if not selected_appliance and selected_uuid:
-            selected_appliance = TempmonSession.query(tempmon.Appliance)\
-                                               .get(selected_uuid)
+            selected_appliance = TempmonSession.get(tempmon.Appliance, selected_uuid)
+
+        context = {
+            'index_url': self.request.route_url('tempmon.appliances'),
+            'index_title': "TempMon Appliances",
+            'appliance': selected_appliance,
+        }
 
         appliances = TempmonSession.query(tempmon.Appliance)\
                                    .order_by(tempmon.Appliance.name)\
                                    .all()
-        appliance_options = tags.Options([
-            tags.Option(appliance.name, appliance.uuid)
-            for appliance in appliances])
 
-        if use_buefy:
-            appliance_select = None
-            raise NotImplementedError
-        else:
-            appliance_select = tags.select('appliance_uuid', selected_uuid, appliance_options)
+        context['appliances_data'] = [{'uuid': a.uuid,
+                                       'name': a.name}
+                                      for a in appliances]
 
-        return {
-            'index_url': self.request.route_url('tempmon.appliances'),
-            'index_title': "TempMon Appliances",
-            'use_buefy': use_buefy,
-            'appliance_select': appliance_select,
-            'appliance': selected_appliance,
-        }
+        return context
 
     def readings(self):
 
@@ -101,7 +90,7 @@ class TempmonDashboardView(View):
         uuid = self.request.params.get('appliance_uuid')
         if not uuid:
             return {'error': "Must specify valid appliance_uuid"}
-        appliance = TempmonSession.query(tempmon.Appliance).get(uuid)
+        appliance = TempmonSession.get(tempmon.Appliance, uuid)
         if not appliance:
             return {'error': "Must specify valid appliance_uuid"}
 
@@ -158,5 +147,12 @@ class TempmonDashboardView(View):
                         renderer='json')
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    TempmonDashboardView = kwargs.get('TempmonDashboardView', base['TempmonDashboardView'])
     TempmonDashboardView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py
index de0ca42d..573f9a2d 100644
--- a/tailbone/views/tempmon/probes.py
+++ b/tailbone/views/tempmon/probes.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +24,13 @@
 Views for tempmon probes
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
-
-from rattail.time import make_utc, localtime
 from rattail_tempmon.db import model as tempmon
 
 import colander
 from deform import widget as dfwidget
-from webhelpers2.html import tags, HTML
+from webhelpers2.html import tags
 
 from tailbone import forms, grids
 from tailbone.views.tempmon import MasterView
@@ -54,6 +49,7 @@ class TempmonProbeView(MasterView):
 
     has_rows = True
     model_row_class = tempmon.Reading
+    rows_title = "Readings"
 
     labels = {
         'critical_max_timeout': "Critical High Timeout",
@@ -103,10 +99,11 @@ class TempmonProbeView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TempmonProbeView, self).configure_grid(g)
+        super().configure_grid(g)
 
-        g.joiners['client'] = lambda q: q.join(tempmon.Client)
-        g.sorters['client'] = g.make_sorter(tempmon.Client.config_key)
+        # client
+        g.set_joiner('client', lambda q: q.join(tempmon.Client))
+        g.set_sorter('client', tempmon.Client.config_key)
         g.set_sort_defaults('client')
 
         g.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE)
@@ -126,7 +123,7 @@ class TempmonProbeView(MasterView):
         return "No"
 
     def configure_form(self, f):
-        super(TempmonProbeView, self).configure_form(f)
+        super().configure_form(f)
 
         # config_key
         f.set_validator('config_key', self.unique_config_key)
@@ -191,7 +188,7 @@ class TempmonProbeView(MasterView):
             if data['enabled'] and form.model_instance.enabled:
                 data['enabled'] = form.model_instance.enabled
 
-        return super(TempmonProbeView, self).objectify(form, data=data)
+        return super().objectify(form, data=data)
 
     def unique_config_key(self, node, value):
         query = self.Session.query(tempmon.Probe)\
@@ -206,7 +203,7 @@ class TempmonProbeView(MasterView):
         client = probe.client
         if not client:
             return ""
-        text = six.text_type(client)
+        text = str(client)
         url = self.request.route_url('tempmon.clients.view', uuid=client.uuid)
         return tags.link_to(text, url)
 
@@ -214,7 +211,7 @@ class TempmonProbeView(MasterView):
         appliance = probe.appliance
         if not appliance:
             return ""
-        text = six.text_type(appliance)
+        text = str(appliance)
         url = self.request.route_url('tempmon.appliances.view', uuid=appliance.uuid)
         return tags.link_to(text, url)
 
@@ -245,7 +242,7 @@ class TempmonProbeView(MasterView):
         return reading.client
 
     def configure_row_grid(self, g):
-        super(TempmonProbeView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         # # probe
         # g.set_filter('probe', tempmon.Probe.description)
@@ -255,7 +252,6 @@ class TempmonProbeView(MasterView):
 
     def graph(self):
         probe = self.get_instance()
-        use_buefy = self.get_use_buefy()
 
         key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid)
         selected = self.request.params.get('time-range')
@@ -263,30 +259,16 @@ class TempmonProbeView(MasterView):
             selected = self.request.session.get(key, 'last hour')
         self.request.session[key] = selected
 
-        range_options = tags.Options([
-            tags.Option("Last Hour", 'last hour'),
-            tags.Option("Last 6 Hours", 'last 6 hours'),
-            tags.Option("Last Day", 'last day'),
-            tags.Option("Last Week", 'last week'),
-        ])
-
-        if use_buefy:
-            time_range = HTML.tag('b-select', c=[range_options.render()],
-                                  **{'v-model': 'currentTimeRange',
-                                     '@input': 'timeRangeChanged'})
-        else:
-            time_range = tags.select('time-range', selected, range_options)
-
         context = {
             'probe': probe,
-            'parent_title': six.text_type(probe),
+            'parent_title': str(probe),
             'parent_url': self.get_action_url('view', probe),
-            'time_range': time_range,
             'current_time_range': selected,
         }
         return self.render_to_response('graph', context)
 
     def graph_readings(self):
+        app = self.get_rattail_app()
         probe = self.get_instance()
 
         key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid)
@@ -307,7 +289,7 @@ class TempmonProbeView(MasterView):
             raise NotImplementedError("Unknown time range: {}".format(selected))
 
         # figure out which readings we need to graph
-        cutoff = make_utc() - datetime.timedelta(seconds=cutoff)
+        cutoff = app.make_utc() - datetime.timedelta(seconds=cutoff)
         readings = self.Session.query(tempmon.Reading)\
                                .filter(tempmon.Reading.probe == probe)\
                                .filter(tempmon.Reading.taken >= cutoff)\
@@ -316,7 +298,7 @@ class TempmonProbeView(MasterView):
 
         # convert readings to data for scatter plot
         data = [{
-            'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(),
+            'x': app.localtime(reading.taken, from_utc=True).isoformat(),
             'y': float(reading.degrees_f),
         } for reading in readings]
         return data
@@ -344,5 +326,12 @@ class TempmonProbeView(MasterView):
         cls._defaults(config)
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    TempmonProbeView = kwargs.get('TempmonProbeView', base['TempmonProbeView'])
     TempmonProbeView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py
index a9c6d2c2..02e3fc51 100644
--- a/tailbone/views/tempmon/readings.py
+++ b/tailbone/views/tempmon/readings.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,6 @@
 Views for tempmon readings
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 from sqlalchemy import orm
 
 from rattail_tempmon.db import model as tempmon
@@ -70,17 +67,21 @@ class TempmonReadingView(MasterView):
                       .options(orm.joinedload(tempmon.Reading.client))
 
     def configure_grid(self, g):
-        super(TempmonReadingView, self).configure_grid(g)
+        super().configure_grid(g)
 
-        g.sorters['client_key'] = g.make_sorter(tempmon.Client.config_key)
-        g.filters['client_key'] = g.make_filter('client_key', tempmon.Client.config_key)
+        # client_key
+        g.set_sorter('client_key', tempmon.Client.config_key)
+        g.set_filter('client_key', tempmon.Client.config_key)
 
-        g.sorters['client_host'] = g.make_sorter(tempmon.Client.hostname)
-        g.filters['client_host'] = g.make_filter('client_host', tempmon.Client.hostname)
+        # client_host
+        g.set_sorter('client_host', tempmon.Client.hostname)
+        g.set_filter('client_host', tempmon.Client.hostname)
 
-        g.joiners['probe'] = lambda q: q.join(tempmon.Probe, tempmon.Probe.uuid == tempmon.Reading.probe_uuid)
-        g.sorters['probe'] = g.make_sorter(tempmon.Probe.description)
-        g.filters['probe'] = g.make_filter('probe', tempmon.Probe.description)
+        # probe
+        g.set_joiner('probe', lambda q: q.join(tempmon.Probe,
+                                               tempmon.Probe.uuid == tempmon.Reading.probe_uuid))
+        g.set_sorter('probe', tempmon.Probe.description)
+        g.set_filter('probe', tempmon.Probe.description)
 
         g.set_sort_defaults('taken', 'desc')
         g.set_type('taken', 'datetime')
@@ -98,7 +99,7 @@ class TempmonReadingView(MasterView):
         return reading.client.hostname
 
     def configure_form(self, f):
-        super(TempmonReadingView, self).configure_form(f)
+        super().configure_form(f)
 
         # client
         f.set_renderer('client', self.render_client)
@@ -112,7 +113,7 @@ class TempmonReadingView(MasterView):
         client = reading.client
         if not client:
             return ""
-        text = six.text_type(client)
+        text = str(client)
         url = self.request.route_url('tempmon.clients.view', uuid=client.uuid)
         return tags.link_to(text, url)
 
@@ -120,10 +121,17 @@ class TempmonReadingView(MasterView):
         probe = reading.probe
         if not probe:
             return ""
-        text = six.text_type(probe)
+        text = str(probe)
         url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid)
         return tags.link_to(text, url)
 
 
-def includeme(config):
+def defaults(config, **kwargs):
+    base = globals()
+
+    TempmonReadingView = kwargs.get('TempmonReadingView', base['TempmonReadingView'])
     TempmonReadingView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py
new file mode 100644
index 00000000..46f51c83
--- /dev/null
+++ b/tailbone/views/tenders.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Views for tenders
+"""
+
+from rattail.db.model import Tender
+
+from tailbone.views import MasterView
+
+
+class TenderView(MasterView):
+    """
+    Master view for the Tender class.
+    """
+    model_class = Tender
+    has_versions = True
+
+    grid_columns = [
+        'code',
+        'name',
+        'is_cash',
+        'is_foodstamp',
+        'allow_cash_back',
+        'kick_drawer',
+    ]
+
+    form_fields = [
+        'code',
+        'name',
+        'is_cash',
+        'is_foodstamp',
+        'allow_cash_back',
+        'kick_drawer',
+        'notes',
+        'disabled',
+    ]
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        g.set_link('code')
+
+        g.set_link('name')
+        g.set_sort_defaults('name')
+
+    def grid_extra_class(self, tender, i):
+        if tender.disabled:
+            return 'warning'
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        f.set_type('notes', 'text')
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    TenderView = kwargs.get('TenderView', base['TenderView'])
+    TenderView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/trainwreck.py b/tailbone/views/trainwreck.py
deleted file mode 100644
index a21fd8ef..00000000
--- a/tailbone/views/trainwreck.py
+++ /dev/null
@@ -1,214 +0,0 @@
-# -*- coding: utf-8; -*-
-################################################################################
-#
-#  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
-#
-#  This file is part of Rattail.
-#
-#  Rattail is free software: you can redistribute it and/or modify it under the
-#  terms of the GNU General Public License as published by the Free Software
-#  Foundation, either version 3 of the License, or (at your option) any later
-#  version.
-#
-#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
-#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-#  details.
-#
-#  You should have received a copy of the GNU General Public License along with
-#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
-#
-################################################################################
-"""
-Trainwreck views
-"""
-
-from __future__ import unicode_literals, absolute_import
-
-import six
-
-from rattail.time import localtime
-from rattail.util import OrderedDict
-
-from tailbone.db import TrainwreckSession, ExtraTrainwreckSessions
-from tailbone.views import MasterView
-
-
-class TransactionView(MasterView):
-    """
-    Master view for Trainwreck transactions
-    """
-    # model_class = trainwreck.Transaction
-    model_title = "Trainwreck Transaction"
-    model_title_plural = "Trainwreck Transactions"
-    route_prefix = 'trainwreck.transactions'
-    url_prefix = '/trainwreck/transactions'
-    creatable = False
-    editable = False
-    deletable = False
-
-    supports_multiple_engines = True
-    engine_type_key = 'trainwreck'
-    SessionDefault = TrainwreckSession
-    SessionExtras = ExtraTrainwreckSessions
-
-    labels = {
-        'cashback': "Cash Back",
-    }
-
-    grid_columns = [
-        'start_time',
-        'end_time',
-        'upload_time',
-        'system',
-        'terminal_id',
-        'receipt_number',
-        'cashier_name',
-        'customer_id',
-        'customer_name',
-        'total',
-    ]
-
-    form_fields = [
-        'system',
-        'system_id',
-        'terminal_id',
-        'receipt_number',
-        'start_time',
-        'end_time',
-        'upload_time',
-        'cashier_id',
-        'cashier_name',
-        'customer_id',
-        'customer_name',
-        'shopper_id',
-        'shopper_name',
-        'subtotal',
-        'discounted_subtotal',
-        'tax',
-        'cashback',
-        'total',
-        'void',
-    ]
-
-    has_rows = True
-    # model_row_class = trainwreck.TransactionItem
-    rows_default_pagesize = 100
-
-    row_labels = {
-        'item_id': "Item ID",
-        'department_number': "Dept. No.",
-    }
-
-    row_grid_columns = [
-        'sequence',
-        'item_type',
-        'item_scancode',
-        'department_number',
-        'description',
-        'unit_quantity',
-        'subtotal',
-        'tax',
-        'total',
-        'void',
-    ]
-
-    row_form_fields = [
-        'sequence',
-        'item_type',
-        'item_scancode',
-        'item_id',
-        'department_number',
-        'department_name',
-        'description',
-        'unit_quantity',
-        'subtotal',
-        'tax',
-        'total',
-        'exempt_from_gross_sales',
-        'void',
-    ]
-
-    def get_db_engines(self):
-        engines = OrderedDict(self.rattail_config.trainwreck_engines)
-        hidden = self.rattail_config.getlist('tailbone', 'engines.trainwreck.hidden',
-                                             default=None)
-        if hidden:
-            for key in hidden:
-                engines.pop(key, None)
-        return engines
-
-    def configure_grid(self, g):
-        super(TransactionView, self).configure_grid(g)
-        g.filters['receipt_number'].default_active = True
-        g.filters['receipt_number'].default_verb = 'equal'
-        g.filters['upload_time'].default_active = True
-        g.filters['upload_time'].default_verb = 'equal'
-        g.filters['upload_time'].default_value = six.text_type(localtime(self.rattail_config).date())
-        g.set_sort_defaults('upload_time', 'desc')
-
-        g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
-        g.set_type('total', 'currency')
-        g.set_label('terminal_id', "Terminal")
-        g.set_label('receipt_number', "Receipt No.")
-        g.set_label('customer_id', "Customer ID")
-
-        g.set_link('start_time')
-        g.set_link('end_time')
-        g.set_link('upload_time')
-        g.set_link('receipt_number')
-        g.set_link('customer_id')
-        g.set_link('customer_name')
-        g.set_link('total')
-
-    def configure_form(self, f):
-        super(TransactionView, self).configure_form(f)
-
-        # system
-        f.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
-
-        # currency fields
-        f.set_type('subtotal', 'currency')
-        f.set_type('discounted_subtotal', 'currency')
-        f.set_type('tax', 'currency')
-        f.set_type('cashback', 'currency')
-        f.set_type('total', 'currency')
-
-        # label overrides
-        f.set_label('system_id', "System ID")
-        f.set_label('terminal_id', "Terminal")
-        f.set_label('cashier_id', "Cashier ID")
-        f.set_label('customer_id', "Customer ID")
-        f.set_label('shopper_id', "Shopper ID")
-
-    def get_row_data(self, transaction):
-        return self.Session.query(self.model_row_class)\
-                           .filter(self.model_row_class.transaction == transaction)\
-                           .order_by(self.model_row_class.sequence)
-
-    def get_parent(self, item):
-        return item.transaction
-
-    def configure_row_grid(self, g):
-        super(TransactionView, self).configure_row_grid(g)
-        g.set_sort_defaults('sequence')
-
-        g.set_type('unit_quantity', 'quantity')
-        g.set_type('subtotal', 'currency')
-        g.set_type('discounted_subtotal', 'currency')
-        g.set_type('tax', 'currency')
-        g.set_type('total', 'currency')
-
-    def configure_row_form(self, f):
-        super(TransactionView, self).configure_row_form(f)
-
-        # quantity fields
-        f.set_type('unit_quantity', 'quantity')
-
-        # currency fields
-        f.set_type('unit_price', 'currency')
-        f.set_type('subtotal', 'currency')
-        f.set_type('discounted_subtotal', 'currency')
-        f.set_type('tax', 'currency')
-        f.set_type('total', 'currency')
diff --git a/tailbone/views/trainwreck/__init__.py b/tailbone/views/trainwreck/__init__.py
new file mode 100644
index 00000000..b5eea351
--- /dev/null
+++ b/tailbone/views/trainwreck/__init__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Trainwreck Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from .base import TransactionView
+
+
+def includeme(config):
+    config.include('tailbone.views.trainwreck.defaults')
diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py
new file mode 100644
index 00000000..d5f077aa
--- /dev/null
+++ b/tailbone/views/trainwreck/base.py
@@ -0,0 +1,515 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Trainwreck views
+"""
+
+from webhelpers2.html import HTML, tags
+
+from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions
+from tailbone.views import MasterView
+
+
+class TransactionView(MasterView):
+    """
+    Master view for Trainwreck transactions
+    """
+    # model_class = trainwreck.Transaction
+    model_title = "Trainwreck Transaction"
+    model_title_plural = "Trainwreck Transactions"
+    route_prefix = 'trainwreck.transactions'
+    url_prefix = '/trainwreck/transactions'
+    creatable = False
+    editable = False
+    deletable = False
+    results_downloadable = True
+
+    supports_multiple_engines = True
+    engine_type_key = 'trainwreck'
+    SessionDefault = TrainwreckSession
+    SessionExtras = ExtraTrainwreckSessions
+
+    configurable = True
+
+    labels = {
+        'store_id': "Store",
+        'cashback': "Cash Back",
+    }
+
+    grid_columns = [
+        'start_time',
+        'end_time',
+        'system',
+        'store_id',
+        'terminal_id',
+        'receipt_number',
+        'cashier_name',
+        'customer_id',
+        'customer_name',
+        'total',
+    ]
+
+    form_fields = [
+        'system',
+        'system_id',
+        'store_id',
+        'terminal_id',
+        'receipt_number',
+        'effective_date',
+        'start_time',
+        'end_time',
+        'upload_time',
+        'cashier_id',
+        'cashier_name',
+        'customer_id',
+        'customer_name',
+        'shopper_id',
+        'shopper_name',
+        'shopper_level_number',
+        'custorder_xref_markers',
+        'subtotal',
+        'discounted_subtotal',
+        'tax',
+        'cashback',
+        'total',
+        'patronage',
+        'equity_current',
+        'self_updated',
+        'void',
+    ]
+
+    has_rows = True
+    # model_row_class = trainwreck.TransactionItem
+    rows_default_pagesize = 100
+
+    row_labels = {
+        'item_id': "Item ID",
+        'department_number': "Dept. No.",
+        'subdepartment_number': "Subdept. No.",
+    }
+
+    row_grid_columns = [
+        'sequence',
+        'item_type',
+        'item_scancode',
+        'department_number',
+        'subdepartment_number',
+        'description',
+        'unit_quantity',
+        'subtotal',
+        'tax',
+        'total',
+        'void',
+    ]
+
+    row_form_fields = [
+        'transaction',
+        'sequence',
+        'item_type',
+        'item_scancode',
+        'item_id',
+        'department_number',
+        'department_name',
+        'subdepartment_number',
+        'subdepartment_name',
+        'description',
+        'custorder_item_xref',
+        'unit_quantity',
+        'subtotal',
+        'discounts',
+        'discounted_subtotal',
+        'tax',
+        'total',
+        'exempt_from_gross_sales',
+        'net_sales',
+        'gross_sales',
+        'void',
+    ]
+
+    def get_db_engines(self):
+        app = self.get_rattail_app()
+        trainwreck_handler = app.get_trainwreck_handler()
+        return trainwreck_handler.get_trainwreck_engines(include_hidden=False)
+
+    def make_isolated_session(self):
+        from rattail.trainwreck.db import Session as TrainwreckSession
+
+        dbkey = self.get_current_engine_dbkey()
+        if dbkey != 'default':
+            app = self.get_rattail_app()
+            trainwreck_handler = app.get_trainwreck_handler()
+            trainwreck_engines = trainwreck_handler.get_trainwreck_engines()
+            if dbkey in trainwreck_engines:
+                return TrainwreckSesssion(bind=trainwreck_engines[dbkey])
+
+        return TrainwreckSession()
+
+    def get_context_menu_items(self, txn=None):
+        items = super().get_context_menu_items(txn)
+        route_prefix = self.get_route_prefix()
+
+        if self.listing:
+
+            if self.has_perm('rollover'):
+                url = self.request.route_url(f'{route_prefix}.rollover')
+                items.append(tags.link_to("Yearly Rollover", url))
+
+        return items
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+        app = self.get_rattail_app()
+
+        g.filters['receipt_number'].default_active = True
+        g.filters['receipt_number'].default_verb = 'equal'
+
+        # end_time
+        g.set_sort_defaults('end_time', 'desc')
+        g.filters['end_time'].default_active = True
+        g.filters['end_time'].default_verb = 'equal'
+        # TODO: should expose this setting somewhere
+        if self.rattail_config.getbool('trainwreck', 'show_yesterday_first'):
+            date = app.yesterday()
+        else:
+            date = app.today()
+        g.filters['end_time'].default_value = str(date)
+
+        g.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
+        g.set_type('total', 'currency')
+        g.set_type('patronage', 'currency')
+        g.set_label('terminal_id', "Terminal")
+        g.set_label('receipt_number', "Receipt No.")
+        g.set_label('customer_id', "Customer ID")
+
+        g.set_link('start_time')
+        g.set_link('end_time')
+        g.set_link('upload_time')
+        g.set_link('receipt_number')
+        g.set_link('customer_id')
+        g.set_link('customer_name')
+        g.set_link('total')
+
+    def grid_extra_class(self, transaction, i):
+        if transaction.void:
+            return 'warning'
+
+    def configure_form(self, f):
+        super().configure_form(f)
+
+        # system
+        f.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
+
+        # currency fields
+        f.set_type('subtotal', 'currency')
+        f.set_type('discounted_subtotal', 'currency')
+        f.set_type('tax', 'currency')
+        f.set_type('cashback', 'currency')
+        f.set_type('total', 'currency')
+        f.set_type('patronage', 'currency')
+
+        # custorder_xref_markers
+        f.set_renderer('custorder_xref_markers', self.render_custorder_xref_markers)
+
+        # label overrides
+        f.set_label('system_id', "System ID")
+        f.set_label('terminal_id', "Terminal")
+        f.set_label('cashier_id', "Cashier ID")
+        f.set_label('customer_id', "Customer ID")
+        f.set_label('shopper_id', "Shopper ID")
+
+    def render_custorder_xref_markers(self, txn, field):
+        markers = getattr(txn, field)
+        if not markers:
+            return
+
+        route_prefix = self.get_route_prefix()
+        factory = self.get_grid_factory()
+
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.custorder_xref_markers',
+            data=[],
+            columns=['custorder_xref', 'custorder_item_xref'])
+
+        return HTML.literal(
+            g.render_table_element(data_prop='custorderXrefMarkersData'))
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        config = self.rattail_config
+
+        form = kwargs['form']
+        if 'custorder_xref_markers' in form:
+            txn = kwargs['instance']
+            markers = []
+            for marker in txn.custorder_xref_markers:
+                markers.append({
+                    'custorder_xref': marker.custorder_xref,
+                    'custorder_item_xref': marker.custorder_item_xref,
+                })
+            kwargs['custorder_xref_markers_data'] = markers
+
+        # collapse header
+        kwargs['main_form_title'] = "Transaction Header"
+        kwargs['main_form_collapsible'] = True
+        kwargs['main_form_autocollapse'] = config.get_bool(
+            'tailbone.trainwreck.view_txn.autocollapse_header',
+            default=False)
+
+        return kwargs
+
+    def get_xref_buttons(self, txn):
+        app = self.get_rattail_app()
+        clientele = app.get_clientele_handler()
+        buttons = super().get_xref_buttons(txn)
+
+        if txn.customer_id:
+            customer = clientele.locate_customer_for_key(Session(), txn.customer_id)
+            if customer:
+                person = app.get_person(customer)
+                if person:
+                    url = self.request.route_url('people.view_profile', uuid=person.uuid)
+                    buttons.append(self.make_xref_button(text=str(person),
+                                                         url=url,
+                                                         internal=True))
+
+        return buttons
+
+    def get_row_data(self, transaction):
+        return self.Session.query(self.model_row_class)\
+                           .filter(self.model_row_class.transaction == transaction)
+
+    def get_parent(self, item):
+        return item.transaction
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+        g.set_sort_defaults('sequence')
+
+        g.set_type('unit_quantity', 'quantity')
+        g.set_type('subtotal', 'currency')
+        g.set_type('discounted_subtotal', 'currency')
+        g.set_type('tax', 'currency')
+        g.set_type('total', 'currency')
+
+        g.set_link('item_scancode')
+        g.set_link('description')
+
+    def row_grid_extra_class(self, row, i):
+        if row.void:
+            return 'warning'
+
+    def get_row_instance_title(self, instance):
+        return "Trainwreck Line Item"
+
+    def configure_row_form(self, f):
+        super().configure_row_form(f)
+
+        # transaction
+        f.set_renderer('transaction', self.render_transaction)
+
+        # quantity fields
+        f.set_type('unit_quantity', 'quantity')
+
+        # currency fields
+        f.set_type('unit_price', 'currency')
+        f.set_type('subtotal', 'currency')
+        f.set_type('discounted_subtotal', 'currency')
+        f.set_type('tax', 'currency')
+        f.set_type('total', 'currency')
+
+        # discounts
+        f.set_renderer('discounts', self.render_discounts)
+
+    def render_transaction(self, item, field):
+        txn = getattr(item, field)
+        text = str(txn)
+        url = self.get_action_url('view', txn)
+        return tags.link_to(text, url)
+
+    def render_discounts(self, item, field):
+        if not item.discounts:
+            return
+
+        route_prefix = self.get_route_prefix()
+        factory = self.get_grid_factory()
+
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.discounts',
+            data=[],
+            columns=['discount_type', 'description', 'amount'],
+            labels={'discount_type': "Type"})
+
+        return HTML.literal(
+            g.render_table_element(data_prop='discountsData'))
+
+    def template_kwargs_view_row(self, **kwargs):
+        form = kwargs['form']
+        if 'discounts' in form:
+
+            app = self.get_rattail_app()
+            item = kwargs['instance']
+            discounts_data = []
+            for discount in item.discounts:
+                discounts_data.append({
+                    'discount_type': discount.discount_type,
+                    'description': discount.description,
+                    'amount': app.render_currency(discount.amount),
+                })
+            kwargs['discounts_data'] = discounts_data
+
+        return kwargs
+
+    def rollover(self):
+        """
+        View for performing yearly rollover functions.
+        """
+        app = self.get_rattail_app()
+        trainwreck_handler = app.get_trainwreck_handler()
+        trainwreck_engines = trainwreck_handler.get_trainwreck_engines()
+        current_year = app.localtime().year
+
+        # find oldest and newest dates for each database
+        engines_data = []
+        for key, engine in trainwreck_engines.items():
+
+            if key == 'default':
+                session = self.Session()
+            else:
+                session = ExtraTrainwreckSessions[key]()
+
+            error = False
+            oldest = None
+            newest = None
+            try:
+                oldest = trainwreck_handler.get_oldest_transaction_date(session)
+                newest = trainwreck_handler.get_newest_transaction_date(session)
+            except:
+                error = True
+
+            engines_data.append({
+                'key': key,
+                'oldest_date': app.render_date(oldest) if oldest else None,
+                'newest_date': app.render_date(newest) if newest else None,
+                'error': error,
+            })
+
+        return self.render_to_response('rollover', {
+            'instance_title': "Yearly Rollover",
+            'trainwreck_handler': trainwreck_handler,
+            'current_year': current_year,
+            'next_year': current_year + 1,
+            'trainwreck_engines': trainwreck_engines,
+            'engines_data': engines_data,
+        })
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # display
+            {'section': 'tailbone',
+             'option': 'trainwreck.view_txn.autocollapse_header',
+             'type': bool},
+
+            # rotation
+            {'section': 'trainwreck',
+             'option': 'use_rotation',
+             'type': bool},
+            {'section': 'trainwreck',
+             'option': 'current_years',
+             'type': int},
+
+        ]
+
+    def configure_get_context(self):
+        context = super().configure_get_context()
+
+        app = self.get_rattail_app()
+        trainwreck_handler = app.get_trainwreck_handler()
+        trainwreck_engines = trainwreck_handler.get_trainwreck_engines()
+
+        context['trainwreck_engines'] = trainwreck_engines
+        context['hidden_databases'] = dict([
+            (key, trainwreck_handler.engine_is_hidden(key))
+            for key in trainwreck_engines])
+
+        return context
+
+    def configure_gather_settings(self, data):
+        settings = super().configure_gather_settings(data)
+
+        app = self.get_rattail_app()
+        trainwreck_handler = app.get_trainwreck_handler()
+        trainwreck_engines = trainwreck_handler.get_trainwreck_engines()
+
+        hidden = []
+        for key in trainwreck_engines:
+            name = 'hidedb_{}'.format(key)
+            if data.get(name) == 'true':
+                hidden.append(key)
+        settings.append({'name': 'trainwreck.db.hide',
+                         'value': ', '.join(hidden)})
+
+        return settings
+
+    def configure_remove_settings(self):
+        super().configure_remove_settings()
+        app = self.get_rattail_app()
+
+        names = [
+            'trainwreck.db.hide',
+            'tailbone.engines.trainwreck.hidden', # deprecated
+        ]
+
+        # nb. using thread-local session here; we do not use
+        # self.Session b/c it may not point to Rattail
+        session = Session()
+        for name in names:
+            app.delete_setting(session, name)
+
+    @classmethod
+    def defaults(cls, config):
+        cls._trainwreck_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _trainwreck_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        model_title_plural = cls.get_model_title_plural()
+
+        # fix perm group title
+        config.add_tailbone_permission_group(permission_prefix,
+                                             model_title_plural)
+
+        # rollover
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.rollover'.format(permission_prefix),
+                                       label="Perform yearly rollover for Trainwreck")
+        config.add_route('{}.rollover'.format(route_prefix),
+                         '{}/rollover'.format(url_prefix))
+        config.add_view(cls, attr='rollover',
+                        route_name='{}.rollover'.format(route_prefix),
+                        permission='{}.rollover'.format(permission_prefix))
diff --git a/tailbone/grids/mobile.py b/tailbone/views/trainwreck/defaults.py
similarity index 56%
rename from tailbone/grids/mobile.py
rename to tailbone/views/trainwreck/defaults.py
index dc6a04b9..85c61fae 100644
--- a/tailbone/grids/mobile.py
+++ b/tailbone/views/trainwreck/defaults.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -21,33 +21,30 @@
 #
 ################################################################################
 """
-Mobile Grids
+Trainwreck "default" views (i.e. assuming "default" schema)
 """
 
 from __future__ import unicode_literals, absolute_import
 
-from pyramid.renderers import render
+from rattail.trainwreck.db.model import defaults as trainwreck
 
-from .core import Grid
+from tailbone.views.trainwreck import base
 
 
-class MobileGrid(Grid):
+class TransactionView(base.TransactionView):
     """
-    Base class for all mobile grids
+    Master view for Trainwreck transactions
     """
+    model_class = trainwreck.Transaction
+    model_row_class = trainwreck.TransactionItem
 
-    def render_filters(self, template='/mobile/grids/filters_simple.mako', **kwargs):
-        context = kwargs
-        context['request'] = self.request
-        context['grid'] = self
-        return render(template, context)
 
-    def render_grid(self, template='/mobile/grids/grid.mako', **kwargs):
-        context = kwargs
-        context['grid'] = self
-        return render(template, context)
+def defaults(config, **kwargs):
+    base = globals()
 
-    def render_complete(self, template='/mobile/grids/complete.mako', **kwargs):
-        context = kwargs
-        context['grid'] = self
-        return render(template, context)
+    TransactionView = kwargs.get('TransactionView', base['TransactionView'])
+    TransactionView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/typical.py b/tailbone/views/typical.py
new file mode 100644
index 00000000..35259a14
--- /dev/null
+++ b/tailbone/views/typical.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Typical views for convenient includes
+"""
+
+
+def defaults(config, **kwargs):
+    mod = lambda spec: kwargs.get(spec, spec)
+
+    # main tables
+    config.include(mod('tailbone.views.brands'))
+    config.include(mod('tailbone.views.categories'))
+    config.include(mod('tailbone.views.customergroups'))
+    config.include(mod('tailbone.views.customers'))
+    config.include(mod('tailbone.views.custorders'))
+    config.include(mod('tailbone.views.departments'))
+    config.include(mod('tailbone.views.employees'))
+    config.include(mod('tailbone.views.families'))
+    config.include(mod('tailbone.views.members'))
+    config.include(mod('tailbone.views.products'))
+    config.include(mod('tailbone.views.purchases'))
+    config.include(mod('tailbone.views.reportcodes'))
+    config.include(mod('tailbone.views.stores'))
+    config.include(mod('tailbone.views.subdepartments'))
+    config.include(mod('tailbone.views.taxes'))
+    config.include(mod('tailbone.views.tenders'))
+    config.include(mod('tailbone.views.uoms'))
+    config.include(mod('tailbone.views.vendors'))
+
+    # batches
+    config.include(mod('tailbone.views.batch.handheld'))
+    config.include(mod('tailbone.views.batch.importer'))
+    config.include(mod('tailbone.views.batch.inventory'))
+    config.include(mod('tailbone.views.batch.pos'))
+    config.include(mod('tailbone.views.batch.vendorcatalog'))
+    config.include(mod('tailbone.views.purchasing'))
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/uoms.py b/tailbone/views/uoms.py
new file mode 100644
index 00000000..0b7b060f
--- /dev/null
+++ b/tailbone/views/uoms.py
@@ -0,0 +1,137 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2022 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+UOM Views
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from rattail.db import model
+
+from tailbone.views import MasterView
+
+
+class UnitOfMeasureView(MasterView):
+    """
+    Master view for the UOM mappings.
+    """
+    model_class = model.UnitOfMeasure
+    route_prefix = 'uoms'
+    url_prefix = '/units-of-measure'
+    bulk_deletable = True
+    has_versions = True
+
+    labels = {
+        'sil_code': "SIL Code",
+    }
+
+    grid_columns = [
+        'abbreviation',
+        'sil_code',
+        'description',
+        'notes',
+    ]
+
+    form_fields = [
+        'abbreviation',
+        'sil_code',
+        'description',
+        'notes',
+    ]
+
+    def configure_grid(self, g):
+        super(UnitOfMeasureView, self).configure_grid(g)
+
+        g.set_renderer('description', self.render_description)
+
+        g.set_sort_defaults('abbreviation')
+
+        g.set_link('abbreviation')
+        g.set_link('description')
+
+    def configure_form(self, f):
+        super(UnitOfMeasureView, self).configure_form(f)
+
+        f.set_renderer('description', self.render_description)
+        f.set_type('notes', 'text')
+
+        if not self.creating:
+            f.set_readonly('abbreviation')
+
+        if self.creating or self.editing:
+            f.remove('description')
+            f.set_enum('sil_code', self.enum.UNIT_OF_MEASURE)
+
+    def redirect_after_create(self, uom, **kwargs):
+        return self.redirect(self.get_index_url())
+
+    def redirect_after_edit(self, uom, **kwargs):
+        return self.redirect(self.get_index_url())
+
+    def render_description(self, uom, field):
+        code = uom.sil_code
+        if code:
+            if code in self.enum.UNIT_OF_MEASURE:
+                return self.enum.UNIT_OF_MEASURE[code]
+            return "(unknown code)"
+
+    def collect_wild_uoms(self):
+        app = self.get_rattail_app()
+        handler = app.get_products_handler()
+        uoms = handler.collect_wild_uoms()
+        self.request.session.flash("All abbreviations from the wild have been added.  Now please map each to a SIL code.")
+        return self.redirect(self.get_index_url())
+
+    @classmethod
+    def defaults(cls, config):
+        cls._uom_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _uom_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        model_title_plural = cls.get_model_title_plural()
+
+        # fix perm group name
+        config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
+
+        # collect wild uoms
+        config.add_tailbone_permission(permission_prefix, '{}.collect_wild_uoms'.format(permission_prefix),
+                                       "Collect UoM abbreviations from the wild")
+        config.add_route('{}.collect_wild_uoms'.format(route_prefix), '{}/collect-wild-uoms'.format(url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='collect_wild_uoms', route_name='{}.collect_wild_uoms'.format(route_prefix),
+                        permission='{}.collect_wild_uoms'.format(permission_prefix))
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    UnitOfMeasureView = kwargs.get('UnitOfMeasureView', base['UnitOfMeasureView'])
+    UnitOfMeasureView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py
index 54605efb..ffa88032 100644
--- a/tailbone/views/upgrades.py
+++ b/tailbone/views/upgrades.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,26 +24,24 @@
 Views for app upgrades
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import json
 import os
 import re
 import logging
+import warnings
+from collections import OrderedDict
 
-import six
-from sqlalchemy import orm
+import sqlalchemy as sa
 
-from rattail.core import Object
-from rattail.db import model, Session as RattailSession
-from rattail.time import make_utc
+from rattail.db.model import Upgrade
 from rattail.threads import Thread
-from rattail.upgrades import get_upgrade_handler
 
 from deform import widget as dfwidget
 from webhelpers2.html import tags, HTML
 
 from tailbone.views import MasterView
 from tailbone.progress import get_progress_session #, SessionProgress
+from tailbone.config import should_expose_websockets
 
 
 log = logging.getLogger(__name__)
@@ -53,12 +51,14 @@ class UpgradeView(MasterView):
     """
     Master view for all user events
     """
-    model_class = model.Upgrade
+    model_class = Upgrade
     downloadable = True
     cloneable = True
+    configurable = True
     executable = True
     execute_progress_template = '/upgrade.mako'
     execute_progress_initial_msg = "Upgrading"
+    execute_can_cancel = False
 
     labels = {
         'executed_by': "Executed by",
@@ -68,6 +68,7 @@ class UpgradeView(MasterView):
     }
 
     grid_columns = [
+        'system',
         'created',
         'description',
         # 'not_until',
@@ -78,6 +79,7 @@ class UpgradeView(MasterView):
     ]
 
     form_fields = [
+        'system',
         'description',
         # 'not_until',
         # 'requirements',
@@ -96,29 +98,42 @@ class UpgradeView(MasterView):
     ]
 
     def __init__(self, request):
-        super(UpgradeView, self).__init__(request)
-        self.handler = self.get_handler()
+        super().__init__(request)
 
-    def get_handler(self):
-        """
-        Returns the ``UpgradeHandler`` instance for the view.  The handler
-        factory for this may be defined by config, e.g.:
+        if hasattr(self, 'get_handler'):
+            warnings.warn("defining get_handler() is deprecated.  please "
+                          "override AppHandler.get_upgrade_handler() instead",
+                          DeprecationWarning, stacklevel=2)
+            self.upgrade_handler = self.get_handler()
 
-        .. code-block:: ini
+        else:
+            app = self.get_rattail_app()
+            self.upgrade_handler = app.get_upgrade_handler()
 
-           [rattail.upgrades]
-           handler = myapp.upgrades:CustomUpgradeHandler
-        """
-        return get_upgrade_handler(self.rattail_config)
+    @property
+    def handler(self):
+        warnings.warn("handler attribute is deprecated; "
+                      "please use upgrade_handler instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.upgrade_handler
 
     def configure_grid(self, g):
-        super(UpgradeView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
+
+        # system
+        systems = self.upgrade_handler.get_all_systems()
+        systems_enum = dict([(s['key'], s['label']) for s in systems])
+        g.set_enum('system', systems_enum)
+
         g.set_joiner('executed_by', lambda q: q.join(model.User, model.User.uuid == model.Upgrade.executed_by_uuid).outerjoin(model.Person))
         g.set_sorter('executed_by', model.Person.display_name)
         g.set_enum('status_code', self.enum.UPGRADE_STATUS)
         g.set_type('created', 'datetime')
         g.set_type('executed', 'datetime')
         g.set_sort_defaults('created', 'desc')
+
+        g.set_link('system')
         g.set_link('created')
         g.set_link('description')
         # g.set_link('not_until')
@@ -131,8 +146,17 @@ class UpgradeView(MasterView):
             return 'notice'
 
     def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        app = self.get_rattail_app()
+        model = self.model
         upgrade = kwargs['instance']
 
+        kwargs['system_title'] = app.get_title()
+        if upgrade.system:
+            system = self.upgrade_handler.get_system(upgrade.system)
+            if system:
+                kwargs['system_title'] = system['label']
+
         kwargs['show_prev_next'] = True
         kwargs['prev_url'] = None
         kwargs['next_url'] = None
@@ -154,32 +178,49 @@ class UpgradeView(MasterView):
         return kwargs
 
     def configure_form(self, f):
-        super(UpgradeView, self).configure_form(f)
+        super().configure_form(f)
+        upgrade = f.model_instance
+
+        # system
+        systems = self.upgrade_handler.get_all_systems()
+        systems_enum = OrderedDict([(s['key'], s['label'])
+                                    for s in systems])
+        f.set_enum('system', systems_enum)
+        f.set_required('system')
+        if self.creating:
+            if len(systems) == 1:
+                f.set_default('system', list(systems_enum)[0])
 
         # status_code
         if self.creating:
             f.remove_field('status_code')
         else:
             f.set_enum('status_code', self.enum.UPGRADE_STATUS)
-            # f.set_readonly('status_code')
+            f.set_renderer('status_code', self.render_status_code)
 
         # executing
         if not self.editing:
             f.remove('executing')
 
         f.set_type('created', 'datetime')
-        f.set_type('enabled', 'boolean')
         f.set_type('executed', 'datetime')
         # f.set_widget('not_until', dfwidget.DateInputWidget())
         f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8))
         f.set_renderer('stdout_file', self.render_stdout_file)
         f.set_renderer('stderr_file', self.render_stdout_file)
-        f.set_renderer('package_diff', self.render_package_diff)
+
+        # package_diff
+        if self.viewing and upgrade.executed and (
+                upgrade.system == 'rattail'
+                or not upgrade.system):
+            f.set_renderer('package_diff', self.render_package_diff)
+        else:
+            f.remove_field('package_diff')
+
         # f.set_readonly('created')
         # f.set_readonly('created_by')
         f.set_readonly('executed')
         f.set_readonly('executed_by')
-        upgrade = f.model_instance
         if self.creating or self.editing:
             f.remove_field('created')
             f.remove_field('created_by')
@@ -188,28 +229,57 @@ class UpgradeView(MasterView):
             if self.creating or not upgrade.executed:
                 f.remove_field('executed')
                 f.remove_field('executed_by')
-            if self.editing and upgrade.executed:
-                f.remove_field('enabled')
 
-        elif f.model_instance.executed:
-            f.remove_field('enabled')
-
-        else:
+        elif not upgrade.executed:
             f.remove_field('executed')
             f.remove_field('executed_by')
             f.remove_field('stdout_file')
             f.remove_field('stderr_file')
 
+        # enabled
+        if not self.creating and upgrade.executed:
+            f.remove('enabled')
+        else:
+            f.set_type('enabled', 'boolean')
+            f.set_default('enabled', True)
+
         if not self.viewing or not upgrade.executed:
-            f.remove_field('package_diff')
             f.remove_field('exit_code')
 
+    def render_status_code(self, upgrade, field):
+        code = getattr(upgrade, field)
+        text = self.enum.UPGRADE_STATUS[code]
+
+        if code == self.enum.UPGRADE_STATUS_EXECUTING:
+
+            text = HTML.tag('span', c=[text])
+
+            button = HTML.tag('b-button',
+                              type='is-warning',
+                              icon_pack='fas',
+                              icon_left='sad-tear',
+                              c=['{{ declareFailureSubmitting ? "Working, please wait..." : "Declare Failure" }}'],
+                              **{':disabled': 'declareFailureSubmitting',
+                                 '@click': 'declareFailureClick'})
+
+            return HTML.tag('div', class_='level', c=[
+                HTML.tag('div', class_='level-left', c=[
+                    HTML.tag('div', class_='level-item', c=[text]),
+                    HTML.tag('div', class_='level-item', c=[button]),
+                ]),
+            ])
+
+        # just show status per normal
+        return text
+
     def configure_clone_form(self, f):
-        f.fields = ['description', 'notes', 'enabled']
+        f.fields = ['system', 'description', 'notes', 'enabled']
 
     def clone_instance(self, original):
+        app = self.get_rattail_app()
         cloned = self.model_class()
-        cloned.created = make_utc()
+        cloned.system = original.system
+        cloned.created = app.make_utc()
         cloned.created_by = self.request.user
         cloned.description = original.description
         cloned.notes = original.notes
@@ -231,14 +301,12 @@ class UpgradeView(MasterView):
         return filename
 
     def render_package_diff(self, upgrade, fieldname):
-        use_buefy = self.get_use_buefy()
         try:
             before = self.parse_requirements(upgrade, 'before')
             after = self.parse_requirements(upgrade, 'after')
 
             kwargs = {}
-            if use_buefy:
-                kwargs['extra_row_attrs'] = self.get_extra_diff_row_attrs
+            kwargs['extra_row_attrs'] = self.get_extra_diff_row_attrs
             diff = self.make_diff(before, after,
                                   columns=["package", "old version", "new version"],
                                   render_field=self.render_diff_field,
@@ -246,24 +314,16 @@ class UpgradeView(MasterView):
                                   **kwargs)
 
             kwargs = {}
-            if use_buefy:
-                kwargs['@click.prevent'] = "showingPackages = 'all'"
-                kwargs[':style'] = "{'font-weight': showingPackages == 'all' ? 'bold' : null}"
-            else:
-                kwargs['class_'] = 'all'
+            kwargs['@click.prevent'] = "showingPackages = 'all'"
+            kwargs[':style'] = "{'font-weight': showingPackages == 'all' ? 'bold' : null}"
             all_link = tags.link_to("all", '#', **kwargs)
 
             kwargs = {}
-            if use_buefy:
-                kwargs['@click.prevent'] = "showingPackages = 'diffs'"
-                kwargs[':style'] = "{'font-weight': showingPackages == 'diffs' ? 'bold' : null}"
-            else:
-                kwargs['class_'] = 'diffs'
+            kwargs['@click.prevent'] = "showingPackages = 'diffs'"
+            kwargs[':style'] = "{'font-weight': showingPackages == 'diffs' ? 'bold' : null}"
             diffs_link = tags.link_to("diffs only", '#', **kwargs)
 
             kwargs = {}
-            if not use_buefy:
-                kwargs['class_'] = 'showing'
             showing = HTML.tag('div', c=["showing: "
                                          + all_link
                                          + " / "
@@ -277,7 +337,6 @@ class UpgradeView(MasterView):
             return HTML.tag('div', c="(not available for this upgrade)")
 
     def get_extra_diff_row_attrs(self, field, attrs):
-        # note, this is only needed/used with Buefy
         extra = {}
         if attrs.get('class') != 'diff':
             extra['v-show'] = "showingPackages == 'all'"
@@ -288,30 +347,49 @@ class UpgradeView(MasterView):
 
     commit_hash_pattern = re.compile(r'^.{40}$')
 
-    def get_changelog_url(self, project, old_version, new_version):
-        projects = {
+    def get_changelog_projects(self):
+        project_map = {
+            'onager': 'onager',
+            'pyCOREPOS': 'pycorepos',
             'rattail': 'rattail',
+            'rattail_corepos': 'rattail-corepos',
+            'rattail-onager': 'rattail-onager',
+            'rattail_tempmon': 'rattail-tempmon',
+            'rattail_woocommerce': 'rattail-woocommerce',
             'Tailbone': 'tailbone',
-            'pyCatapult': 'pycatapult',
-            'rattail-catapult': 'rattail-catapult',
-            'rattail-tempmon': 'rattail-tempmon',
-            'tailbone-catapult': 'tailbone-catapult',
+            'tailbone_corepos': 'tailbone-corepos',
+            'tailbone-onager': 'tailbone-onager',
+            'tailbone_theo': 'theo',
+            'tailbone_woocommerce': 'tailbone-woocommerce',
         }
-        if project not in projects:
+
+        projects = {}
+        for name, repo in project_map.items():
+            projects[name] = {
+                'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}',
+                'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md',
+            }
+        return projects
+
+    def get_changelog_url(self, project, old_version, new_version):
+        # cannot generate URL if new version is unknown
+        if not new_version:
             return
+
+        projects = self.get_changelog_projects()
+
+        project_name = project
+        if project_name not in projects:
+            # cannot generate a changelog URL for unknown project
+            return
+
+        project = projects[project_name]
+
         if self.commit_hash_pattern.match(new_version):
-            if new_version == old_version:
-                return 'https://rattailproject.org/trac/log/{}/?rev={}&limit=100'.format(
-                    projects[project], new_version)
-            else:
-                return 'https://rattailproject.org/trac/log/{}/?rev={}&stop_rev={}&limit=100'.format(
-                    projects[project], new_version, old_version)
+            return project['commit_url'].format(new_version=new_version, old_version=old_version)
+
         elif re.match(r'^\d+\.\d+\.\d+$', new_version):
-            return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst?rev=v{}'.format(
-                projects[project], new_version)
-        else:
-            return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst'.format(
-                projects[project])
+            return project['release_url'].format(new_version=new_version, old_version=old_version)
 
     def render_diff_field(self, field, diff):
         old_version = diff.old_value(field)
@@ -343,13 +421,14 @@ class UpgradeView(MasterView):
         return packages
 
     def parse_requirement(self, line):
+        app = self.get_rattail_app()
         match = re.match(r'^.*@(.*)#egg=(.*)$', line)
         if match:
-            return Object(name=match.group(2), version=match.group(1))
+            return app.make_object(name=match.group(2), version=match.group(1))
 
         match = re.match(r'^(.*)==(.*)$', line)
         if match:
-            return Object(name=match.group(1), version=match.group(2))
+            return app.make_object(name=match.group(1), version=match.group(2))
 
     def download_path(self, upgrade, filename):
         return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename)
@@ -367,11 +446,29 @@ class UpgradeView(MasterView):
     #     key = '{}.execute'.format(self.get_grid_key())
     #     return SessionProgress(self.request, key, session_type='file')
 
-    def execute_instance(self, upgrade, user, **kwargs):
-        session = orm.object_session(upgrade)
-        self.handler.mark_executing(upgrade)
+    def executable_instance(self, upgrade):
+        if upgrade.executed:
+            return False
+        if upgrade.status_code != self.enum.UPGRADE_STATUS_PENDING:
+            return False
+        return True
+
+    def execute_instance(self, upgrade, user, progress=None, **kwargs):
+        app = self.get_rattail_app()
+        session = app.get_session(upgrade)
+
+        # record the fact that execution has begun for this ugprade
+        self.upgrade_handler.mark_executing(upgrade)
         session.commit()
-        self.handler.do_execute(upgrade, user, **kwargs)
+
+        # let handler execute the upgrade
+        self.upgrade_handler.do_execute(upgrade, user, **kwargs)
+
+        # success msg
+        msg = "Execution has finished, for better or worse."
+        if not upgrade.system or upgrade.system == 'rattail':
+            msg += "  You may need to restart your web app."
+        return msg
 
     def execute_progress(self):
         upgrade = self.get_instance()
@@ -399,24 +496,105 @@ class UpgradeView(MasterView):
 
         return data
 
+    def declare_failure(self):
+        upgrade = self.get_instance()
+        if upgrade.executing and upgrade.status_code == self.enum.UPGRADE_STATUS_EXECUTING:
+            upgrade.executing = False
+            upgrade.status_code = self.enum.UPGRADE_STATUS_FAILED
+            self.request.session.flash("Upgrade was declared a failure.", 'warning')
+        else:
+            self.request.session.flash("Upgrade was not currently executing!  "
+                                       "So it was not declared a failure.",
+                                       'error')
+        return self.redirect(self.get_action_url('view', upgrade))
+
     def delete_instance(self, upgrade):
         self.handler.delete_files(upgrade)
-        super(UpgradeView, self).delete_instance(upgrade)
+        super().delete_instance(upgrade)
+
+    def configure_get_context(self, **kwargs):
+        context = super().configure_get_context(**kwargs)
+
+        context['upgrade_systems'] = self.upgrade_handler.get_all_systems()
+
+        return context
+
+    def configure_gather_settings(self, data):
+        settings = super().configure_gather_settings(data)
+
+        keys = []
+        for system in json.loads(data['upgrade_systems']):
+            key = system['key']
+            if key == 'rattail':
+                settings.append({'name': 'rattail.upgrades.command',
+                                 'value': system['command']})
+            else:
+                keys.append(key)
+                settings.append({'name': 'rattail.upgrades.system.{}.label'.format(key),
+                                 'value': system['label']})
+                settings.append({'name': 'rattail.upgrades.system.{}.command'.format(key),
+                                 'value': system['command']})
+        if keys:
+            settings.append({'name': 'rattail.upgrades.systems',
+                             'value': ', '.join(keys)})
+
+        return settings
+
+    def configure_remove_settings(self):
+        super().configure_remove_settings()
+        app = self.get_rattail_app()
+        model = self.model
+
+        to_delete = self.Session.query(model.Setting)\
+                           .filter(sa.or_(
+                               model.Setting.name == 'rattail.upgrades.command',
+                               model.Setting.name == 'rattail.upgrades.systems',
+                               model.Setting.name.like('rattail.upgrades.system.%.label'),
+                               model.Setting.name.like('rattail.upgrades.system.%.command')))\
+                           .all()
+
+        for setting in to_delete:
+            app.delete_setting(self.Session(), setting.name)
 
     @classmethod
     def defaults(cls, config):
+        cls._defaults(config)
+        cls._upgrade_defaults(config)
+
+    @classmethod
+    def _upgrade_defaults(cls, config):
         route_prefix = cls.get_route_prefix()
-        url_prefix = cls.get_url_prefix()
         permission_prefix = cls.get_permission_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
         model_key = cls.get_model_key()
 
         # execution progress
-        config.add_route('{}.execute_progress'.format(route_prefix), '{}/{{{}}}/execute/progress'.format(url_prefix, model_key))
-        config.add_view(cls, attr='execute_progress', route_name='{}.execute_progress'.format(route_prefix),
-                        permission='{}.execute'.format(permission_prefix), renderer='json')
+        config.add_route('{}.execute_progress'.format(route_prefix),
+                         '{}/execute/progress'.format(instance_url_prefix))
+        config.add_view(cls, attr='execute_progress',
+                        route_name='{}.execute_progress'.format(route_prefix),
+                        permission='{}.execute'.format(permission_prefix),
+                        renderer='json')
 
-        cls._defaults(config)
+        # declare failure
+        config.add_route('{}.declare_failure'.format(route_prefix),
+                         '{}/declare-failure'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='declare_failure',
+                        route_name='{}.declare_failure'.format(route_prefix),
+                        permission='{}.execute'.format(permission_prefix))
+
+
+def defaults(config, **kwargs):
+    base = globals()
+    rattail_config = config.registry['rattail_config']
+
+    UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
+    UpgradeView.defaults(config)
+
+    if should_expose_websockets(rattail_config):
+        config.include('tailbone.views.asgi.upgrades')
 
 
 def includeme(config):
-    UpgradeView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 078e99ca..dfed0a11 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,35 +24,33 @@
 User Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import copy
-
-import six
+import sqlalchemy as sa
 from sqlalchemy import orm
 
-from rattail.db import model
-from rattail.db.auth import administrator_role, guest_role, authenticated_role, set_user_password, has_permission
+from rattail.db.model import User, UserEvent
 
 import colander
 from deform import widget as dfwidget
 from webhelpers2.html import HTML, tags
 
 from tailbone import forms
-from tailbone.db import Session
-from tailbone.views import MasterView
+from tailbone.views import MasterView, View
 from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
-from tailbone.config import protected_usernames
+from tailbone.util import raw_datetime
 
 
-class UsersView(PrincipalMasterView):
+class UserView(PrincipalMasterView):
     """
     Master view for the User model.
     """
-    model_class = model.User
-    has_rows = True
-    model_row_class = model.UserEvent
+    model_class = User
     has_versions = True
+    touchable = True
+    mergeable = True
+
+    labels = {
+        'api_tokens': "API Tokens",
+    }
 
     grid_columns = [
         'username',
@@ -70,34 +68,44 @@ class UsersView(PrincipalMasterView):
         'active',
         'active_sticky',
         'set_password',
+        'prevent_password_change',
+        'api_tokens',
         'roles',
+        'permissions',
     ]
 
+    has_rows = True
+    model_row_class = UserEvent
+    rows_title = "User Events"
+    rows_viewable = False
+
     row_grid_columns = [
         'type_code',
         'occurred',
     ]
 
-    mergeable = True
-    merge_additive_fields = [
-        'sent_message_count',
-        'received_message_count',
-    ]
-    merge_coalesce_fields = [
-        'person_uuid',
-        'person_name',
-        'active',
-    ]
-    merge_fields = merge_additive_fields + [
-        'uuid',
-        'username',
-        'person_uuid',
-        'person_name',
-        'role_count',
-    ]
+    def __init__(self, request):
+        super().__init__(request)
+        app = self.get_rattail_app()
+
+        # always get a reference to the auth/merge handler
+        self.auth_handler = app.get_auth_handler()
+        self.merge_handler = self.auth_handler
+
+    def get_context_menu_items(self, user=None):
+        items = super().get_context_menu_items(user)
+
+        if self.viewing:
+
+            if self.has_perm('preferences'):
+                url = self.get_action_url('preferences', user)
+                items.append(tags.link_to("Edit User Preferences", url))
+
+        return items
 
     def query(self, session):
-        query = super(UsersView, self).query(session)
+        query = super().query(session)
+        model = self.model
 
         # bring in the related Person(s)
         query = query.outerjoin(model.Person)\
@@ -106,7 +114,8 @@ class UsersView(PrincipalMasterView):
         return query
 
     def configure_grid(self, g):
-        super(UsersView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
 
         del g.filters['salt']
         g.filters['username'].default_active = True
@@ -134,6 +143,10 @@ class UsersView(PrincipalMasterView):
         g.set_link('last_name')
         g.set_link('display_name')
 
+    def grid_extra_class(self, user, i):
+        if not user.active:
+            return 'warning'
+
     def editable_instance(self, user):
         """
         If the given user is "protected" then we only allow edit if current
@@ -154,24 +167,8 @@ class UsersView(PrincipalMasterView):
             return True
         return not self.user_is_protected(user)
 
-    def user_is_protected(self, user):
-        """
-        This logic will consult the settings, for a list of "protected"
-        usernames, which should require root privileges to edit.  If no setting
-        is found, or the given ``user`` is not represented in the setting, then
-        edit is allowed.
-
-        But if there is a setting, and the ``user`` is represented in it, then
-        this method will return ``True`` only if the "current" app user is
-        "root", otherwise will return ``False``.
-        """
-        if not hasattr(self, 'protected_usernames'):
-            self.protected_usernames = protected_usernames(self.rattail_config)
-        if self.protected_usernames and user.username in self.protected_usernames:
-            return True
-        return False
-
     def unique_username(self, node, value):
+        model = self.model
         query = self.Session.query(model.User)\
                             .filter(model.User.username == value)
         if self.editing:
@@ -180,8 +177,20 @@ class UsersView(PrincipalMasterView):
         if query.count():
             raise colander.Invalid(node, "Username must be unique")
 
+    def valid_person(self, node, value):
+        """
+        Make sure ``value`` corresponds to an existing
+        ``Person.uuid``.
+        """
+        if value:
+            model = self.model
+            person = self.Session.get(model.Person, value)
+            if not person:
+                raise colander.Invalid(node, "Person not found (you must *select* a record)")
+
     def configure_form(self, f):
-        super(UsersView, self).configure_form(f)
+        super().configure_form(f)
+        model = self.model
         user = f.model_instance
 
         # username
@@ -196,14 +205,19 @@ class UsersView(PrincipalMasterView):
                 person_display = ""
                 if self.request.method == 'POST':
                     if self.request.POST.get('person_uuid'):
-                        person = self.Session.query(model.Person).get(self.request.POST['person_uuid'])
+                        person = self.Session.get(model.Person, self.request.POST['person_uuid'])
                         if person:
-                            person_display = six.text_type(person)
+                            person_display = str(person)
                 elif self.editing:
-                    person_display = six.text_type(user.person or '')
-                people_url = self.request.route_url('people.autocomplete')
-                f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=person_display, service_url=people_url))
+                    person_display = str(user.person or '')
+                try:
+                    people_url = self.request.route_url('people.autocomplete')
+                except KeyError:
+                    pass        # TODO: wutta compat
+                else:
+                    f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
+                        field_display=person_display, service_url=people_url))
+                f.set_validator('person_uuid', self.valid_person)
                 f.set_label('person_uuid', "Person")
 
         # person name(s)
@@ -219,10 +233,24 @@ class UsersView(PrincipalMasterView):
             f.set_renderer('display_name_', self.render_person_name)
 
         # set_password
-        f.set_widget('set_password', dfwidget.CheckedPasswordWidget())
+        if self.editing and user.prevent_password_change and not self.request.is_root:
+            f.remove('set_password')
+        else:
+            f.set_widget('set_password', dfwidget.CheckedPasswordWidget())
         # if self.creating:
         #     f.set_required('password')
 
+        # api_tokens
+        if self.creating or self.editing or self.deleting:
+            f.remove('api_tokens')
+        elif self.has_perm('manage_api_tokens'):
+            f.set_renderer('api_tokens', self.render_api_tokens)
+            f.set_vuejs_component_kwargs(**{':apiTokens': 'apiTokens',
+                                            '@api-new-token': 'apiNewToken',
+                                            '@api-token-delete': 'apiTokenDelete'})
+        else:
+            f.remove('api_tokens')
+
         # roles
         f.set_renderer('roles', self.render_roles)
         if self.creating or self.editing:
@@ -230,11 +258,13 @@ class UsersView(PrincipalMasterView):
                 f.remove_field('roles')
             else:
                 roles = self.get_possible_roles().all()
-                role_values = [(s.uuid, six.text_type(s)) for s in roles]
+                role_values = [(s.uuid, str(s)) for s in roles]
                 f.set_node('roles', colander.Set())
                 size = len(roles)
                 if size < 3:
                     size = 3
+                elif size > 20:
+                    size = 20
                 f.set_widget('roles', dfwidget.SelectWidget(multiple=True,
                                                             size=size,
                                                             values=role_values))
@@ -252,56 +282,144 @@ class UsersView(PrincipalMasterView):
         # fs.confirm_password.attrs(autocomplete='new-password')
 
         if self.viewing:
-            permissions = self.request.registry.settings.get('tailbone_permissions', {})
-            f.append('permissions')
-            f.set_renderer('permissions', PermissionsRenderer(permissions=permissions,
-                                                              include_guest=True,
+            permissions = self.request.registry.settings.get('wutta_permissions', {})
+            f.set_renderer('permissions', PermissionsRenderer(request=self.request,
+                                                              permissions=permissions,
+                                                              include_anonymous=True,
                                                               include_authenticated=True))
+        else:
+            f.remove('permissions')
 
         if self.viewing or self.deleting:
             f.remove('set_password')
 
+    def render_api_tokens(self, user, field):
+        route_prefix = self.get_route_prefix()
+        permission_prefix = self.get_permission_prefix()
+
+        factory = self.get_grid_factory()
+        g = factory(
+            self.request,
+            key=f'{route_prefix}.api_tokens',
+            data=[],
+            columns=['description', 'created'],
+            actions=[
+                self.make_action('delete', icon='trash',
+                                 click_handler="$emit('api-token-delete', props.row)")])
+
+        button = self.make_button("New", is_primary=True,
+                                  icon_left='plus',
+                                  **{'@click': "$emit('api-new-token')"})
+
+        table = HTML.literal(
+            g.render_table_element(data_prop='apiTokens'))
+
+        return HTML.tag('div', c=[button, table])
+
+    def add_api_token(self):
+        user = self.get_instance()
+        data = self.request.json_body
+
+        token = self.auth_handler.add_api_token(user, data['description'])
+        self.Session.flush()
+
+        return {'ok': True,
+                'raw_token': token.token_string,
+                'tokens': self.get_api_tokens(user)}
+
+    def delete_api_token(self):
+        model = self.model
+        user = self.get_instance()
+        data = self.request.json_body
+
+        token = self.Session.get(model.UserAPIToken, data['uuid'])
+        if not token:
+            return {'error': "API token not found"}
+
+        if token.user is not user:
+            return {'error': "API token not found"}
+
+        self.auth_handler.delete_api_token(token)
+        self.Session.flush()
+
+        return {'ok': True,
+                'tokens': self.get_api_tokens(user)}
+
+    def template_kwargs_view(self, **kwargs):
+        kwargs = super().template_kwargs_view(**kwargs)
+        user = kwargs['instance']
+
+        kwargs['api_tokens_data'] = self.get_api_tokens(user)
+
+        return kwargs
+
+    def get_api_tokens(self, user):
+        tokens = []
+        for token in reversed(user.api_tokens):
+            tokens.append({
+                'uuid': token.uuid,
+                'description': token.description,
+                'created': raw_datetime(self.rattail_config, token.created),
+            })
+        return tokens
+
     def get_possible_roles(self):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
 
         # some roles should never have users "belong" to them
         excluded = [
-            guest_role(self.Session()).uuid,
-            authenticated_role(self.Session()).uuid,
+            auth.get_role_anonymous(self.Session()).uuid,
+            auth.get_role_authenticated(self.Session()).uuid,
         ]
 
-        # only allow "root" user to change admin role membership
+        # only allow "root" user to change true admin role membership
         if not self.request.is_root:
-            excluded.append(administrator_role(self.Session()).uuid)
+            excluded.append(auth.get_role_administrator(self.Session()).uuid)
 
-        return self.Session.query(model.Role)\
-                           .filter(~model.Role.uuid.in_(excluded))\
-                           .order_by(model.Role.name)
+        # basic list, minus exclusions so far
+        roles = self.Session.query(model.Role)\
+                            .filter(~model.Role.uuid.in_(excluded))
+
+        # only allow "admin" user to change admin-ish role memberships
+        if not self.request.is_admin:
+            roles = roles.filter(sa.or_(
+                model.Role.adminish == False,
+                model.Role.adminish == None))
+
+        return roles.order_by(model.Role.name)
 
     def objectify(self, form, data=None):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
 
         # create/update user as per normal
         if data is None:
             data = form.validated
-        user = super(UsersView, self).objectify(form, data)
+        user = super().objectify(form, data)
 
         # create/update person as needed
         names = {}
-        if 'first_name_' in form:
+        if 'first_name_' in form and data['first_name_']:
             names['first'] = data['first_name_']
-        if 'last_name_' in form:
+        if 'last_name_' in form and data['last_name_']:
             names['last'] = data['last_name_']
-        if 'display_name_' in form:
-            names['display'] = data['display_name_']
+        if 'display_name_' in form and data['display_name_']:
+            names['full'] = data['display_name_']
+        # we will not have a person reference yet, when creating new user.  if
+        # that is the case, go ahead and load it, if specified.
+        if self.creating and user.person_uuid:
+            self.Session.add(user)
+            self.Session.flush()
         # note, do *not* create new person unless name(s) provided
         if not user.person and any([n for n in names.values()]):
             user.person = model.Person()
         if user.person:
-            if 'first' in names:
-                user.person.first_name = names['first']
-            if 'last' in names:
-                user.person.last_name = names['last']
-            if 'display' in names:
-                user.person.display_name = names['display']
+            app = self.get_rattail_app()
+            handler = app.get_people_handler()
+            handler.update_names(user.person, **names)
 
         # force "local only" flag unless global access granted
         if self.secure_global_objects:
@@ -309,8 +427,8 @@ class UsersView(PrincipalMasterView):
                 user.person.local_only = True
 
         # maybe set user password
-        if data['set_password']:
-            set_user_password(user, data['set_password'])
+        if 'set_password' in form and data['set_password']:
+            auth.set_user_password(user, data['set_password'])
 
         # update roles for user
         self.update_roles(user, data)
@@ -323,9 +441,12 @@ class UsersView(PrincipalMasterView):
         if 'roles' not in data:
             return
 
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = app.model
         old_roles = set([r.uuid for r in user.roles])
         new_roles = data['roles']
-        admin = administrator_role(self.Session())
+        admin = auth.get_role_administrator(self.Session())
 
         # add any new roles for the user, taking care not to add the admin role
         # unless acting as root
@@ -334,19 +455,35 @@ class UsersView(PrincipalMasterView):
                 if self.request.is_root or uuid != admin.uuid:
                     user._roles.append(model.UserRole(role_uuid=uuid))
 
+                    # also record a change to the role, for datasync.
+                    # this is done "just in case" the role is to be
+                    # synced to all nodes
+                    if self.Session().rattail_record_changes:
+                        self.Session.add(model.Change(class_name='Role',
+                                                      instance_uuid=uuid,
+                                                      deleted=False))
+
         # remove any roles which were *not* specified, although must take care
         # not to remove admin role, unless acting as root
         for uuid in old_roles:
             if uuid not in new_roles:
                 if self.request.is_root or uuid != admin.uuid:
-                    role = self.Session.query(model.Role).get(uuid)
+                    role = self.Session.get(model.Role, uuid)
                     user.roles.remove(role)
 
+                    # also record a change to the role, for datasync.
+                    # this is done "just in case" the role is to be
+                    # synced to all nodes
+                    if self.Session().rattail_record_changes:
+                        self.Session.add(model.Change(class_name='Role',
+                                                      instance_uuid=uuid,
+                                                      deleted=False))
+
     def render_person(self, user, field):
         person = user.person
         if not person:
             return ""
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(person, url)
 
@@ -356,10 +493,10 @@ class UsersView(PrincipalMasterView):
         name = getattr(user, field[:-1], None)
         if not name:
             return ""
-        return six.text_type(name)
+        return str(name)
 
     def render_roles(self, user, field):
-        roles = user.roles
+        roles = sorted(user.roles, key=lambda r: r.name)
         items = []
         for role in roles:
             text = role.name
@@ -368,24 +505,29 @@ class UsersView(PrincipalMasterView):
         return HTML.tag('ul', c=items)
 
     def get_row_data(self, user):
+        model = self.model
         return self.Session.query(model.UserEvent)\
                            .filter(model.UserEvent.user == user)
 
     def configure_row_grid(self, g):
-        super(UsersView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.width = 'half'
         g.filterable = False
         g.set_sort_defaults('occurred', 'desc')
         g.set_enum('type_code', self.enum.USER_EVENT)
         g.set_label('type_code', "Event Type")
-        g.main_actions = []
 
     def get_version_child_classes(self):
+        model = self.model
         return [
             (model.UserRole, 'user_uuid'),
         ]
 
     def find_principals_with_permission(self, session, permission):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
+        model = self.model
+
         # TODO: this should search Permission table instead, and work backward to User?
         all_users = session.query(model.User)\
                            .filter(model.User.active == True)\
@@ -395,34 +537,146 @@ class UsersView(PrincipalMasterView):
                                     .joinedload(model.Role._permissions))
         users = []
         for user in all_users:
-            if has_permission(session, user, permission):
+            if auth.has_permission(session, user, permission):
                 users.append(user)
         return users
 
-    def get_merge_data(self, user):
-        return {
-            'uuid': user.uuid,
-            'username': user.username,
-            'person_uuid': user.person_uuid,
-            'person_name': user.person.display_name if user.person else None,
-            '_roles': user.roles,
-            'role_count': len(user.roles),
-            'active': user.active,
-            'sent_message_count': len(user.sent_messages),
-            'received_message_count': len(user._messages),
-        }
+    def find_by_perm_configure_results_grid(self, g):
+        g.append('username')
+        g.set_link('username')
 
-    def get_merge_resulting_data(self, remove, keep):
-        result = super(UsersView, self).get_merge_resulting_data(remove, keep)
-        result['role_count'] = len(set(remove['_roles'] + keep['_roles']))
-        return result
+        g.append('person')
+        g.set_link('person')
 
-    def merge_objects(self, removing, keeping):
-        # TODO: merge roles, messages
-        assert not removing.sent_messages
-        assert not removing._messages
-        assert not removing._roles
-        self.Session.delete(removing)
+    def find_by_perm_normalize(self, user):
+        data = super().find_by_perm_normalize(user)
+
+        data['username'] = user.username
+        data['person'] = str(user.person or '')
+
+        return data
+
+    def preferences(self, user=None):
+        """
+        View to modify preferences for a particular user.
+        """
+        current_user = True
+        if not user:
+            current_user = False
+            user = self.get_instance()
+
+        # TODO: this is of course largely copy/pasted from the
+        # MasterView.configure() method..should refactor?
+        if self.request.method == 'POST':
+            if self.request.POST.get('remove_settings'):
+                self.preferences_remove_settings(user)
+                self.request.session.flash("Settings have been removed.")
+                return self.redirect(self.request.current_route_url())
+            else:
+                data = self.request.POST
+
+                # then gather/save settings
+                settings = self.preferences_gather_settings(data, user)
+                self.preferences_remove_settings(user)
+                self.configure_save_settings(settings)
+                self.request.session.flash("Settings have been saved.")
+                return self.redirect(self.request.current_route_url())
+
+        context = self.preferences_get_context(user, current_user)
+        return self.render_to_response('preferences', context)
+
+    def my_preferences(self):
+        """
+        View to modify preferences for the current user.
+        """
+        user = self.request.user
+        if not user:
+            raise self.forbidden()
+        return self.preferences(user=user)
+
+    def preferences_get_context(self, user, current_user):
+        simple_settings = self.preferences_get_simple_settings(user)
+        context = self.configure_get_context(simple_settings=simple_settings,
+                                             input_file_templates=False)
+
+        instance_title = self.get_instance_title(user)
+        context.update({
+            'user': user,
+            'instance': user,
+            'instance_title': instance_title,
+            'instance_url': self.get_action_url('view', user),
+            'config_title': instance_title,
+            'config_preferences': True,
+            'current_user': current_user,
+        })
+
+        if current_user:
+            context.update({
+                'index_url': None,
+                'index_title': instance_title,
+            })
+
+        # theme style options
+        options = [{'value': None, 'label': "default"}]
+        styles = self.rattail_config.getlist('tailbone', 'themes.styles',
+                                             default=[])
+        for name in styles:
+            css = None
+            if self.request.use_oruga:
+                css = self.rattail_config.get(f'tailbone.themes.bb_style.{name}')
+            if not css:
+                css = self.rattail_config.get(f'tailbone.themes.style.{name}')
+            if css:
+                options.append({'value': css, 'label': name})
+        context['theme_style_options'] = options
+
+        return context
+
+    def preferences_get_simple_settings(self, user):
+        """
+        This method is conceptually the same as for
+        :meth:`~tailbone.views.master.MasterView.configure_get_simple_settings()`.
+        See its docs for more info.
+
+        The only difference here is that we are given a user account,
+        so the settings involved should only pertain to that user.
+        """
+        # TODO: can stop pre-fetching this value only once we are
+        # confident all settings have been updated in the wild
+        user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'user_css')
+        if not user_css:
+            user_css = self.rattail_config.get(f'tailbone.{user.uuid}', 'buefy_css')
+
+        return [
+
+            # display
+            {'section': f'tailbone.{user.uuid}',
+             'option': 'user_css',
+             'value': user_css,
+             'save_if_empty': False},
+        ]
+
+    def preferences_gather_settings(self, data, user):
+        simple_settings = self.preferences_get_simple_settings(user)
+        settings = self.configure_gather_settings(
+            data, simple_settings=simple_settings, input_file_templates=False)
+
+        # TODO: ugh why does user_css come back as 'default' instead of None?
+        final_settings = []
+        for setting in settings:
+            if setting['name'].endswith('.user_css'):
+                if setting['value'] == 'default':
+                    continue
+            final_settings.append(setting)
+
+        return final_settings
+
+    def preferences_remove_settings(self, user):
+        app = self.get_rattail_app()
+        simple_settings = self.preferences_get_simple_settings(user)
+        self.configure_remove_settings(simple_settings=simple_settings,
+                                       input_file_templates=False)
+        app.delete_setting(self.Session(), f'tailbone.{user.uuid}.buefy_css')
 
     @classmethod
     def defaults(cls, config):
@@ -435,7 +689,9 @@ class UsersView(PrincipalMasterView):
         """
         Provide extra default configuration for the User master view.
         """
+        route_prefix = cls.get_route_prefix()
         permission_prefix = cls.get_permission_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
         model_title = cls.get_model_title()
 
         # view/edit roles
@@ -444,12 +700,51 @@ class UsersView(PrincipalMasterView):
         config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix),
                                        "Edit the Roles to which a {} belongs".format(model_title))
 
+        # manage API tokens
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.manage_api_tokens'.format(permission_prefix),
+                                       "Manage API tokens for any {}".format(model_title))
+        config.add_route('{}.add_api_token'.format(route_prefix),
+                         '{}/add-api-token'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='add_api_token',
+                        route_name='{}.add_api_token'.format(route_prefix),
+                        permission='{}.manage_api_tokens'.format(permission_prefix),
+                        renderer='json')
+        config.add_route('{}.delete_api_token'.format(route_prefix),
+                         '{}/delete-api-token'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='delete_api_token',
+                        route_name='{}.delete_api_token'.format(route_prefix),
+                        permission='{}.manage_api_tokens'.format(permission_prefix),
+                        renderer='json')
 
-class UserEventsView(MasterView):
+        # edit preferences for any user
+        config.add_tailbone_permission(permission_prefix,
+                                       '{}.preferences'.format(permission_prefix),
+                                       "Edit preferences for any {}".format(model_title))
+        config.add_route('{}.preferences'.format(route_prefix),
+                         '{}/preferences'.format(instance_url_prefix))
+        config.add_view(cls, attr='preferences',
+                        route_name='{}.preferences'.format(route_prefix),
+                        permission='{}.preferences'.format(permission_prefix))
+
+        # edit "my" preferences (for current user)
+        config.add_route('my.preferences',
+                         '/my/preferences')
+        config.add_view(cls, attr='my_preferences',
+                        route_name='my.preferences')
+
+
+# TODO: deprecate / remove this
+UsersView = UserView
+
+
+class UserEventView(MasterView):
     """
     Master view for all user events
     """
-    model_class = model.UserEvent
+    model_class = UserEvent
     url_prefix = '/user-events'
     viewable = False
     creatable = False
@@ -464,11 +759,13 @@ class UserEventsView(MasterView):
     ]
 
     def get_data(self, session=None):
-        query = super(UserEventsView, self).get_data(session=session)
+        query = super().get_data(session=session)
+        model = self.model
         return query.join(model.User)
 
     def configure_grid(self, g):
-        super(UserEventsView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.model
         g.set_joiner('person', lambda q: q.outerjoin(model.Person))
         g.set_sorter('user', model.User.username)
         g.set_sorter('person', model.Person.display_name)
@@ -489,7 +786,23 @@ class UserEventsView(MasterView):
         if event.user.person:
             return event.user.person.display_name
 
+# TODO: deprecate / remove this
+UserEventsView = UserEventView
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    UserView = kwargs.get('UserView', base['UserView'])
+    UserView.defaults(config)
+
+    UserEventView = kwargs.get('UserEventView', base['UserEventView'])
+    UserEventView.defaults(config)
+
 
 def includeme(config):
-    UsersView.defaults(config)
-    UserEventsView.defaults(config)
+    wutta_config = config.registry.settings['wutta_config']
+    if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False):
+        config.include('tailbone.views.wutta.users')
+    else:
+        defaults(config)
diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py
index 51b528f2..210df39e 100644
--- a/tailbone/views/vendors/__init__.py
+++ b/tailbone/views/vendors/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2020 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,14 @@
 Views pertaining to vendors
 """
 
-from __future__ import unicode_literals, absolute_import
+from .core import VendorView
 
-from .core import VendorsView, VendorsAutocomplete
+
+def defaults(config, **kwargs):
+    from .core import defaults
+    return defaults(config, **kwargs)
 
 
 def includeme(config):
     config.include('tailbone.views.vendors.core')
+    config.include('tailbone.views.vendors.samplefiles')
diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py
index e021a88a..2471ad47 100644
--- a/tailbone/views/vendors/catalogs.py
+++ b/tailbone/views/vendors/catalogs.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,13 +26,11 @@
 Please use `tailbone.views.batch.vendorcatalog` instead.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 
 def includeme(config):
     warnings.warn("The `tailbone.views.vendors.catalogs` module is deprecated, "
                   "please use `tailbone.views.batch.vendorcatalog` instead.",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
     config.include('tailbone.views.batch.vendorcatalog')
diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py
index 7f9c064e..addf153c 100644
--- a/tailbone/views/vendors/core.py
+++ b/tailbone/views/vendors/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,24 +24,24 @@
 Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from webhelpers2.html import tags
 
-from tailbone.views import MasterView, AutocompleteView
+from tailbone.views import MasterView
+from tailbone.db import Session
 
 
-class VendorsView(MasterView):
+class VendorView(MasterView):
     """
     Master view for the Vendor class.
     """
     model_class = model.Vendor
     has_versions = True
     touchable = True
+    results_downloadable = True
+    supports_autocomplete = True
+    configurable = True
 
     labels = {
         'id': "ID",
@@ -56,6 +56,7 @@ class VendorsView(MasterView):
         'phone',
         'email',
         'contact',
+        'terms',
     ]
 
     form_fields = [
@@ -69,10 +70,11 @@ class VendorsView(MasterView):
         'default_email',
         'orders_email',
         'contact',
+        'terms',
     ]
 
     def configure_grid(self, g):
-        super(VendorsView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
@@ -86,12 +88,12 @@ class VendorsView(MasterView):
         g.set_link('abbreviation')
 
     def configure_form(self, f):
-        super(VendorsView, self).configure_form(f)
+        super().configure_form(f)
+        app = self.get_rattail_app()
         vendor = f.model_instance
 
-        f.set_label('lead_time_days', "Lead Time in Days")
-
-        f.set_label('order_interval', "Order Interval in Days")
+        f.set_type('lead_time_days', 'quantity')
+        f.set_type('order_interval_days', 'quantity')
 
         # default_phone
         f.set_renderer('default_phone', self.render_default_phone)
@@ -106,7 +108,7 @@ class VendorsView(MasterView):
         # orders_email
         f.set_renderer('orders_email', self.render_orders_email)
         if not self.creating and vendor.emails:
-            f.set_default('orders_email', vendor.get_email_address(type_='Orders') or '')
+            f.set_default('orders_email', app.get_contact_email_address(vendor, type_='Orders') or '')
 
         # contact
         if self.creating:
@@ -118,12 +120,13 @@ class VendorsView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        vendor = super(VendorsView, self).objectify(form, data)
+        vendor = super().objectify(form, data)
         vendor = self.objectify_contact(vendor, data)
+        app = self.get_rattail_app()
 
         if 'orders_email' in data:
             address = data['orders_email']
-            email = vendor.get_email(type_='Orders')
+            email = app.get_contact_email(vendor, type_='Orders')
             if address:
                 if email:
                     if email.address != address:
@@ -140,7 +143,8 @@ class VendorsView(MasterView):
             return vendor.emails[0].address
 
     def render_orders_email(self, vendor, field):
-        return vendor.get_email_address(type_='Orders')
+        app = self.get_rattail_app()
+        return app.get_contact_email_address(vendor, type_='Orders')
 
     def render_default_phone(self, vendor, field):
         if vendor.phones:
@@ -150,7 +154,7 @@ class VendorsView(MasterView):
         person = vendor.contact
         if not person:
             return ""
-        text = six.text_type(person)
+        text = str(person)
         url = self.request.route_url('people.view', uuid=person.uuid)
         return tags.link_to(text, url)
 
@@ -162,24 +166,84 @@ class VendorsView(MasterView):
             self.Session.delete(cost)
 
     def get_version_child_classes(self):
-        return [
+        return super().get_version_child_classes() + [
             (model.VendorPhoneNumber, 'parent_uuid'),
             (model.VendorEmailAddress, 'parent_uuid'),
             (model.VendorContact, 'vendor_uuid'),
         ]
 
+    def configure_get_simple_settings(self):
+        config = self.rattail_config
+        return [
 
-class VendorsAutocomplete(AutocompleteView):
+            # display
+            {'section': 'rattail',
+             'option': 'vendors.choice_uses_dropdown',
+             'type': bool},
+        ]
 
-    mapped_class = model.Vendor
-    fieldname = 'name'
+    def configure_get_context(self, **kwargs):
+        context = super().configure_get_context(**kwargs)
+
+        context['supported_vendor_settings'] = self.configure_get_supported_vendor_settings()
+
+        return context
+
+    def configure_gather_settings(self, data, **kwargs):
+        settings = super().configure_gather_settings(
+            data, **kwargs)
+
+        supported_vendor_settings = self.configure_get_supported_vendor_settings()
+        for setting in supported_vendor_settings.values():
+            name = 'rattail.vendor.{}'.format(setting['key'])
+            settings.append({'name': name,
+                             'value': data[name]})
+
+        return settings
+
+    def configure_remove_settings(self, **kwargs):
+        super().configure_remove_settings(**kwargs)
+        app = self.get_rattail_app()
+        names = []
+
+        supported_vendor_settings = self.configure_get_supported_vendor_settings()
+        for setting in supported_vendor_settings.values():
+            names.append('rattail.vendor.{}'.format(setting['key']))
+
+        if names:
+            # nb. using thread-local session here; we do not use
+            # self.Session b/c it may not point to Rattail
+            session = Session()
+            for name in names:
+                app.delete_setting(session, name)
+
+    def configure_get_supported_vendor_settings(self):
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+        batch_handler = app.get_batch_handler('purchase')
+        settings = {}
+
+        for parser in batch_handler.get_supported_invoice_parsers():
+            key = parser.vendor_key
+            if not key:
+                continue
+
+            vendor = vendor_handler.get_vendor(self.Session(), key)
+            settings[key] = {
+                'key': key,
+                'value': vendor.uuid if vendor else None,
+                'label': str(vendor) if vendor else None,
+            }
+
+        return settings
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    VendorView = kwargs.get('VendorView', base['VendorView'])
+    VendorView.defaults(config)
 
 
 def includeme(config):
-
-    # autocomplete
-    config.add_route('vendors.autocomplete', '/vendors/autocomplete')
-    config.add_view(VendorsAutocomplete, route_name='vendors.autocomplete',
-                    renderer='json', permission='vendors.list')
-
-    VendorsView.defaults(config)
+    defaults(config)
diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py
index e61329f6..40fe0365 100644
--- a/tailbone/views/vendors/invoices.py
+++ b/tailbone/views/vendors/invoices.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2019 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -26,13 +26,11 @@
 Please use `tailbone.views.batch.vendorinvoice` instead.
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 
 def includeme(config):
     warnings.warn("The `tailbone.views.vendors.invoices` module is deprecated, "
                   "please use `tailbone.views.batch.vendorinvoice` instead.",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
     config.include('tailbone.views.batch.vendorinvoice')
diff --git a/tailbone/views/vendors/samplefiles.py b/tailbone/views/vendors/samplefiles.py
new file mode 100644
index 00000000..a75bc1fb
--- /dev/null
+++ b/tailbone/views/vendors/samplefiles.py
@@ -0,0 +1,162 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Model View for Vendor Sample Files
+"""
+
+from rattail.db.model import VendorSampleFile
+
+from webhelpers2.html import tags
+
+from tailbone import forms
+from tailbone.views import MasterView
+
+
+class VendorSampleFileView(MasterView):
+    """
+    Master model view for Vendor Sample Files
+    """
+    model_class = VendorSampleFile
+    route_prefix = 'vendorsamplefiles'
+    url_prefix = '/vendors/sample-files'
+    downloadable = True
+    has_versions = True
+
+    grid_columns = [
+        'vendor',
+        'file_type',
+        'effective_date',
+        'filename',
+        'created_by',
+    ]
+
+    form_fields = [
+        'vendor',
+        'file_type',
+        'filename',
+        'effective_date',
+        'notes',
+        'created_by',
+    ]
+
+    def configure_grid(self, g):
+        super(VendorSampleFileView, self).configure_grid(g)
+        model = self.model
+
+        # vendor
+        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name,
+                     default_active=True, default_verb='contains')
+        g.set_link('vendor')
+
+        # filename
+        g.set_link('filename')
+
+        # effective_date
+        g.set_sort_defaults('effective_date', 'desc')
+
+    def configure_form(self, f):
+        super(VendorSampleFileView, self).configure_form(f)
+
+        # vendor
+        f.set_renderer('vendor', self.render_vendor)
+        if self.creating:
+            f.replace('vendor', 'vendor_uuid')
+            f.set_label('vendor_uuid', "Vendor")
+            f.set_widget('vendor_uuid',
+                         forms.widgets.make_vendor_widget(self.request))
+        else:
+            f.set_readonly('vendor')
+
+        # filename
+        if self.creating:
+            f.replace('filename', 'file')
+            f.set_type('file', 'file')
+        else:
+            f.set_readonly('filename')
+            f.set_renderer('filename', self.render_filename)
+
+        # effective_date
+        f.set_type('effective_date', 'date_jquery')
+
+        # notes
+        f.set_type('notes', 'text')
+
+        # created_by
+        if self.creating or self.editing:
+            f.remove('created_by')
+        else:
+            f.set_readonly('created_by')
+            f.set_renderer('created_by', self.render_user)
+
+    def objectify(self, form, data=None):
+        if data is None:
+            data = form.validated
+
+        sample = super(VendorSampleFileView, self).objectify(form, data=data)
+
+        if self.creating:
+            sample.filename = data['file']['filename']
+            data['file']['fp'].seek(0)
+            sample.bytes = data['file']['fp'].read()
+            sample.created_by = self.request.user
+
+        return sample
+
+    def render_filename(self, sample, field):
+        filename = getattr(sample, field)
+        if not filename:
+            return
+
+        size = self.readable_size(None, size=len(sample.bytes))
+        text = "{} ({})".format(filename, size)
+        url = self.get_action_url('download', sample)
+        return tags.link_to(text, url)
+
+    def download(self):
+        """
+        View for downloading a sample file.
+
+        We override default logic to send raw bytes from DB, and avoid
+        writing file to disk.
+        """
+        sample = self.get_instance()
+
+        response = self.request.response
+        response.content_length = len(sample.bytes)
+        response.content_disposition = 'attachment; filename="{}"'.format(
+            sample.filename)
+        response.body = sample.bytes
+        return response
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    VendorSampleFileView = kwargs.get('VendorSampleFileView', base['VendorSampleFileView'])
+    VendorSampleFileView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/versions.py b/tailbone/views/versions.py
new file mode 100644
index 00000000..6c370996
--- /dev/null
+++ b/tailbone/views/versions.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2021 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Master view for version tables
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+import sqlalchemy_continuum as continuum
+
+from tailbone.views import MasterView
+from tailbone.util import raw_datetime
+
+
+class VersionMasterView(MasterView):
+    """
+    Base class for version master views
+    """
+    creatable = False
+    editable = False
+    deletable = False
+
+    labels = {
+        'transaction_issued_at': "Changed",
+        'transaction_user': "Changed by",
+        'transaction_id': "Transaction ID",
+    }
+
+    grid_columns = [
+        'transaction_issued_at',
+        'transaction_user',
+        'version_parent',
+        'transaction_id',
+    ]
+
+    def query(self, session):
+        Transaction = continuum.transaction_class(self.true_model_class)
+
+        query = session.query(self.model_class)\
+                       .join(Transaction,
+                             Transaction.id == self.model_class.transaction_id)
+
+        return query
+
+    def configure_grid(self, g):
+        super(VersionMasterView, self).configure_grid(g)
+        Transaction = continuum.transaction_class(self.true_model_class)
+
+        g.set_sorter('transaction_issued_at', Transaction.issued_at)
+        g.set_sorter('transaction_id', Transaction.id)
+        g.set_sort_defaults('transaction_issued_at', 'desc')
+
+        g.set_renderer('transaction_issued_at', self.render_transaction_issued_at)
+        g.set_renderer('transaction_user', self.render_transaction_user)
+        g.set_renderer('transaction_id', self.render_transaction_id)
+
+        g.set_link('transaction_issued_at')
+        g.set_link('transaction_user')
+        g.set_link('version_parent')
+
+    def render_transaction_issued_at(self, version, field):
+        value = version.transaction.issued_at
+        return raw_datetime(self.rattail_config, value)
+
+    def render_transaction_user(self, version, field):
+        return version.transaction.user
+
+    def render_transaction_id(self, version, field):
+        return version.transaction.id
diff --git a/tailbone/views/views.py b/tailbone/views/views.py
new file mode 100644
index 00000000..67cba2e2
--- /dev/null
+++ b/tailbone/views/views.py
@@ -0,0 +1,220 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2023 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Views for views
+"""
+
+import os
+import sys
+
+from rattail.db.util import get_fieldnames
+from rattail.util import simple_error
+
+import colander
+from deform import widget as dfwidget
+
+from tailbone.views import MasterView
+
+
+class ModelViewView(MasterView):
+    """
+    Master view for views
+    """
+    normalized_model_name = 'model_view'
+    model_key = 'route_prefix'
+    model_title = "Model View"
+    url_prefix = '/views/model'
+    viewable = True
+    creatable = True
+    editable = False
+    deletable = False
+    filterable = False
+    pageable = False
+
+    grid_columns = [
+        'label',
+        'model_name',
+        'route_prefix',
+        'permission_prefix',
+    ]
+
+    def get_data(self, **kwargs):
+        """
+        Fetch existing model views from app registry
+        """
+        data = []
+
+        all_views = self.request.registry.settings['tailbone_model_views']
+        for model_name in sorted(all_views):
+            model_views = all_views[model_name]
+            for view in model_views:
+                data.append({
+                    'model_name': model_name,
+                    'label': view['label'],
+                    'route_prefix': view['route_prefix'],
+                    'permission_prefix': view['permission_prefix'],
+                })
+
+        return data
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+
+        # label
+        g.sorters['label'] = g.make_simple_sorter('label')
+        g.set_sort_defaults('label')
+        g.set_link('label')
+        g.set_searchable('label')
+
+        # model_name
+        g.sorters['model_name'] = g.make_simple_sorter('model_name', foldcase=True)
+        g.set_searchable('model_name')
+
+        # route
+        g.sorters['route'] = g.make_simple_sorter('route')
+        g.set_searchable('route')
+
+        # permission
+        g.sorters['permission'] = g.make_simple_sorter('permission')
+        g.set_searchable('permission')
+
+    def default_view_url(self):
+        return lambda view, i: self.request.route_url(view['route_prefix'])
+
+    def make_form_schema(self):
+        return ModelViewSchema()
+
+    def template_kwargs_create(self, **kwargs):
+        kwargs = super().template_kwargs_create(**kwargs)
+        app = self.get_rattail_app()
+        db_handler = app.get_db_handler()
+
+        model_classes = db_handler.get_model_classes()
+        kwargs['model_names'] = [cls.__name__ for cls in model_classes]
+
+        pkg = self.rattail_config.get('rattail', 'running_from_source.rootpkg')
+        if pkg:
+            kwargs['pkgroot'] = pkg
+            pkg = sys.modules[pkg]
+            pkgdir = os.path.dirname(pkg.__file__)
+            kwargs['view_dir'] = os.path.join(pkgdir, 'web', 'views') + os.sep
+        else:
+            kwargs['pkgroot'] = 'poser'
+            kwargs['view_dir'] = '??' + os.sep
+
+        return kwargs
+
+    def write_view_file(self):
+        data = self.request.json_body
+        path = data['view_file']
+
+        if os.path.exists(path):
+            if data['overwrite']:
+                os.remove(path)
+            else:
+                return {'error': "File already exists"}
+
+        app = self.get_rattail_app()
+        tb = app.get_tailbone_handler()
+        model_class = getattr(self.model, data['model_name'])
+
+        data['model_module_name'] = self.model.__name__
+        data['model_title_plural'] = getattr(model_class,
+                                             'model_title_plural',
+                                             # TODO
+                                             model_class.__name__)
+
+        data['model_versioned'] = hasattr(model_class, '__versioned__')
+
+        fieldnames = get_fieldnames(self.rattail_config,
+                                                  model_class)
+        fieldnames.remove('uuid')
+        data['model_fieldnames'] = fieldnames
+
+        tb.write_model_view(data, path)
+
+        return {'ok': True}
+
+    def check_view(self):
+        data = self.request.json_body
+
+        try:
+            url = self.request.route_url(data['route_prefix'])
+        except Exception as error:
+            return {'ok': True,
+                    'problem': simple_error(error)}
+
+        return {'ok': True, 'url': url}
+
+    @classmethod
+    def defaults(cls, config):
+        rattail_config = config.registry.settings.get('rattail_config')
+
+        # allow creating views only if *not* production
+        if not rattail_config.production():
+            cls.creatable = True
+
+        cls._model_view_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _model_view_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+
+        if cls.creatable:
+
+            # write view class to file
+            config.add_route('{}.write_view_file'.format(route_prefix),
+                             '{}/write-view-file'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='write_view_file',
+                            route_name='{}.write_view_file'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.create'.format(permission_prefix))
+
+            # check view
+            config.add_route('{}.check_view'.format(route_prefix),
+                             '{}/check-view'.format(url_prefix),
+                             request_method='POST')
+            config.add_view(cls, attr='check_view',
+                            route_name='{}.check_view'.format(route_prefix),
+                            renderer='json',
+                            permission='{}.create'.format(permission_prefix))
+
+
+class ModelViewSchema(colander.Schema):
+
+    model_name = colander.SchemaNode(colander.String())
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    ModelViewView = kwargs.get('ModelViewView', base['ModelViewView'])
+    ModelViewView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py
new file mode 100644
index 00000000..d8094e4b
--- /dev/null
+++ b/tailbone/views/workorders.py
@@ -0,0 +1,416 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Work Order Views
+"""
+
+import sqlalchemy as sa
+
+from rattail.db.model import WorkOrder, WorkOrderEvent
+
+from webhelpers2.html import HTML
+
+from tailbone import forms, grids
+from tailbone.views import MasterView
+
+
+class WorkOrderView(MasterView):
+    """
+    Master view for work orders
+    """
+    model_class = WorkOrder
+    route_prefix = 'workorders'
+    url_prefix = '/workorders'
+    bulk_deletable = True
+
+    labels = {
+        'id': "ID",
+        'status_code': "Status",
+    }
+
+    grid_columns = [
+        'id',
+        'customer',
+        'date_received',
+        'date_released',
+        'status_code',
+    ]
+
+    form_fields = [
+        'id',
+        'customer',
+        'notes',
+        'date_submitted',
+        'date_received',
+        'date_released',
+        'date_delivered',
+        'status_code',
+    ]
+
+    has_rows = True
+    model_row_class = WorkOrderEvent
+    rows_viewable = False
+
+    row_labels = {
+        'type_code': "Event Type",
+    }
+
+    row_grid_columns = [
+        'type_code',
+        'occurred',
+        'user',
+        'note',
+    ]
+
+    def __init__(self, request):
+        super().__init__(request)
+        app = self.get_rattail_app()
+        self.workorder_handler = app.get_workorder_handler()
+
+    def configure_grid(self, g):
+        super().configure_grid(g)
+        model = self.model
+
+        # customer
+        g.set_joiner('customer', lambda q: q.join(model.Customer))
+        g.set_sorter('customer', model.Customer.name)
+        g.set_filter('customer', model.Customer.name)
+
+        # status
+        g.set_filter('status_code', model.WorkOrder.status_code,
+                     factory=StatusFilter,
+                     default_active=True,
+                     default_verb='is_active')
+        g.set_enum('status_code', self.enum.WORKORDER_STATUS)
+
+        g.set_sort_defaults('id', 'desc')
+
+        g.set_link('id')
+        g.set_link('customer')
+
+    def grid_extra_class(self, workorder, i):
+        if workorder.status_code == self.enum.WORKORDER_STATUS_CANCELED:
+            return 'warning'
+
+    def configure_form(self, f):
+        super().configure_form(f)
+        model = self.model
+        SelectWidget = forms.widgets.JQuerySelectWidget
+
+        # id
+        if self.creating:
+            f.remove_field('id')
+        else:
+            f.set_readonly('id')
+
+        # customer
+        if self.creating:
+            f.replace('customer', 'customer_uuid')
+            f.set_label('customer_uuid', "Customer")
+            f.set_widget('customer_uuid',
+                         forms.widgets.make_customer_widget(self.request))
+            f.set_input_handler('customer_uuid', 'customerChanged')
+        else:
+            f.set_readonly('customer')
+            f.set_renderer('customer', self.render_customer)
+
+        # notes
+        f.set_type('notes', 'text')
+
+        # status_code
+        if self.creating:
+            f.remove('status_code')
+        else:
+            f.set_enum('status_code', self.enum.WORKORDER_STATUS)
+            f.set_renderer('status_code', self.render_status_code)
+            if not self.has_perm('edit_status'):
+                f.set_readonly('status_code')
+
+        # date fields
+        f.set_type('date_submitted', 'date_jquery')
+        f.set_type('date_received', 'date_jquery')
+        f.set_type('date_released', 'date_jquery')
+        f.set_type('date_delivered', 'date_jquery')
+        if self.creating:
+            f.remove('date_submitted',
+                     'date_received',
+                     'date_released',
+                     'date_delivered')
+        elif not self.has_perm('edit_status'):
+            f.set_readonly('date_submitted')
+            f.set_readonly('date_received')
+            f.set_readonly('date_released')
+            f.set_readonly('date_delivered')
+
+    def objectify(self, form, data=None):
+        """
+        Supplements the default logic as follows:
+
+        If creating a new Work Order, will automatically set its status to
+        "submitted" and its ``date_submitted`` to the current date.
+        """
+        if data is None:
+            data = form.validated
+
+        # first let deform do its thing.  if editing, this will update
+        # the record like we want.  but if creating, this will
+        # populate the initial object *without* adding it to session,
+        # which is also what we want, so that we can "replace" the new
+        # object with one the handler creates, below
+        workorder = form.schema.objectify(data, context=form.model_instance)
+
+        if self.creating:
+
+            # now make the "real" work order
+            data = dict([(key, getattr(workorder, key))
+                         for key in data])
+            workorder = self.workorder_handler.make_workorder(self.Session(), **data)
+
+        return workorder
+
+    def render_status_code(self, obj, field):
+        status_code = getattr(obj, field)
+        if status_code is None:
+            return ""
+        if status_code in self.enum.WORKORDER_STATUS:
+            text = self.enum.WORKORDER_STATUS[status_code]
+            if status_code == self.enum.WORKORDER_STATUS_CANCELED:
+                return HTML.tag('span', class_='has-text-danger', c=text)
+            return text
+        return str(status_code)
+
+    def get_row_data(self, workorder):
+        model = self.model
+        return self.Session.query(model.WorkOrderEvent)\
+                           .filter(model.WorkOrderEvent.workorder == workorder)
+
+    def get_parent(self, event):
+        return event.workorder
+
+    def configure_row_grid(self, g):
+        super().configure_row_grid(g)
+        g.set_enum('type_code', self.enum.WORKORDER_EVENT)
+        g.set_sort_defaults('occurred')
+
+    def receive(self):
+        """
+        Sets work order status to "received".
+        """
+        workorder = self.get_instance()
+        self.workorder_handler.receive(workorder)
+        self.Session.flush()
+        return self.redirect(self.get_action_url('view', workorder))
+
+    def await_estimate(self):
+        """
+        Sets work order status to "awaiting estimate confirmation".
+        """
+        workorder = self.get_instance()
+        self.workorder_handler.await_estimate(workorder)
+        self.Session.flush()
+        return self.redirect(self.get_action_url('view', workorder))
+
+    def await_parts(self):
+        """
+        Sets work order status to "awaiting parts".
+        """
+        workorder = self.get_instance()
+        self.workorder_handler.await_parts(workorder)
+        self.Session.flush()
+        return self.redirect(self.get_action_url('view', workorder))
+
+    def work_on_it(self):
+        """
+        Sets work order status to "working on it".
+        """
+        workorder = self.get_instance()
+        self.workorder_handler.work_on_it(workorder)
+        self.Session.flush()
+        return self.redirect(self.get_action_url('view', workorder))
+
+    def release(self):
+        """
+        Sets work order status to "released".
+        """
+        workorder = self.get_instance()
+        self.workorder_handler.release(workorder)
+        self.Session.flush()
+        return self.redirect(self.get_action_url('view', workorder))
+
+    def deliver(self):
+        """
+        Sets work order status to "delivered".
+        """
+        workorder = self.get_instance()
+        self.workorder_handler.deliver(workorder)
+        self.Session.flush()
+        return self.redirect(self.get_action_url('view', workorder))
+
+    def cancel(self):
+        """
+        Sets work order status to "canceled".
+        """
+        workorder = self.get_instance()
+        self.workorder_handler.cancel(workorder)
+        self.Session.flush()
+        return self.redirect(self.get_action_url('view', workorder))
+
+    @classmethod
+    def defaults(cls, config):
+        cls._defaults(config)
+        cls._workorder_defaults(config)
+
+    @classmethod
+    def _workorder_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        instance_url_prefix = cls.get_instance_url_prefix()
+        model_title = cls.get_model_title()
+
+        # perm for editing status
+        config.add_tailbone_permission(
+            permission_prefix,
+            '{}.edit_status'.format(permission_prefix),
+            "Directly edit status and related fields for {}".format(model_title))
+
+        # receive
+        config.add_route('{}.receive'.format(route_prefix),
+                         '{}/receive'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='receive',
+                        route_name='{}.receive'.format(route_prefix),
+                        permission='{}.edit'.format(permission_prefix))
+
+        # await_estimate
+        config.add_route('{}.await_estimate'.format(route_prefix),
+                         '{}/await-estimate'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='await_estimate',
+                        route_name='{}.await_estimate'.format(route_prefix),
+                        permission='{}.edit'.format(permission_prefix))
+
+        # await_parts
+        config.add_route('{}.await_parts'.format(route_prefix),
+                         '{}/await-parts'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='await_parts',
+                        route_name='{}.await_parts'.format(route_prefix),
+                        permission='{}.edit'.format(permission_prefix))
+
+        # work_on_it
+        config.add_route('{}.work_on_it'.format(route_prefix),
+                         '{}/work-on-it'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='work_on_it',
+                        route_name='{}.work_on_it'.format(route_prefix),
+                        permission='{}.edit'.format(permission_prefix))
+
+        # release
+        config.add_route('{}.release'.format(route_prefix),
+                         '{}/release'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='release',
+                        route_name='{}.release'.format(route_prefix),
+                        permission='{}.edit'.format(permission_prefix))
+
+        # deliver
+        config.add_route('{}.deliver'.format(route_prefix),
+                         '{}/deliver'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='deliver',
+                        route_name='{}.deliver'.format(route_prefix),
+                        permission='{}.edit'.format(permission_prefix))
+
+        # cancel
+        config.add_route('{}.cancel'.format(route_prefix),
+                         '{}/cancel'.format(instance_url_prefix),
+                         request_method='POST')
+        config.add_view(cls, attr='cancel',
+                        route_name='{}.cancel'.format(route_prefix),
+                        permission='{}.edit'.format(permission_prefix))
+
+
+class StatusFilter(grids.filters.AlchemyIntegerFilter):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        from drild import enum
+
+        self.active_status_codes = [
+            # enum.WORKORDER_STATUS_CREATED,
+            enum.WORKORDER_STATUS_SUBMITTED,
+            enum.WORKORDER_STATUS_RECEIVED,
+            enum.WORKORDER_STATUS_PENDING_ESTIMATE,
+            enum.WORKORDER_STATUS_WAITING_FOR_PARTS,
+            enum.WORKORDER_STATUS_WORKING_ON_IT,
+            enum.WORKORDER_STATUS_RELEASED,
+        ]
+
+    @property
+    def verb_labels(self):
+        labels = dict(super().verb_labels)
+        labels['is_active'] = "Is Active"
+        labels['not_active'] = "Is Not Active"
+        return labels
+
+    @property
+    def valueless_verbs(self):
+        verbs = list(super().valueless_verbs)
+        verbs.extend([
+            'is_active',
+            'not_active',
+        ])
+        return verbs
+
+    @property
+    def default_verbs(self):
+        verbs = super().default_verbs
+        if callable(verbs):
+            verbs = verbs()
+
+        verbs = list(verbs or [])
+        verbs.insert(0, 'is_active')
+        verbs.insert(1, 'not_active')
+        return verbs
+
+    def filter_is_active(self, query, value):
+        return query.filter(
+            WorkOrder.status_code.in_(self.active_status_codes))
+
+    def filter_not_active(self, query, value):
+        return query.filter(sa.or_(
+            ~WorkOrder.status_code.in_(self.active_status_codes),
+            WorkOrder.status_code == None,
+        ))
+
+
+def defaults(config, **kwargs):
+    base = globals()
+
+    WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView'])
+    WorkOrderView.defaults(config)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
new file mode 100644
index 00000000..bd96bd4d
--- /dev/null
+++ b/tailbone/views/wutta/people.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Person Views
+"""
+
+import colander
+import sqlalchemy as sa
+from webhelpers2.html import HTML
+
+from wuttaweb.views import people as wutta
+from tailbone.views import people as tailbone
+from tailbone.db import Session
+from rattail.db.model import Person
+from tailbone.grids import Grid
+
+
+class PersonView(wutta.PersonView):
+    """
+    This is the first attempt at blending newer Wutta views with
+    legacy Tailbone config.
+
+    So, this is a Wutta-based view but it should be included by a
+    Tailbone app configurator.
+    """
+    model_class = Person
+    Session = Session
+
+    labels = {
+        'display_name': "Full Name",
+    }
+
+    grid_columns = [
+        'display_name',
+        'first_name',
+        'last_name',
+        'phone',
+        'email',
+        'merge_requested',
+    ]
+
+    filter_defaults = {
+        'display_name': {'active': True, 'verb': 'contains'},
+    }
+    sort_defaults = 'display_name'
+
+    form_fields = [
+        'first_name',
+        'middle_name',
+        'last_name',
+        'display_name',
+        'phone',
+        'email',
+        # TODO
+        # 'address',
+    ]
+
+    ##############################
+    # CRUD methods
+    ##############################
+
+    # TODO: must use older grid for now, to render filters correctly
+    def make_grid(self, **kwargs):
+        """ """
+        return Grid(self.request, **kwargs)
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+
+        # display_name
+        g.set_link('display_name')
+
+        # merge_requested
+        g.set_label('merge_requested', "MR")
+        g.set_renderer('merge_requested', self.render_merge_requested)
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+
+        # email
+        if self.creating or self.editing:
+            f.remove('email')
+        else:
+            # nb. avoid colanderalchemy
+            f.set_node('email', colander.String())
+
+        # phone
+        if self.creating or self.editing:
+            f.remove('phone')
+        else:
+            # nb. avoid colanderalchemy
+            f.set_node('phone', colander.String())
+
+    ##############################
+    # support methods
+    ##############################
+
+    def render_merge_requested(self, person, key, value, session=None):
+        """ """
+        model = self.app.model
+        session = session or self.Session()
+        merge_request = session.query(model.MergePeopleRequest)\
+                               .filter(sa.or_(
+                                   model.MergePeopleRequest.removing_uuid == person.uuid,
+                                   model.MergePeopleRequest.keeping_uuid == person.uuid))\
+                               .filter(model.MergePeopleRequest.merged == None)\
+                               .first()
+        if merge_request:
+            return HTML.tag('span',
+                            class_='has-text-danger has-text-weight-bold',
+                            title="A merge has been requested for this person.",
+                            c="MR")
+
+
+def defaults(config, **kwargs):
+    kwargs.setdefault('PersonView', PersonView)
+    tailbone.defaults(config, **kwargs)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/views/wutta/users.py b/tailbone/views/wutta/users.py
new file mode 100644
index 00000000..3c3f8d52
--- /dev/null
+++ b/tailbone/views/wutta/users.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+User Views
+"""
+
+from wuttaweb.views import users as wutta
+from tailbone.views import users as tailbone
+from tailbone.db import Session
+from rattail.db.model import User
+from tailbone.grids import Grid
+
+
+class UserView(wutta.UserView):
+    """
+    This is the first attempt at blending newer Wutta views with
+    legacy Tailbone config.
+
+    So, this is a Wutta-based view but it should be included by a
+    Tailbone app configurator.
+    """
+    model_class = User
+    Session = Session
+
+    # TODO: must use older grid for now, to render filters correctly
+    def make_grid(self, **kwargs):
+        """ """
+        return Grid(self.request, **kwargs)
+
+
+def defaults(config, **kwargs):
+    kwargs.setdefault('UserView', UserView)
+    tailbone.defaults(config, **kwargs)
+
+
+def includeme(config):
+    defaults(config)
diff --git a/tailbone/webapi.py b/tailbone/webapi.py
new file mode 100644
index 00000000..d0edb412
--- /dev/null
+++ b/tailbone/webapi.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail is free software: you can redistribute it and/or modify it under the
+#  terms of the GNU General Public License as published by the Free Software
+#  Foundation, either version 3 of the License, or (at your option) any later
+#  version.
+#
+#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+#  details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Tailbone Web API
+"""
+
+import simplejson
+
+from cornice.renderer import CorniceRenderer
+from pyramid.config import Configurator
+
+from tailbone import app
+from tailbone.auth import TailboneSecurityPolicy
+from tailbone.providers import get_all_providers
+
+
+def make_rattail_config(settings):
+    """
+    Make a Rattail config object from the given settings.
+    """
+    rattail_config = app.make_rattail_config(settings)
+    return rattail_config
+
+
+def make_pyramid_config(settings):
+    """
+    Make a Pyramid config object from the given settings.
+    """
+    rattail_config = settings['rattail_config']
+    pyramid_config = Configurator(settings=settings, root_factory=app.Root)
+
+    # configure user authorization / authentication
+    pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True))
+
+    # always require CSRF token protection
+    pyramid_config.set_default_csrf_options(require_csrf=True,
+                                            token='_csrf',
+                                            header='X-XSRF-TOKEN')
+
+    # bring in some Pyramid goodies
+    pyramid_config.include('tailbone.beaker')
+    pyramid_config.include('pyramid_tm')
+    pyramid_config.include('cornice')
+
+    # use simplejson to serialize cornice view context; cf.
+    # https://cornice.readthedocs.io/en/latest/upgrading.html#x-to-5-x
+    # https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/renderers.html
+    json_renderer = CorniceRenderer(serializer=simplejson.dumps)
+    pyramid_config.add_renderer('cornicejson', json_renderer)
+
+    # bring in the pyramid_retry logic, if available
+    # TODO: pretty soon we can require this package, hopefully..
+    try:
+        import pyramid_retry
+    except ImportError:
+        pass
+    else:
+        pyramid_config.include('pyramid_retry')
+
+    # fetch all tailbone providers
+    providers = get_all_providers(rattail_config)
+    for provider in providers.values():
+
+        # configure DB sessions associated with transaction manager
+        provider.configure_db_sessions(rattail_config, pyramid_config)
+
+    # add some permissions magic
+    pyramid_config.add_directive('add_wutta_permission_group',
+                                 'wuttaweb.auth.add_permission_group')
+    pyramid_config.add_directive('add_wutta_permission',
+                                 'wuttaweb.auth.add_permission')
+    # TODO: deprecate / remove these
+    pyramid_config.add_directive('add_tailbone_permission_group',
+                                 'wuttaweb.auth.add_permission_group')
+    pyramid_config.add_directive('add_tailbone_permission',
+                                 'wuttaweb.auth.add_permission')
+
+    return pyramid_config
+
+
+def main(global_config, views='tailbone.api', **settings):
+    """
+    This function returns a Pyramid WSGI application.
+    """
+    rattail_config = make_rattail_config(settings)
+    pyramid_config = make_pyramid_config(settings)
+
+    # event hooks
+    pyramid_config.add_subscriber('tailbone.subscribers.new_request',
+                                  'pyramid.events.NewRequest')
+    # TODO: is this really needed?
+    pyramid_config.add_subscriber('tailbone.subscribers.context_found',
+                                  'pyramid.events.ContextFound')
+
+    # views
+    pyramid_config.include(views)
+
+    return pyramid_config.make_wsgi_app()
diff --git a/tasks.py b/tasks.py
index e2ba5670..6983dbea 100644
--- a/tasks.py
+++ b/tasks.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2018 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,26 +24,25 @@
 Tasks for Tailbone
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 import shutil
 
 from invoke import task
 
 
-here = os.path.abspath(os.path.dirname(__file__))
-exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
-
-
 @task
-def release(ctx, skip_tests=False):
+def release(c, skip_tests=False):
     """
     Release a new version of 'Tailbone'.
     """
     if not skip_tests:
-        ctx.run('tox')
+        c.run('pytest')
 
-    shutil.rmtree('Tailbone.egg-info')
-    ctx.run('python setup.py sdist --formats=gztar')
-    ctx.run('twine upload dist/Tailbone-{}.tar.gz'.format(__version__))
+    if os.path.exists('dist'):
+        shutil.rmtree('dist')
+    if os.path.exists('Tailbone.egg-info'):
+        shutil.rmtree('Tailbone.egg-info')
+
+    c.run('python -m build --sdist')
+
+    c.run('twine upload dist/*')
diff --git a/tests/__init__.py b/tests/__init__.py
index 7dec63f0..40d8071f 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -12,9 +12,6 @@ class TestCase(unittest.TestCase):
 
     def setUp(self):
         self.config = testing.setUp()
-        # TODO: this probably shouldn't (need to) be here
-        self.config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-        self.config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
 
     def tearDown(self):
         testing.tearDown()
diff --git a/tests/fixtures.py b/tests/fixtures.py
deleted file mode 100644
index a07825fd..00000000
--- a/tests/fixtures.py
+++ /dev/null
@@ -1,28 +0,0 @@
-
-import fixture
-
-from rattail.db import model
-
-
-class DepartmentData(fixture.DataSet):
-
-    class grocery:
-        number = 1
-        name = 'Grocery'
-
-    class supplements:
-        number = 2
-        name = 'Supplements'
-
-
-def load_fixtures(engine):
-
-    dbfixture = fixture.SQLAlchemyFixture(
-        env={
-            'DepartmentData': model.Department,
-            },
-        engine=engine)
-
-    data = dbfixture.data(DepartmentData)
-
-    data.setup()
diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py
new file mode 100644
index 00000000..894d2302
--- /dev/null
+++ b/tests/forms/test_core.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+import deform
+from pyramid import testing
+
+from tailbone.forms import core as mod
+from tests.util import WebTestCase
+
+
+class TestForm(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+
+    def make_form(self, **kwargs):
+        kwargs.setdefault('request', self.request)
+        return mod.Form(**kwargs)
+
+    def test_basic(self):
+        form = self.make_form()
+        self.assertIsInstance(form, mod.Form)
+
+    def test_vue_tagname(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.vue_tagname, 'tailbone-form')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.vue_tagname, 'something-else')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.vue_tagname, 'legacy-name')
+
+    def test_vue_component(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.vue_component, 'TailboneForm')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.vue_component, 'SomethingElse')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.vue_component, 'LegacyName')
+
+    def test_component(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.component, 'tailbone-form')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.component, 'something-else')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.component, 'legacy-name')
+
+    def test_component_studly(self):
+
+        # default
+        form = self.make_form()
+        self.assertEqual(form.component_studly, 'TailboneForm')
+
+        # can override with param
+        form = self.make_form(vue_tagname='something-else')
+        self.assertEqual(form.component_studly, 'SomethingElse')
+
+        # can still pass old param
+        form = self.make_form(component='legacy-name')
+        self.assertEqual(form.component_studly, 'LegacyName')
+
+    def test_button_label_submit(self):
+        form = self.make_form()
+
+        # default
+        self.assertEqual(form.button_label_submit, "Submit")
+
+        # can set submit_label
+        with patch.object(form, 'submit_label', new="Submit Label", create=True):
+            self.assertEqual(form.button_label_submit, "Submit Label")
+
+        # can set save_label
+        with patch.object(form, 'save_label', new="Save Label"):
+            self.assertEqual(form.button_label_submit, "Save Label")
+
+        # can set button_label_submit
+        form.button_label_submit = "New Label"
+        self.assertEqual(form.button_label_submit, "New Label")
+
+    def test_get_deform(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        dform = form.get_deform()
+        self.assertIsInstance(dform, deform.Form)
+
+    def test_render_vue_tag(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_tag()
+        self.assertIn('<tailbone-form', html)
+
+    def test_render_vue_template(self):
+        self.pyramid_config.include('tailbone.views.common')
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_template(session=self.session)
+        self.assertIn('<form ', html)
+
+    def test_get_vue_field_value(self):
+        model = self.app.model
+        form = self.make_form(model_class=model.Setting)
+
+        # TODO: yikes what a hack (?)
+        dform = form.get_deform()
+        dform.set_appstruct({'name': 'foo', 'value': 'bar'})
+
+        # null for missing field
+        value = form.get_vue_field_value('doesnotexist')
+        self.assertIsNone(value)
+
+        # normal value is returned
+        value = form.get_vue_field_value('name')
+        self.assertEqual(value, 'foo')
+
+        # but not if we remove field from deform
+        # TODO: what is the use case here again?
+        dform.children.remove(dform['name'])
+        value = form.get_vue_field_value('name')
+        self.assertIsNone(value)
+
+    def test_render_vue_field(self):
+        model = self.app.model
+
+        # sanity check
+        form = self.make_form(model_class=model.Setting)
+        html = form.render_vue_field('name', session=self.session)
+        self.assertIn('<b-field ', html)
diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
new file mode 100644
index 00000000..4d143c85
--- /dev/null
+++ b/tests/grids/test_core.py
@@ -0,0 +1,579 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock, patch
+
+from sqlalchemy import orm
+
+from tailbone.grids import core as mod
+from tests.util import WebTestCase
+
+
+class TestGrid(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+
+    def make_grid(self, key=None, data=[], **kwargs):
+        return mod.Grid(self.request, key=key, data=data, **kwargs)
+
+    def test_basic(self):
+        grid = self.make_grid('foo')
+        self.assertIsInstance(grid, mod.Grid)
+
+    def test_deprecated_params(self):
+
+        # component
+        grid = self.make_grid()
+        self.assertEqual(grid.vue_tagname, 'tailbone-grid')
+        grid = self.make_grid(component='blarg')
+        self.assertEqual(grid.vue_tagname, 'blarg')
+
+        # default_sortkey, default_sortdir
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        grid = self.make_grid(default_sortkey='name')
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
+        grid = self.make_grid(default_sortdir='desc')
+        self.assertEqual(grid.sort_defaults, [])
+        grid = self.make_grid(default_sortkey='name', default_sortdir='desc')
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
+
+        # pageable
+        grid = self.make_grid()
+        self.assertFalse(grid.paginated)
+        grid = self.make_grid(pageable=True)
+        self.assertTrue(grid.paginated)
+
+        # default_pagesize
+        grid = self.make_grid()
+        self.assertEqual(grid.pagesize, 20)
+        grid = self.make_grid(default_pagesize=15)
+        self.assertEqual(grid.pagesize, 15)
+
+        # default_page
+        grid = self.make_grid()
+        self.assertEqual(grid.page, 1)
+        grid = self.make_grid(default_page=42)
+        self.assertEqual(grid.page, 42)
+
+        # searchable
+        grid = self.make_grid()
+        self.assertEqual(grid.searchable_columns, set())
+        grid = self.make_grid(searchable={'foo': True})
+        self.assertEqual(grid.searchable_columns, {'foo'})
+
+    def test_vue_tagname(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.vue_tagname, 'tailbone-grid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.vue_tagname, 'something-else')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.vue_tagname, 'legacy-name')
+
+    def test_vue_component(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.vue_component, 'TailboneGrid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.vue_component, 'SomethingElse')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.vue_component, 'LegacyName')
+
+    def test_component(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.component, 'tailbone-grid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.component, 'something-else')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.component, 'legacy-name')
+
+    def test_component_studly(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.component_studly, 'TailboneGrid')
+
+        # can override with param
+        grid = self.make_grid('foo', vue_tagname='something-else')
+        self.assertEqual(grid.component_studly, 'SomethingElse')
+
+        # can still pass old param
+        grid = self.make_grid('foo', component='legacy-name')
+        self.assertEqual(grid.component_studly, 'LegacyName')
+
+    def test_actions(self):
+
+        # default
+        grid = self.make_grid('foo')
+        self.assertEqual(grid.actions, [])
+
+        # main actions
+        grid = self.make_grid('foo', main_actions=['foo'])
+        self.assertEqual(grid.actions, ['foo'])
+
+        # more actions
+        grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar'])
+        self.assertEqual(grid.actions, ['foo', 'bar'])
+
+    def test_set_label(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting, filterable=True)
+        self.assertEqual(grid.labels, {})
+
+        # basic
+        grid.set_label('name', "NAME COL")
+        self.assertEqual(grid.labels['name'], "NAME COL")
+
+        # can replace label
+        grid.set_label('name', "Different")
+        self.assertEqual(grid.labels['name'], "Different")
+        self.assertEqual(grid.get_label('name'), "Different")
+
+        # can update only column, not filter
+        self.assertEqual(grid.labels, {'name': "Different"})
+        self.assertIn('name', grid.filters)
+        self.assertEqual(grid.filters['name'].label, "Different")
+        grid.set_label('name', "COLUMN ONLY", column_only=True)
+        self.assertEqual(grid.get_label('name'), "COLUMN ONLY")
+        self.assertEqual(grid.filters['name'].label, "Different")
+
+    def test_get_view_click_handler(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting)
+
+        grid.actions.append(
+            mod.GridAction(self.request, 'view',
+                           click_handler='clickHandler(props.row)'))
+
+        handler = grid.get_view_click_handler()
+        self.assertEqual(handler, 'clickHandler(props.row)')
+
+    def test_set_action_urls(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting)
+
+        grid.actions.append(
+            mod.GridAction(self.request, 'view', url='/blarg'))
+
+        setting = {'name': 'foo', 'value': 'bar'}
+        grid.set_action_urls(setting, setting, 0)
+        self.assertEqual(setting['_action_url_view'], '/blarg')
+
+    def test_default_sortkey(self):
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertIsNone(grid.default_sortkey)
+        grid.default_sortkey = 'name'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
+        self.assertEqual(grid.default_sortkey, 'name')
+        grid.default_sortkey = 'value'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
+        self.assertEqual(grid.default_sortkey, 'value')
+
+    def test_default_sortdir(self):
+        grid = self.make_grid()
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertIsNone(grid.default_sortdir)
+        self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc')
+        grid.sort_defaults = [mod.SortInfo('name', 'asc')]
+        grid.default_sortdir = 'desc'
+        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
+        self.assertEqual(grid.default_sortdir, 'desc')
+
+    def test_pageable(self):
+        grid = self.make_grid()
+        self.assertFalse(grid.paginated)
+        grid.pageable = True
+        self.assertTrue(grid.paginated)
+        grid.paginated = False
+        self.assertFalse(grid.pageable)
+
+    def test_get_pagesize_options(self):
+        grid = self.make_grid()
+
+        # default
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [5, 10, 20, 50, 100, 200])
+
+        # override default
+        options = grid.get_pagesize_options(default=[42])
+        self.assertEqual(options, [42])
+
+        # from legacy config
+        self.config.setdefault('tailbone.grid.pagesize_options', '1 2 3')
+        grid = self.make_grid()
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [1, 2, 3])
+
+        # from new config
+        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '4, 5, 6')
+        grid = self.make_grid()
+        options = grid.get_pagesize_options()
+        self.assertEqual(options, [4, 5, 6])
+
+    def test_get_pagesize(self):
+        grid = self.make_grid()
+
+        # default
+        size = grid.get_pagesize()
+        self.assertEqual(size, 20)
+
+        # override default
+        size = grid.get_pagesize(default=42)
+        self.assertEqual(size, 42)
+
+        # override default options
+        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 10)
+
+        # from legacy config
+        self.config.setdefault('tailbone.grid.default_pagesize', '12')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 12)
+
+        # from new config
+        self.config.setdefault('wuttaweb.grids.default_pagesize', '15')
+        grid = self.make_grid()
+        size = grid.get_pagesize()
+        self.assertEqual(size, 15)
+
+    def test_set_sorter(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # passing None will remove sorter
+        self.assertIn('name', grid.sorters)
+        grid.set_sorter('name', None)
+        self.assertNotIn('name', grid.sorters)
+
+        # can recreate sorter with just column name
+        grid.set_sorter('name')
+        self.assertIn('name', grid.sorters)
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', 'name')
+        self.assertIn('name', grid.sorters)
+
+        # can recreate sorter with model property
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', model.Setting.name)
+        self.assertIn('name', grid.sorters)
+
+        # extra kwargs are ignored
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        grid.set_sorter('name', model.Setting.name, foo='bar')
+        self.assertIn('name', grid.sorters)
+
+        # passing multiple args will invoke make_filter() directly
+        grid.remove_sorter('name')
+        self.assertNotIn('name', grid.sorters)
+        with patch.object(grid, 'make_sorter') as make_sorter:
+            make_sorter.return_value = 42
+            grid.set_sorter('name', 'foo', 'bar')
+            make_sorter.assert_called_once_with('foo', 'bar')
+            self.assertEqual(grid.sorters['name'], 42)
+
+    def test_make_simple_sorter(self):
+        model = self.app.model
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # delegates to grid.make_sorter()
+        with patch.object(grid, 'make_sorter') as make_sorter:
+            make_sorter.return_value = 42
+            sorter = grid.make_simple_sorter('name', foldcase=True)
+            make_sorter.assert_called_once_with('name', foldcase=True)
+            self.assertEqual(sorter, 42)
+
+    def test_load_settings(self):
+        model = self.app.model
+
+        # nb. first use a paging grid
+        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
+                              pagesize=20, page=1)
+
+        # settings are loaded, applied, saved
+        self.assertEqual(grid.page, 1)
+        self.assertNotIn('grid.foo.page', self.request.session)
+        self.request.GET = {'pagesize': '10', 'page': '2'}
+        grid.load_settings()
+        self.assertEqual(grid.page, 2)
+        self.assertEqual(self.request.session['grid.foo.page'], 2)
+
+        # can skip the saving step
+        self.request.GET = {'pagesize': '10', 'page': '3'}
+        grid.load_settings(store=False)
+        self.assertEqual(grid.page, 3)
+        self.assertEqual(self.request.session['grid.foo.page'], 2)
+
+        # no error for non-paginated grid
+        grid = self.make_grid(key='foo', paginated=False)
+        grid.load_settings()
+        self.assertFalse(grid.paginated)
+
+        # nb. next use a sorting grid
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # settings are loaded, applied, saved
+        self.assertEqual(grid.sort_defaults, [])
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+
+        # can skip the saving step
+        self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
+        grid.load_settings(store=False)
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+
+        # no error for non-sortable grid
+        grid = self.make_grid(key='foo', sortable=False)
+        grid.load_settings()
+        self.assertFalse(grid.sortable)
+
+        # with sort defaults
+        grid = self.make_grid(model_class=model.Setting, sortable=True,
+                              sort_on_backend=True, sort_defaults='name')
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+
+        # with multi-column sort defaults
+        grid = self.make_grid(model_class=model.Setting, sortable=True,
+                              sort_on_backend=True)
+        grid.sort_defaults = [
+            mod.SortInfo('name', 'asc'),
+            mod.SortInfo('value', 'desc'),
+        ]
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
+
+        # load settings from session when nothing is in request
+        self.request.GET = {}
+        self.request.session.invalidate()
+        self.assertNotIn('grid.settings.sorters.length', self.request.session)
+        self.request.session['grid.settings.sorters.length'] = 1
+        self.request.session['grid.settings.sorters.1.key'] = 'name'
+        self.request.session['grid.settings.sorters.1.dir'] = 'desc'
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True,
+                              paginated=True, paginate_on_backend=True)
+        self.assertFalse(hasattr(grid, 'active_sorters'))
+        grid.load_settings()
+        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
+
+    def test_persist_settings(self):
+        model = self.app.model
+
+        # nb. start out with paginated-only grid
+        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
+
+        # invalid dest
+        self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')
+
+        # nb. no error if empty settings, but it saves null values
+        grid.persist_settings({}, dest='session')
+        self.assertIsNone(self.request.session['grid.foo.page'])
+
+        # provided values are saved
+        grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
+        self.assertEqual(self.request.session['grid.foo.page'], 3)
+
+        # nb. now switch to sortable-only grid
+        grid = self.make_grid(key='settings', model_class=model.Setting,
+                              sortable=True, sort_on_backend=True)
+
+        # no error if empty settings; does not save values
+        grid.persist_settings({}, dest='session')
+        self.assertNotIn('grid.settings.sorters.length', self.request.session)
+
+        # provided values are saved
+        grid.persist_settings({'sorters.length': 2,
+                               'sorters.1.key': 'name',
+                               'sorters.1.dir': 'desc',
+                               'sorters.2.key': 'value',
+                               'sorters.2.dir': 'asc'},
+                              dest='session')
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+        self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
+        self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')
+
+        # old values removed when new are saved
+        grid.persist_settings({'sorters.length': 1,
+                               'sorters.1.key': 'name',
+                               'sorters.1.dir': 'desc'},
+                              dest='session')
+        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
+        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
+        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
+        self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
+        self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)
+
+    def test_sort_data(self):
+        model = self.app.model
+        sample_data = [
+            {'name': 'foo1', 'value': 'ONE'},
+            {'name': 'foo2', 'value': 'two'},
+            {'name': 'foo3', 'value': 'ggg'},
+            {'name': 'foo4', 'value': 'ggg'},
+            {'name': 'foo5', 'value': 'ggg'},
+            {'name': 'foo6', 'value': 'six'},
+            {'name': 'foo7', 'value': 'seven'},
+            {'name': 'foo8', 'value': 'eight'},
+            {'name': 'foo9', 'value': 'nine'},
+        ]
+        for setting in sample_data:
+            self.app.save_setting(self.session, setting['name'], setting['value'])
+        self.session.commit()
+        sample_query = self.session.query(model.Setting)
+
+        grid = self.make_grid(model_class=model.Setting,
+                              sortable=True, sort_on_backend=True,
+                              sort_defaults=('name', 'desc'))
+        grid.load_settings()
+
+        # can sort a simple list of data
+        sorted_data = grid.sort_data(sample_data)
+        self.assertIsInstance(sorted_data, list)
+        self.assertEqual(len(sorted_data), 9)
+        self.assertEqual(sorted_data[0]['name'], 'foo9')
+        self.assertEqual(sorted_data[-1]['name'], 'foo1')
+
+        # can also sort a data query
+        sorted_query = grid.sort_data(sample_query)
+        self.assertIsInstance(sorted_query, orm.Query)
+        sorted_data = sorted_query.all()
+        self.assertEqual(len(sorted_data), 9)
+        self.assertEqual(sorted_data[0]['name'], 'foo9')
+        self.assertEqual(sorted_data[-1]['name'], 'foo1')
+
+        # cannot sort data if sorter missing in overrides
+        sorted_data = grid.sort_data(sample_data, sorters=[])
+        # nb. sorted data is in same order as original sample (not sorted)
+        self.assertEqual(sorted_data[0]['name'], 'foo1')
+        self.assertEqual(sorted_data[-1]['name'], 'foo9')
+
+        # multi-column sorting for list data
+        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                           {'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
+        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
+
+        # multi-column sorting for query
+        sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                             {'key': 'name', 'dir': 'asc'}])
+        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
+        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
+        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
+
+        # cannot sort data if sortfunc is missing for column
+        grid.remove_sorter('name')
+        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
+                                                           {'key': 'name', 'dir': 'asc'}])
+        # nb. sorted data is in same order as original sample (not sorted)
+        self.assertEqual(sorted_data[0]['name'], 'foo1')
+        self.assertEqual(sorted_data[-1]['name'], 'foo9')
+
+    def test_render_vue_tag(self):
+        model = self.app.model
+
+        # standard
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_tag()
+        self.assertIn('<tailbone-grid', html)
+        self.assertNotIn('@deleteActionClicked', html)
+
+        # with delete hook
+        master = MagicMock(deletable=True, delete_confirm='simple')
+        master.has_perm.return_value = True
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_tag(master=master)
+        self.assertIn('<tailbone-grid', html)
+        self.assertIn('@deleteActionClicked', html)
+
+    def test_render_vue_template(self):
+        # self.pyramid_config.include('tailbone.views.common')
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting)
+        html = grid.render_vue_template(session=self.session)
+        self.assertIn('<b-table', html)
+
+    def test_get_vue_columns(self):
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting, sortable=True)
+        columns = grid.get_vue_columns()
+        self.assertEqual(len(columns), 2)
+        self.assertEqual(columns[0]['field'], 'name')
+        self.assertTrue(columns[0]['sortable'])
+        self.assertEqual(columns[1]['field'], 'value')
+        self.assertTrue(columns[1]['sortable'])
+
+    def test_get_vue_data(self):
+        model = self.app.model
+
+        # sanity check
+        grid = self.make_grid('settings', model_class=model.Setting)
+        data = grid.get_vue_data()
+        self.assertEqual(data, [])
+
+        # calling again returns same data
+        data2 = grid.get_vue_data()
+        self.assertIs(data2, data)
+
+
+class TestGridAction(WebTestCase):
+
+    def test_constructor(self):
+
+        # null by default
+        action = mod.GridAction(self.request, 'view')
+        self.assertIsNone(action.target)
+        self.assertIsNone(action.click_handler)
+
+        # but can set them
+        action = mod.GridAction(self.request, 'view',
+                                target='_blank',
+                                click_handler='doSomething(props.row)')
+        self.assertEqual(action.target, '_blank')
+        self.assertEqual(action.click_handler, 'doSomething(props.row)')
diff --git a/tests/test_app.py b/tests/test_app.py
index 6434aa0e..f49f6b13 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,18 +1,13 @@
-# -*- coding: utf-8 -*-
-
-from __future__ import unicode_literals, absolute_import
+# -*- coding: utf-8; -*-
 
 import os
 from unittest import TestCase
 
-from sqlalchemy import create_engine
+from pyramid.config import Configurator
 
-from rattail.config import RattailConfig
 from rattail.exceptions import ConfigurationError
-from rattail.db import Session as RattailSession
-
-from tailbone import app
-from tailbone.db import Session as TailboneSession
+from rattail.testing import DataTestCase
+from tailbone import app as mod
 
 
 class TestRattailConfig(TestCase):
@@ -20,13 +15,34 @@ class TestRattailConfig(TestCase):
     config_path = os.path.abspath(
         os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf'))
 
-    def tearDown(self):
-        # may or may not be necessary depending on test
-        TailboneSession.remove()
-
     def test_settings_arg_must_include_config_path_by_default(self):
         # error raised if path not provided
-        self.assertRaises(ConfigurationError, app.make_rattail_config, {})
+        self.assertRaises(ConfigurationError, mod.make_rattail_config, {})
         # get a config object if path provided
-        result = app.make_rattail_config({'rattail.config': self.config_path})
-        self.assertTrue(isinstance(result, RattailConfig))
+        result = mod.make_rattail_config({'rattail.config': self.config_path})
+        # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper!
+        self.assertIsNotNone(result)
+        self.assertTrue(hasattr(result, 'get'))
+
+
+class TestMakePyramidConfig(DataTestCase):
+
+    def make_config(self, **kwargs):
+        myconf = self.write_file('web.conf', """
+[rattail.db]
+default.url = sqlite://
+""")
+
+        self.settings = {
+            'rattail.config': myconf,
+            'mako.directories': 'tailbone:templates',
+        }
+        return mod.make_rattail_config(self.settings)
+
+    def test_basic(self):
+        model = self.app.model
+        model.Base.metadata.create_all(bind=self.config.appdb_engine)
+
+        # sanity check
+        pyramid_config = mod.make_pyramid_config(self.settings)
+        self.assertIsInstance(pyramid_config, Configurator)
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 00000000..4519e152
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8; -*-
+
+from tailbone import auth as mod
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 00000000..0cd1938c
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8; -*-
+
+from tailbone import config as mod
+from tests.util import DataTestCase
+
+
+class TestConfigExtension(DataTestCase):
+
+    def test_basic(self):
+        # sanity / coverage check
+        ext = mod.ConfigExtension()
+        ext.configure(self.config)
diff --git a/tests/test_db.py b/tests/test_db.py
new file mode 100644
index 00000000..88cb9d41
--- /dev/null
+++ b/tests/test_db.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8; -*-
+
+# TODO: add real tests at some point but this at least gives us basic
+# coverage when running this "test" module alone
+
+from tailbone import db
+
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
new file mode 100644
index 00000000..81bc2869
--- /dev/null
+++ b/tests/test_subscribers.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from tailbone import subscribers as mod
+from tests.util import DataTestCase
+
+
+class TestNewRequest(DataTestCase):
+
+    def setUp(self):
+        self.setup_db()
+        self.request = self.make_request()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+        })
+
+    def tearDown(self):
+        self.teardown_db()
+        testing.tearDown()
+
+    def make_request(self, **kwargs):
+        return testing.DummyRequest(**kwargs)
+
+    def make_event(self):
+        return MagicMock(request=self.request)
+
+    def test_continuum_remote_addr(self):
+        event = self.make_event()
+
+        # nothing happens
+        mod.new_request(event, session=self.session)
+        self.assertFalse(hasattr(self.session, 'continuum_remote_addr'))
+
+        # unless request has client_addr
+        self.request.client_addr = '127.0.0.1'
+        mod.new_request(event, session=self.session)
+        self.assertEqual(self.session.continuum_remote_addr, '127.0.0.1')
+
+    def test_register_component(self):
+        event = self.make_event()
+
+        # function added
+        self.assertFalse(hasattr(self.request, 'register_component'))
+        mod.new_request(event, session=self.session)
+        self.assertTrue(callable(self.request.register_component))
+
+        # call function
+        self.request.register_component('tailbone-datepicker', 'TailboneDatepicker')
+        self.assertEqual(self.request._tailbone_registered_components,
+                         {'tailbone-datepicker': 'TailboneDatepicker'})
+
+        # duplicate registration ignored
+        self.request.register_component('tailbone-datepicker', 'TailboneDatepicker')
+        self.assertEqual(self.request._tailbone_registered_components,
+                         {'tailbone-datepicker': 'TailboneDatepicker'})
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 00000000..46684f0c
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+
+from pyramid import testing
+
+from rattail.config import RattailConfig
+
+from tailbone import util
+
+
+class TestGetFormData(TestCase):
+
+    def setUp(self):
+        self.config = RattailConfig()
+
+    def make_request(self, **kwargs):
+        kwargs.setdefault('wutta_config', self.config)
+        kwargs.setdefault('rattail_config', self.config)
+        kwargs.setdefault('is_xhr', None)
+        kwargs.setdefault('content_type', None)
+        kwargs.setdefault('POST', {'foo1': 'bar'})
+        kwargs.setdefault('json_body', {'foo2': 'baz'})
+        return testing.DummyRequest(**kwargs)
+
+    def test_default(self):
+        request = self.make_request()
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo1': 'bar'})
+
+    def test_is_xhr(self):
+        request = self.make_request(POST=None, is_xhr=True)
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})
+
+    def test_content_type(self):
+        request = self.make_request(POST=None, content_type='application/json')
+        data = util.get_form_data(request)
+        self.assertEqual(data, {'foo2': 'baz'})
diff --git a/tests/util.py b/tests/util.py
new file mode 100644
index 00000000..4277a7c3
--- /dev/null
+++ b/tests/util.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import MagicMock
+
+from pyramid import testing
+
+from tailbone import subscribers
+from wuttaweb.menus import MenuHandler
+# from wuttaweb.subscribers import new_request_set_user
+from rattail.testing import DataTestCase
+
+
+class WebTestCase(DataTestCase):
+    """
+    Base class for test suites requiring a full (typical) web app.
+    """
+
+    def setUp(self):
+        self.setup_web()
+
+    def setup_web(self):
+        self.setup_db()
+        self.request = self.make_request()
+        self.pyramid_config = testing.setUp(request=self.request, settings={
+            'wutta_config': self.config,
+            'rattail_config': self.config,
+            'mako.directories': ['tailbone:templates', 'wuttaweb:templates'],
+            # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
+        })
+
+        # init web
+        # self.pyramid_config.include('pyramid_deform')
+        self.pyramid_config.include('pyramid_mako')
+        self.pyramid_config.add_directive('add_wutta_permission_group',
+                                          'wuttaweb.auth.add_permission_group')
+        self.pyramid_config.add_directive('add_wutta_permission',
+                                          'wuttaweb.auth.add_permission')
+        self.pyramid_config.add_directive('add_tailbone_permission_group',
+                                          'wuttaweb.auth.add_permission_group')
+        self.pyramid_config.add_directive('add_tailbone_permission',
+                                          'wuttaweb.auth.add_permission')
+        self.pyramid_config.add_directive('add_tailbone_index_page',
+                                          'tailbone.app.add_index_page')
+        self.pyramid_config.add_directive('add_tailbone_model_view',
+                                          'tailbone.app.add_model_view')
+        self.pyramid_config.add_directive('add_tailbone_config_page',
+                                          'tailbone.app.add_config_page')
+        self.pyramid_config.add_subscriber('tailbone.subscribers.before_render',
+                                           'pyramid.events.BeforeRender')
+        self.pyramid_config.include('tailbone.static')
+
+        # setup new request w/ anonymous user
+        event = MagicMock(request=self.request)
+        subscribers.new_request(event, session=self.session)
+        # def user_getter(request, **kwargs): pass
+        # new_request_set_user(event, db_session=self.session,
+        #                      user_getter=user_getter)
+
+    def tearDown(self):
+        self.teardown_web()
+
+    def teardown_web(self):
+        testing.tearDown()
+        self.teardown_db()
+
+    def make_request(self, **kwargs):
+        kwargs.setdefault('rattail_config', self.config)
+        # kwargs.setdefault('wutta_config', self.config)
+        return testing.DummyRequest(**kwargs)
+
+
+class NullMenuHandler(MenuHandler):
+    """
+    Dummy menu handler for testing.
+    """
+    def make_menus(self, request, **kwargs):
+        return []
diff --git a/tests/views/test_autocomplete.py b/tests/views/test_autocomplete.py
deleted file mode 100644
index dc630af4..00000000
--- a/tests/views/test_autocomplete.py
+++ /dev/null
@@ -1,95 +0,0 @@
-
-from mock import Mock
-from pyramid import testing
-
-from .. import TestCase, mock_query
-from tailbone.views import autocomplete
-
-
-class BareAutocompleteViewTests(TestCase):
-
-    def view(self, **kwargs):
-        request = testing.DummyRequest(**kwargs)
-        return autocomplete.AutocompleteView(request)
-
-    def test_attributes(self):
-        view = self.view()
-        self.assertRaises(AttributeError, getattr, view, 'mapped_class')
-        self.assertRaises(AttributeError, getattr, view, 'fieldname')
-
-    def test_filter_query(self):
-        view = self.view()
-        query = Mock()
-        filtered = view.filter_query(query)
-        self.assertTrue(filtered is query)
-
-    def test_make_query(self):
-        view = self.view()
-        # No mapped_class defined for view.
-        self.assertRaises(AttributeError, view.make_query, 'test')
-
-    def test_query(self):
-        view = self.view()
-        query = Mock()
-        view.make_query = Mock(return_value=query)
-        filtered = view.query('test')
-        self.assertTrue(filtered is query)
-
-    def test_display(self):
-        view = self.view()
-        instance = Mock()
-        # No fieldname defined for view.
-        self.assertRaises(AttributeError, view.display, instance)
-
-    def test_call(self):
-        # Empty or missing query term yields empty list.
-        view = self.view(params={})
-        self.assertEqual(view(), [])
-        view = self.view(params={'term': None})
-        self.assertEqual(view(), [])
-        view = self.view(params={'term': ''})
-        self.assertEqual(view(), [])
-        view = self.view(params={'term': '\t'})
-        self.assertEqual(view(), [])
-        # No mapped_class defined for view.
-        view = self.view(params={'term': 'bogus'})
-        self.assertRaises(AttributeError, view)
-
-
-class SampleAutocompleteViewTests(TestCase):
-
-    def setUp(self):
-        super(SampleAutocompleteViewTests, self).setUp()
-        self.Session_query = autocomplete.Session.query
-        self.query = mock_query()
-        autocomplete.Session.query = self.query
-
-    def tearDown(self):
-        super(SampleAutocompleteViewTests, self).tearDown()
-        autocomplete.Session.query = self.Session_query
-
-    def view(self, **kwargs):
-        request = testing.DummyRequest(**kwargs)
-        view = autocomplete.AutocompleteView(request)
-        view.mapped_class = Mock()
-        view.fieldname = 'thing'
-        return view
-
-    def test_make_query(self):
-        view = self.view()
-        view.mapped_class.thing.ilike.return_value = 'whatever'
-        self.assertTrue(view.make_query('test') is self.query)
-        view.mapped_class.thing.ilike.assert_called_with('%test%')
-        self.query.filter.assert_called_with('whatever')
-        self.query.order_by.assert_called_with(view.mapped_class.thing)
-
-    def test_call(self):
-        self.query.all.return_value = [
-            Mock(uuid='1', thing='first'),
-            Mock(uuid='2', thing='second'),
-            ]
-        view = self.view(params={'term': 'bogus'})
-        self.assertEqual(view(), [
-                {'label': 'first', 'value': '1'},
-                {'label': 'second', 'value': '2'},
-                ])
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
new file mode 100644
index 00000000..0e459e7d
--- /dev/null
+++ b/tests/views/test_master.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import master as mod
+from wuttaweb.grids import GridAction
+from tests.util import WebTestCase
+
+
+class TestMasterView(WebTestCase):
+
+    def make_view(self):
+        return mod.MasterView(self.request)
+
+    def test_make_form_kwargs(self):
+        self.pyramid_config.add_route('settings.view', '/settings/{name}')
+        model = self.app.model
+        setting = model.Setting(name='foo', value='bar')
+        self.session.add(setting)
+        self.session.commit()
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            view = self.make_view()
+
+            # sanity / coverage check
+            kw = view.make_form_kwargs(model_instance=setting)
+            self.assertIsNotNone(kw['action_url'])
+
+    def test_make_action(self):
+        model = self.app.model
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            view = self.make_view()
+            action = view.make_action('view')
+            self.assertIsInstance(action, GridAction)
+
+    def test_index(self):
+        self.pyramid_config.include('tailbone.views.common')
+        self.pyramid_config.include('tailbone.views.auth')
+        model = self.app.model
+
+        # mimic view for /settings
+        with patch.object(mod, 'Session', return_value=self.session):
+            with patch.multiple(mod.MasterView, create=True,
+                                model_class=model.Setting,
+                                Session=MagicMock(return_value=self.session),
+                                get_index_url=MagicMock(return_value='/settings/'),
+                                get_help_url=MagicMock(return_value=None)):
+
+                # basic
+                view = self.make_view()
+                response = view.index()
+                self.assertEqual(response.status_code, 200)
+
+                # then again with data, to include view action url
+                data = [{'name': 'foo', 'value': 'bar'}]
+                with patch.object(view, 'get_data', return_value=data):
+                    response = view.index()
+                    self.assertEqual(response.status_code, 200)
+                    self.assertEqual(response.content_type, 'text/html')
+
+                    # then once more as 'partial' - aka. data only
+                    self.request.GET = {'partial': '1'}
+                    response = view.index()
+                    self.assertEqual(response.status_code, 200)
+                    self.assertEqual(response.content_type, 'application/json')
diff --git a/tests/views/test_people.py b/tests/views/test_people.py
new file mode 100644
index 00000000..f85577e7
--- /dev/null
+++ b/tests/views/test_people.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8; -*-
+
+from tailbone.views import users as mod
+from tests.util import WebTestCase
+
+
+class TestPersonView(WebTestCase):
+
+    def make_view(self):
+        return mod.PersonView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.people')
+
+    def test_includeme_wutta(self):
+        self.config.setdefault('tailbone.use_wutta_views', 'true')
+        self.pyramid_config.include('tailbone.views.people')
diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py
new file mode 100644
index 00000000..2b31531c
--- /dev/null
+++ b/tests/views/test_principal.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import principal as mod
+from tests.util import WebTestCase
+
+
+class TestPrincipalMasterView(WebTestCase):
+
+    def make_view(self):
+        return mod.PrincipalMasterView(self.request)
+
+    def test_find_by_perm(self):
+        model = self.app.model
+        self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler')
+        self.pyramid_config.include('tailbone.views.common')
+        self.pyramid_config.include('tailbone.views.auth')
+        self.pyramid_config.add_route('roles', '/roles/')
+        with patch.multiple(mod.PrincipalMasterView, create=True,
+                            model_class=model.Role,
+                            get_help_url=MagicMock(return_value=None),
+                            get_help_markdown=MagicMock(return_value=None),
+                            can_edit_help=MagicMock(return_value=False)):
+
+            # sanity / coverage check
+            view = self.make_view()
+            response = view.find_by_perm()
+            self.assertEqual(response.status_code, 200)
diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py
new file mode 100644
index 00000000..0cdc724e
--- /dev/null
+++ b/tests/views/test_roles.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from tailbone.views import roles as mod
+from tests.util import WebTestCase
+
+
+class TestRoleView(WebTestCase):
+
+    def make_view(self):
+        return mod.RoleView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.roles')
+
+    def get_permissions(self):
+        return {
+            'widgets': {
+                'label': "Widgets",
+                'perms': {
+                    'widgets.list': {
+                        'label': "List widgets",
+                    },
+                    'widgets.polish': {
+                        'label': "Polish the widgets",
+                    },
+                    'widgets.view': {
+                        'label': "View widget",
+                    },
+                },
+            },
+        }
+
+    def test_get_available_permissions(self):
+        model = self.app.model
+        auth = self.app.get_auth_handler()
+        blokes = model.Role(name="Blokes")
+        auth.grant_permission(blokes, 'widgets.list')
+        self.session.add(blokes)
+        barney = model.User(username='barney')
+        barney.roles.append(blokes)
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+        all_perms = self.get_permissions()
+        self.request.registry.settings['wutta_permissions'] = all_perms
+
+        def has_perm(perm):
+            if perm == 'widgets.list':
+                return True
+            return False
+
+        with patch.object(self.request, 'has_perm', new=has_perm, create=True):
+
+            # sanity check; current request has 1 perm
+            self.assertTrue(self.request.has_perm('widgets.list'))
+            self.assertFalse(self.request.has_perm('widgets.polish'))
+            self.assertFalse(self.request.has_perm('widgets.view'))
+
+            # when editing, user sees only the 1 perm
+            with patch.object(view, 'editing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']), ['widgets.list'])
+
+            # but when viewing, same user sees all perms
+            with patch.object(view, 'viewing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']),
+                                 ['widgets.list', 'widgets.polish', 'widgets.view'])
+
+            # also, when admin user is editing, sees all perms
+            self.request.is_admin = True
+            with patch.object(view, 'editing', new=True):
+                perms = view.get_available_permissions()
+                self.assertEqual(list(perms), ['widgets'])
+                self.assertEqual(list(perms['widgets']['perms']),
+                                 ['widgets.list', 'widgets.polish', 'widgets.view'])
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
new file mode 100644
index 00000000..b8523729
--- /dev/null
+++ b/tests/views/test_settings.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8; -*-
+
+from tailbone.views import settings as mod
+from tests.util import WebTestCase
+
+
+class TestSettingView(WebTestCase):
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.settings')
diff --git a/tests/views/test_users.py b/tests/views/test_users.py
new file mode 100644
index 00000000..4b94caf2
--- /dev/null
+++ b/tests/views/test_users.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from tailbone.views import users as mod
+from tailbone.views.principal import PermissionsRenderer
+from tests.util import WebTestCase
+
+
+class TestUserView(WebTestCase):
+
+    def make_view(self):
+        return mod.UserView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.users')
+
+    def test_configure_form(self):
+        self.pyramid_config.include('tailbone.views.users')
+        model = self.app.model
+        barney = model.User(username='barney')
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # must use mock configure when making form
+        def configure(form): pass
+        form = view.make_form(instance=barney, configure=configure)
+
+        with patch.object(view, 'viewing', new=True):
+            self.assertNotIn('permissions', form.renderers)
+            view.configure_form(form)
+            self.assertIsInstance(form.renderers['permissions'], PermissionsRenderer)
diff --git a/tests/views/wutta/__init__.py b/tests/views/wutta/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py
new file mode 100644
index 00000000..31aeb501
--- /dev/null
+++ b/tests/views/wutta/test_people.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from sqlalchemy import orm
+
+from tailbone.views.wutta import people as mod
+from tests.util import WebTestCase
+
+
+class TestPersonView(WebTestCase):
+
+    def make_view(self):
+        return mod.PersonView(self.request)
+
+    def test_includeme(self):
+        self.pyramid_config.include('tailbone.views.wutta.people')
+
+    def test_get_query(self):
+        view = self.make_view()
+
+        # sanity / coverage check
+        query = view.get_query(session=self.session)
+        self.assertIsInstance(query, orm.Query)
+
+    def test_configure_grid(self):
+        model = self.app.model
+        barney = model.User(username='barney')
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # sanity / coverage check
+        grid = view.make_grid(model_class=model.Person)
+        self.assertNotIn('first_name', grid.linked_columns)
+        view.configure_grid(grid)
+        self.assertIn('first_name', grid.linked_columns)
+
+    def test_configure_form(self):
+        model = self.app.model
+        barney = model.Person(display_name="Barney Rubble")
+        self.session.add(barney)
+        self.session.commit()
+        view = self.make_view()
+
+        # email field remains when viewing
+        with patch.object(view, 'viewing', new=True):
+            form = view.make_form(model_instance=barney,
+                                  fields=view.get_form_fields())
+            self.assertIn('email', form.fields)
+            view.configure_form(form)
+            self.assertIn('email', form)
+
+        # email field removed when editing
+        with patch.object(view, 'editing', new=True):
+            form = view.make_form(model_instance=barney,
+                                  fields=view.get_form_fields())
+            self.assertIn('email', form.fields)
+            view.configure_form(form)
+            self.assertNotIn('email', form)
+
+    def test_render_merge_requested(self):
+        model = self.app.model
+        barney = model.Person(display_name="Barney Rubble")
+        self.session.add(barney)
+        user = model.User(username='user')
+        self.session.add(user)
+        self.session.commit()
+        view = self.make_view()
+
+        # null by default
+        html = view.render_merge_requested(barney, 'merge_requested', None,
+                                           session=self.session)
+        self.assertIsNone(html)
+
+        # unless a merge request exists
+        barney2 = model.Person(display_name="Barney Rubble")
+        self.session.add(barney2)
+        self.session.commit()
+        mr = model.MergePeopleRequest(removing_uuid=barney2.uuid,
+                                      keeping_uuid=barney.uuid,
+                                      requested_by=user)
+        self.session.add(mr)
+        self.session.commit()
+        html = view.render_merge_requested(barney, 'merge_requested', None,
+                                           session=self.session)
+        self.assertIn('<span ', html)
diff --git a/tox.ini b/tox.ini
index 1218fec2..3896befb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,40 +1,19 @@
+
 [tox]
-envlist = py27, py35
+envlist = py38, py39, py310, py311
 
 [testenv]
-deps =
-        coverage
-        fixture
-        mock
-        nose
-commands =
-        pip install --upgrade pip
-        pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon
-        nosetests {posargs}
-
-[testenv:py27]
-# TODO: this is only here to avoid latest SA-Utils on python2.7
-deps =
-        coverage
-        fixture
-        mock
-        nose
-        SQLAlchemy-Utils<0.36.7
+deps = rattail-tempmon
+extras = tests
+commands = pytest {posargs}
 
 [testenv:coverage]
-basepython = python
-commands =
-        pip install --upgrade pip
-        pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon
-        nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage}
+basepython = python3
+extras = tests
+commands = pytest --cov=tailbone --cov-report=html
 
 [testenv:docs]
-basepython = python
-deps =
-    Sphinx
-    sphinx-rtd-theme
+basepython = python3
 changedir = docs
-commands =
-        pip install --upgrade pip
-        pip install --upgrade --upgrade-strategy eager Tailbone rattail[auth,bouncer] rattail-tempmon
-        sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs
+extras = docs
+commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs