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/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 85%
rename from CHANGES.rst
rename to docs/OLDCHANGES.rst
index ebba8d69..0a802f40 100644
--- a/CHANGES.rst
+++ b/docs/OLDCHANGES.rst
@@ -2,6 +2,1167 @@
 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)
 --------------------
 
@@ -3831,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)
@@ -4164,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
 
@@ -4178,7 +5339,7 @@ and related technologies.
 
 
 0.6.11 (2017-07-18)
-------------------
+-------------------
 
 * Tweak some basic styles for forms/grids
 
@@ -4186,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/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 b19d859f..d964086f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -44,19 +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/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 3328785e..00000000
--- a/setup.py
+++ /dev/null
@@ -1,190 +0,0 @@
-# -*- 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/>.
-#
-################################################################################
-"""
-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: previously was capping this to pre-1.0 although i'm not sure why.
-    # however the 1.2 release has some breaking changes which require refactor.
-    # cf. https://pypi.org/project/zope.sqlalchemy/#id3
-    'zope.sqlalchemy<1.2',              # 0.7                   1.1
-
-    # 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
-
-    # TODO: remove once their bug is fixed?  idk what this is about yet...
-    'deform<2.0.15',                    # 2.0.14
-
-    # TODO: cornice<5 requires pyramid<2 (see above)
-    'pyramid<2',                        # 1.3b2                 1.10.8
-
-    'asgiref',                          # 3.2.3
-    'colander',                         # 1.7.0
-    'ColanderAlchemy',                  # 0.3.3
-    'humanize',                         # 0.5.1
-    'Mako',                             # 0.6.2
-    'markdown',                         # 3.3.3
-    'openpyxl',                         # 2.4.7
-    'paginate',                         # 0.5.6
-    'paginate_sqlalchemy',              # 0.2.0
-    'passlib',                          # 1.7.1
-    'Pillow',                           # 5.3.0
-    '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
-
-        # TODO: remove version workaround after next sphinx[-rtd-theme] release
-        # cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343
-        'Sphinx!=5.2.0.post0',          # 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
-        'pytest',                       # 4.6.11
-        'pytest-cov',                   # 2.12.1
-    ],
-}
-
-
-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',
-            'webapi = tailbone.webapi: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 321a1037..7095f6c8 100644
--- a/tailbone/_version.py
+++ b/tailbone/_version.py
@@ -1,3 +1,9 @@
 # -*- coding: utf-8; -*-
 
-__version__ = '0.8.267'
+try:
+    from importlib.metadata import version
+except ImportError:
+    from importlib_metadata import version
+
+
+__version__ = version('Tailbone')
diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py
index 867c15a8..a710e30d 100644
--- a/tailbone/api/auth.py
+++ b/tailbone/api/auth.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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 set_user_password
-
 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:
@@ -168,6 +163,9 @@ 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
@@ -175,7 +173,8 @@ class AuthenticationView(APIView):
             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),
diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py
index 5b6102ed..f7bc9333 100644
--- a/tailbone/api/batch/core.py
+++ b/tailbone/api/batch/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,9 @@
 Tailbone Web API - Batch Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import logging
 import warnings
 
-import six
-
 from cornice import Service
 
 from tailbone.api import APIMasterView
@@ -70,9 +66,7 @@ class APIBatchMixin(object):
         """
         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 app.load_object(spec)(self.rattail_config)
+        return app.get_batch_handler(key, default=self.default_handler_spec)
 
 
 class APIBatchView(APIBatchMixin, APIMasterView):
@@ -104,25 +98,25 @@ class APIBatchView(APIBatchMixin, APIMasterView):
 
         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),
         }
 
@@ -273,8 +267,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
         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,
@@ -285,7 +279,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
             '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):
@@ -320,7 +314,7 @@ 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()
 
@@ -332,11 +326,14 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
             log.warning("quick entry failed for '%s' batch %s: %s",
                         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
@@ -352,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 5e56fe46..22b67e54 100644
--- a/tailbone/api/batch/inventory.py
+++ b/tailbone/api/batch/inventory.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +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
 
@@ -41,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'
@@ -50,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
 
@@ -119,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'
@@ -130,23 +127,24 @@ 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.batch_handler.allow_cases(batch)
@@ -174,7 +172,17 @@ class InventoryBatchRowViews(APIBatchRowView):
                 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
 
 
diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py
index 4787aeb9..4f154b21 100644
--- a/tailbone/api/batch/labels.py
+++ b/tailbone/api/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,10 +52,10 @@ 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
diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py
index 9ab9617c..204be8ad 100644
--- a/tailbone/api/batch/ordering.py
+++ b/tailbone/api/batch/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,21 +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.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'
@@ -57,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
@@ -82,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):
@@ -94,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?
 
@@ -148,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,
@@ -169,8 +177,8 @@ 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)
 
@@ -179,6 +187,18 @@ class OrderingBatchViews(APIBatchView):
         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),
@@ -209,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'
@@ -219,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
@@ -240,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
 
@@ -269,7 +290,17 @@ class OrderingBatchRowViews(APIBatchRowView):
         if not self.batch_handler.is_mutable(row.batch):
             return {'error': "Batch is not mutable"}
 
-        self.batch_handler.update_row_quantity(row, **data)
+        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
 
 
diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index c755de65..b23bff55 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,16 +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.db import model
-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
@@ -46,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'
@@ -56,19 +54,21 @@ 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
@@ -79,9 +79,15 @@ class ReceivingBatchViews(APIBatchView):
 
     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):
         """
@@ -114,8 +120,9 @@ 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"}
 
@@ -146,31 +153,31 @@ class ReceivingBatchViews(APIBatchView):
         collection_url_prefix = cls.get_collection_url_prefix()
         object_url_prefix = cls.get_object_url_prefix()
 
-        # auto-receive
-        config.add_route('{}.auto_receive'.format(route_prefix),
-                         '{}/{{uuid}}/auto-receive'.format(object_url_prefix))
-        config.add_view(cls, attr='auto_receive',
-                        route_name='{}.auto_receive'.format(route_prefix),
-                        permission='{}.auto_receive'.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
-        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')
+        # 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'
@@ -179,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
@@ -275,21 +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(ReceivingBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
+        model = self.app.model
 
         batch = row.batch
-        app = self.get_rattail_app()
-        prodder = app.get_products_handler()
+        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
@@ -321,6 +338,9 @@ 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
@@ -328,6 +348,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
         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
@@ -356,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 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'])
@@ -367,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'])
@@ -395,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
             data['received_alert'] = None
             if self.batch_handler.get_units_confirmed(row):
                 msg = "You have already received some of this product; last update was {}.".format(
-                    humanize.naturaltime(app.make_utc() - row.modified))
+                    humanize.naturaltime(self.app.make_utc() - row.modified))
                 data['received_alert'] = msg
 
         return data
@@ -404,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.batch_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
@@ -440,11 +471,11 @@ 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):
diff --git a/tailbone/api/common.py b/tailbone/api/common.py
index 3e96609a..6cacfb06 100644
--- a/tailbone/api/common.py
+++ b/tailbone/api/common.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,18 +85,20 @@ 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']:
@@ -105,17 +106,27 @@ class CommonView(APIView):
 
             data['client_ip'] = self.request.client_addr
             email_key = data['email_key'] or self.feedback_email_key
-            send_email(self.rattail_config, email_key, data=data)
+            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')
@@ -128,6 +139,14 @@ 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()
diff --git a/tailbone/api/core.py b/tailbone/api/core.py
index 613b1566..0d8eec32 100644
--- a/tailbone/api/core.py
+++ b/tailbone/api/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Tailbone Web API - Core Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from tailbone.views import View
 
 
@@ -101,20 +99,20 @@ class APIView(View):
                return info
         """
         app = self.get_rattail_app()
-        auth_handler = app.get_auth_handler()
+        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': auth_handler.get_email_address(user),
+            'email_address': app.get_contact_email_address(user),
         }
 
         # maybe get/use "extra" info
diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py
index e9953572..85d28c24 100644
--- a/tailbone/api/customers.py
+++ b/tailbone/api/customers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Customer Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -46,7 +42,7 @@ class CustomerView(APIMasterView):
     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,
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 97426214..551d6428 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,26 +24,15 @@
 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 cornice import resource, Service
 
-from tailbone.api import APIView, api
+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):
@@ -195,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
@@ -279,7 +268,7 @@ class APIMasterView(APIView):
         return self._fieldnames
 
     def normalize(self, obj):
-        data = {'_str': six.text_type(obj)}
+        data = {'_str': str(obj)}
 
         for field in self.get_fieldnames():
             data[field] = getattr(obj, field)
@@ -287,7 +276,7 @@ class APIMasterView(APIView):
         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 = {}
@@ -343,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
 
@@ -365,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):
         """
@@ -394,7 +387,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 not obj:
             raise self.notfound()
 
@@ -479,13 +472,16 @@ class APIMasterView(APIView):
         """
         obj = self.get_object()
 
-        filename = self.request.GET.get('filename', None)
-        if not filename:
-            raise self.notfound()
-        path = self.download_path(obj, filename)
+        # 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)
 
-        response = self.file_response(path, attachment=False)
-        return response
+        return self.rawbytes_response(obj)
+
+    def rawbytes_response(self, obj):
+        raise NotImplementedError
 
     ##############################
     # autocomplete
diff --git a/tailbone/api/people.py b/tailbone/api/people.py
index 7e06e969..f7c08dfa 100644
--- a/tailbone/api/people.py
+++ b/tailbone/api/people.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Person Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -45,7 +41,7 @@ class PersonView(APIMasterView):
     def normalize(self, person):
         return {
             'uuid': person.uuid,
-            '_str': six.text_type(person),
+            '_str': str(person),
             'first_name': person.first_name,
             'last_name': person.last_name,
             'display_name': person.display_name,
diff --git a/tailbone/api/products.py b/tailbone/api/products.py
index 48a6e4aa..3f29ff54 100644
--- a/tailbone/api/products.py
+++ b/tailbone/api/products.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,17 +24,21 @@
 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 APIMasterView
 
 
+log = logging.getLogger(__name__)
+
+
 class ProductView(APIMasterView):
     """
     API views for Product data
@@ -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,104 @@ 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()
diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py
index 6ce5f778..467c8a0d 100644
--- a/tailbone/api/upgrades.py
+++ b/tailbone/api/upgrades.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Upgrade Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -53,7 +49,7 @@ 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
 
 
diff --git a/tailbone/api/users.py b/tailbone/api/users.py
index 2b6476a2..a6bcad57 100644
--- a/tailbone/api/users.py
+++ b/tailbone/api/users.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Tailbone Web API - User Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -57,6 +55,10 @@ 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()
diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py
index 7fa61590..64311b1b 100644
--- a/tailbone/api/vendors.py
+++ b/tailbone/api/vendors.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Tailbone Web API - Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from tailbone.api import APIMasterView
@@ -44,7 +40,7 @@ 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,
         }
diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py
index eabe4cdb..19def6c4 100644
--- a/tailbone/api/workorders.py
+++ b/tailbone/api/workorders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,8 @@
 Tailbone Web API - Work Order Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
 
-import six
-
 from rattail.db.model import WorkOrder
 
 from cornice import Service
@@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView):
     object_url_prefix = '/workorder'
 
     def __init__(self, *args, **kwargs):
-        super(WorkOrderView, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         app = self.get_rattail_app()
         self.workorder_handler = app.get_workorder_handler()
 
     def normalize(self, workorder):
-        data = super(WorkOrderView, self).normalize(workorder)
+        data = super().normalize(workorder)
         data.update({
             'customer_name': workorder.customer.name,
             'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
-            'date_submitted': six.text_type(workorder.date_submitted or ''),
-            'date_received': six.text_type(workorder.date_received or ''),
-            'date_released': six.text_type(workorder.date_released or ''),
-            'date_delivered': six.text_type(workorder.date_delivered or ''),
+            '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
 
@@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView):
         if 'status_code' in data:
             data['status_code'] = int(data['status_code'])
 
-        return super(WorkOrderView, self).update_object(workorder, data)
+        return super().update_object(workorder, data)
 
     def status_codes(self):
         """
diff --git a/tailbone/app.py b/tailbone/app.py
index d7155829..d2d0c5ef 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,25 +24,20 @@
 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
@@ -63,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':
@@ -123,18 +135,21 @@ def make_pyramid_config(settings, configure_csrf=True):
         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)
 
-    # add rattail config directly to registry
+    # add rattail config directly to registry, for access throughout the app
     config.registry['rattail_config'] = rattail_config
 
     # configure user authorization / authentication
-    config.set_authorization_policy(TailboneAuthorizationPolicy())
-    config.set_authentication_policy(SessionAuthenticationPolicy())
+    config.set_security_policy(TailboneSecurityPolicy())
 
     # maybe require CSRF token protection
     if configure_csrf:
@@ -145,9 +160,20 @@ def make_pyramid_config(settings, configure_csrf=True):
     # 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:
@@ -159,7 +185,7 @@ def make_pyramid_config(settings, configure_csrf=True):
 
     # fetch all tailbone providers
     providers = get_all_providers(rattail_config)
-    for provider in six.itervalues(providers):
+    for provider in providers.values():
 
         # configure DB sessions associated with transaction manager
         provider.configure_db_sessions(rattail_config, config)
@@ -170,13 +196,22 @@ def make_pyramid_config(settings, configure_csrf=True):
             for spec in includes:
                 config.include(spec)
 
-    # 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')
+    # 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')
 
@@ -191,7 +226,7 @@ def add_websocket(config, name, view, attr=None):
         rattail_config = config.registry.settings['rattail_config']
         rattail_app = rattail_config.get_app()
 
-        if isinstance(view, six.string_types):
+        if isinstance(view, str):
             view_callable = rattail_app.load_object(view)
         else:
             view_callable = view
@@ -239,6 +274,36 @@ def add_config_page(config, route_name, label, permission):
     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']
 
@@ -246,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)
@@ -267,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
index f2146577..1afbe12a 100644
--- a/tailbone/asgi.py
+++ b/tailbone/asgi.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,14 +24,10 @@
 ASGI App Utilities
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
+import configparser
 import logging
 
-import six
-from six.moves import configparser
-
 from rattail.util import load_object
 
 from asgiref.wsgi import WsgiToAsgi
@@ -49,6 +45,12 @@ class TailboneWsgiToAsgi(WsgiToAsgi):
         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', {})
@@ -85,7 +87,7 @@ def make_asgi_app(main_app=None):
     # parse the settings needed for pyramid app
     settings = dict(parser.items('app:main'))
 
-    if isinstance(main_app, six.string_types):
+    if isinstance(main_app, str):
         make_wsgi_app = load_object(main_app)
     elif callable(main_app):
         make_wsgi_app = main_app
diff --git a/tailbone/auth.py b/tailbone/auth.py
index 88fbab0b..95bf90ba 100644
--- a/tailbone/auth.py
+++ b/tailbone/auth.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 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,58 +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):
-        config = context.request.rattail_config
-        model = config.get_model()
+    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
+
+    def load_identity(self, request):
+        config = request.registry.settings.get('rattail_config')
         app = config.get_app()
-        auth = app.get_auth_handler()
+        user = None
 
-        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:
-                    # this is pretty rare, but can happen in dev after
-                    # re-creating the database, which means new user uuids.
-                    # TODO: the odds of this query returning a user in that
-                    # case, are probably nil, and we should just skip this bit?
-                    user = Session.query(model.User).get(userid)
-                    if user:
-                        if auth.has_permission(Session(), user, permission):
-                            return True
-        if Everyone in principals:
-            return auth.has_permission(Session(), None, permission)
-        return False
+        if self.api_mode:
 
-    def principals_allowed_by_permission(self, context, permission):
-        raise NotImplementedError
+            # 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)
 
+        if not user:
 
-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)
+            # fetch user uuid from current session
+            uuid = self.session_helper.authenticated_userid(request)
+            if not uuid:
+                return
 
+            # fetch user object from db
+            model = app.model
+            user = self.db_session.get(model.User, uuid)
+            if not user:
+                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)
+        # 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 4c393b49..8392ba0a 100644
--- a/tailbone/config.py
+++ b/tailbone/config.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,9 +50,12 @@ 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')
diff --git a/tailbone/db.py b/tailbone/db.py
index ae919e49..8b37f399 100644
--- a/tailbone/db.py
+++ b/tailbone/db.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -21,16 +21,13 @@
 #
 ################################################################################
 """
-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
 from sqlalchemy.orm import sessionmaker, scoped_session
-from pkg_resources import get_distribution, parse_version
 
 from rattail.db import SessionBase
 from rattail.db.continuum import versioning_manager
@@ -45,23 +42,28 @@ TrainwreckSession = scoped_session(sessionmaker())
 # empty dict for now, this must populated on app startup (if needed)
 ExtraTrainwreckSessions = {}
 
-# some of the logic below may need to vary somewhat, based on which version of
-# zope.sqlalchemy we have installed
-zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version
-zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version)
-
 
 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
 
@@ -73,82 +75,117 @@ 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.
     """
     # the upstream internals of this function has changed a little over time.
     # unfortunately for us, that means we must include each variant here.
 
-    if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+
-        if datamanager._SESSION_STATE.get(session, None) is None:
-            if session.twophase:
-                DataManager = datamanager.TwoPhaseSessionDataManager
-            else:
-                DataManager = TailboneSessionDataManager
-            DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
-
-    else: # pre-1.1
-        if datamanager._SESSION_STATE.get(id(session), None) is None:
-            if session.twophase:
-                DataManager = datamanager.TwoPhaseSessionDataManager
-            else:
-                DataManager = TailboneSessionDataManager
-            DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
+    if datamanager._SESSION_STATE.get(session, None) is None:
+        if session.twophase:
+            DataManager = datamanager.TwoPhaseSessionDataManager
+        else:
+            DataManager = TailboneSessionDataManager
+        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,
     )
@@ -160,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 d57aa9ac..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,37 +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,
+    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):
-        """
-        Constructor.  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)``.
-        """
         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
 
@@ -90,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"
 
@@ -126,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 4d58b943..6183d17f 100644
--- a/tailbone/forms/common.py
+++ b/tailbone/forms/common.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index bf508a6f..4024557b 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,19 +24,18 @@
 Forms Core
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import hashlib
 import json
 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_quantity
-from rattail.core import UNSPECIFIED
+from rattail.util import pretty_boolean
 from rattail.db.util import get_fieldnames
 
 import colander
@@ -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, get_form_data
-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
 
 
@@ -222,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.
@@ -231,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
@@ -324,7 +328,7 @@ class Form(object):
     """
     Base class for all forms.
     """
-    save_label = "Save"
+    save_label = "Submit"
     update_label = "Save"
     show_cancel = True
     auto_disable = True
@@ -335,8 +339,12 @@ class Form(object):
                  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',
-                 vuejs_field_converters={},
+                 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:
@@ -344,6 +352,7 @@ class Form(object):
         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.readonly = readonly
         self.readonly_fields = set(readonly_fields or [])
@@ -369,21 +378,83 @@ class Form(object):
         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 component_studly(self):
-        words = self.component.split('-')
+    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):
+        """
+        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
 
@@ -400,6 +471,9 @@ class Form(object):
         return get_fieldnames(self.request.rattail_config, self.model_class,
                               columns=True, proxies=True, relations=True)
 
+    def set_grouping(self, items):
+        self.grouping = OrderedDict(items)
+
     def make_renderers(self):
         """
         Return a default set of field renderers, based on :attr:`model_class`.
@@ -556,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:
@@ -573,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':
@@ -584,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':
@@ -613,17 +708,40 @@ class Form(object):
             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))
-            # must explicitly replace node, if we already have a schema
-            if self.schema:
-                self.schema[key] = self.nodes[key]
+        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
@@ -687,9 +805,8 @@ class Form(object):
            case the validator pertains to the form at large instead of
            one of the fields.
 
-           TODO: what should the validator look like?
-
-        :param validator: Callable validator for the node.
+        :param validator: Callable which accepts ``(node, value)``
+           args.
         """
         self.validators[key] = validator
 
@@ -711,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):
         """
@@ -735,15 +857,15 @@ class Form(object):
     def set_vuejs_field_converter(self, field, converter):
         self.vuejs_field_converters[field] = converter
 
-    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 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'):
@@ -783,31 +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'].setdefault('ref', self.component_studly)
-                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
@@ -815,6 +941,36 @@ 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
@@ -826,25 +982,38 @@ class Form(object):
             value = convert(field.cstruct)
             return json.dumps(value)
 
-        if isinstance(field.schema.typ, deform.FileData):
-            # TODO: we used to always/only return 'null' here but hopefully
-            # this also works, to show existing filename when present
-            if field.cstruct and field.cstruct['filename']:
-                return json.dumps({'name': field.cstruct['filename']})
-            return 'null'
-
         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()
@@ -865,68 +1034,208 @@ class Form(object):
             return False
         return True
 
-    def render_buefy_field(self, fieldname, bfield_attrs={}):
+    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 given field in a Buefy-compatible way.  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.
+        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]
+        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',
-                'label': self.get_label(fieldname),
             }
 
             # add some magic for file input fields
-            if isinstance(field.schema.typ, deform.FileData):
+            if field and isinstance(field.schema.typ, deform.FileData):
                 attrs['class_'] = 'file'
 
-            # show helptext if present
-            if self.has_helptext(fieldname):
-                attrs['message'] = self.render_helptext(fieldname)
+            # 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)
+            error_messages = self.get_error_messages(field) if field else None
             if error_messages:
+                field_type = 'is-danger'
+                messages.extend(error_messages)
 
-                # TODO: this surely can't be what we ought to do
-                # here..?  seems like we must pass JS but not JSON,
-                # sort of, so we custom-write the JS code to ensure
-                # single instead of double quotes delimit strings
-                # within the code.
-                message = '[{}]'.format(', '.join([
-                    "'{}'".format(msg.replace("'", r"\'"))
-                    for msg in 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))
 
-                attrs.update({
-                    'type': 'is-danger',
-                    ':message': message,
-                })
+            # ..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
-            html = field.serialize(use_buefy=True,
-                                   **self.get_renderer_kwargs(fieldname))
-            # TODO: why do we not get HTML literal from serialize() ?
-            html = HTML.literal(html)
+            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)
+            return HTML.tag('b-field', c=html, **attrs)
 
-        else: # hidden field
+        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.
@@ -937,20 +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 = kwargs.get('label')
         if not label:
             label = self.get_label(field_name)
-        label = HTML.tag('label', label, for_=field_name)
-        field = self.render_field_value(field_name) or ''
-        field_div = HTML.tag('div', class_='field', c=[field])
-        contents = [label, field_div]
 
-        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
@@ -962,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)
@@ -974,7 +1293,8 @@ 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):
@@ -997,13 +1317,14 @@ 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()
@@ -1022,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)
@@ -1054,83 +1375,70 @@ class Form(object):
 
     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.
 
-            controls = get_form_data(self.request).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 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, 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)
 
-            dform = self.make_deform_form()
-            try:
-                self.validated = dform.validate(controls)
-                return True
-            except deform.ValidationFailure:
-                return False
+        if hasattr(self, 'validated'):
+            del self.validated
+        if self.request.method != 'POST':
+            return False
 
-        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
+        controls = get_form_data(self.request).items()
 
+        # 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):
-        if field in self:
-            i = self.index(field)
-            self.insert(i, newfield)
-        else:
-            log.warning("field '%s' not found, will append new field: %s",
-                        field, newfield)
-            self.append(newfield)
-
-    def insert_after(self, field, newfield):
-        if field in self:
-            i = self.index(field)
-            self.insert(i + 1, newfield)
-        else:
-            log.warning("field '%s' not found, will append new field: %s",
-                        field, newfield)
-            self.append(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 e72ab6b9..8c16726d 100644
--- a/tailbone/forms/widgets.py
+++ b/tailbone/forms/widgets.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,20 +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.db import Session
-from tailbone.forms.types import ProductQuantity
 
 
 class ReadonlyWidget(dfwidget.HiddenWidget):
@@ -45,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?
@@ -61,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
@@ -82,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"
@@ -100,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):
@@ -113,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)
@@ -123,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
 
@@ -151,6 +154,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
     template = 'checkbox_dynamic'
 
 
+# TODO: deprecate / remove this
 class PlainSelectWidget(dfwidget.SelectWidget):
     template = 'select_plain'
 
@@ -169,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
@@ -212,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)
@@ -239,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
@@ -261,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 '
@@ -289,6 +337,114 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
         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
@@ -313,13 +469,16 @@ def make_customer_widget(request, **kwargs):
 
 class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
     """
-    Autocomplete widget for a Customer reference field.
+    Autocomplete widget for a
+    :class:`~rattail:rattail.db.model.customers.Customer` reference
+    field.
     """
 
     def __init__(self, request, *args, **kwargs):
-        super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         self.request = request
-        model = self.request.rattail_config.get_model()
+        app = self.request.rattail_config.get_app()
+        model = app.model
 
         # must figure out URL providing autocomplete service
         if 'service_url' not in kwargs:
@@ -337,26 +496,30 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
                 self.input_callback = input_handler
 
     def serialize(self, field, cstruct, **kw):
-
+        """ """
         # fetch customer to provide button label, if we have a value
         if cstruct:
-            model = self.request.rattail_config.get_model()
-            customer = Session.query(model.Customer).get(cstruct)
+            app = self.request.rattail_config.get_app()
+            model = app.model
+            customer = Session.get(model.Customer, cstruct)
             if customer:
-                self.field_display = six.text_type(customer)
+                self.field_display = str(customer)
 
-        return super(CustomerAutocompleteWidget, self).serialize(
+        return super().serialize(
             field, cstruct, **kw)
 
 
 class CustomerDropdownWidget(dfwidget.SelectWidget):
     """
-    Dropdown widget for a Customer reference field.
+    Dropdown widget for a
+    :class:`~rattail:rattail.db.model.customers.Customer` reference
+    field.
     """
 
     def __init__(self, request, *args, **kwargs):
-        super(CustomerDropdownWidget, self).__init__(*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:
@@ -368,10 +531,8 @@ class CustomerDropdownWidget(dfwidget.SelectWidget):
                     customers = customers()
 
             else: # default customer list
-                model = self.request.rattail_config.get_model()
-                customers = Session.query(model.Customer)\
-                                   .order_by(model.Customer.name)\
-                                   .all()
+                customers = app.get_clientele_handler()\
+                               .get_all_customers(Session())
 
             # convert customer list to option values
             self.values = [(c.uuid, c.name)
@@ -393,13 +554,106 @@ class DepartmentWidget(dfwidget.SelectWidget):
     def __init__(self, request, **kwargs):
 
         if 'values' not in kwargs:
-            model = request.rattail_config.get_model()
+            app = request.rattail_config.get_app()
+            model = app.model
             departments = Session.query(model.Department)\
                                  .order_by(model.Department.number)
-            values = [(dept.uuid, six.text_type(dept))
+            values = [(dept.uuid, str(dept))
                       for dept in departments]
             if not kwargs.pop('required', True):
                 values.insert(0, ('', "(none)"))
             kwargs['values'] = values
 
-        super(DepartmentWidget, self).__init__(**kwargs)
+        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/core.py b/tailbone/grids/core.py
index 2f11f094..56b97b86 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,25 +24,24 @@
 Core Grid Classes
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import warnings
+import inspect
 import logging
+import warnings
+from urllib.parse import urlencode
 
-import six
-from six.moves import urllib
 import sqlalchemy as sa
 from sqlalchemy import orm
 
+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
@@ -51,23 +50,85 @@ from tailbone.util import raw_datetime
 log = logging.getLogger(__name__)
 
 
-class FieldList(list):
-    """
-    Convenience wrapper for a field list.
+class Grid(WuttaGrid):
     """
+    Base class for all grids.
 
-    def insert_before(self, field, newfield):
-        i = self.index(field)
-        self.insert(i, newfield)
+    This is now a subclass of
+    :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add
+    customizations which have traditionally been part of Tailbone.
 
-    def insert_after(self, field, newfield):
-        i = self.index(field)
-        self.insert(i + 1, newfield)
+    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/
 
-class Grid(object):
-    """
-    Core grid class.  In sore need of documentation.
+    .. 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
 
@@ -105,31 +166,109 @@ class Grid(object):
                   '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 __init__(self, key, data, columns=None, width='auto', request=None,
-                 model_class=None, model_title=None, model_title_plural=None,
-                 enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[],
-                 raw_renderers={},
-                 extra_row_class=None, linked_columns=[], url='#',
-                 joiners={}, filterable=False, filters={}, use_byte_string_filters=False,
-                 searchable={},
-                 sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
-                 pageable=False, default_pagesize=None, default_page=1,
-                 checkboxes=False, checked=None, check_handler=None, check_all_handler=None,
-                 clicking_row_checks_box=False, click_handlers=None,
-                 main_actions=[], more_actions=[], delete_speedbump=False,
-                 ajax_data_url=None, component='tailbone-grid',
-                 **kwargs):
+    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'))
 
-        self.key = key
-        self.data = data
-        self.columns = FieldList(columns) if columns is not None else None
-        self.width = width
-        self.request = request
-        self.model_class = model_class
-        if self.model_class and self.columns is None:
-            self.columns = self.make_columns()
+        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'))
+
+        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'))
+
+        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'))
+
+        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'):
@@ -142,32 +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.assume_local_times = assume_local_times
-        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.searchable = searchable or {}
-
-        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
@@ -175,46 +295,110 @@ 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')]
 
-    def remove(self, *keys):
-        """
-        This *removes* some column(s) from the grid, altogether.
-        """
-        for key in keys:
-            if key in self.columns:
-                self.columns.remove(key)
+    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):
         """
@@ -224,7 +408,7 @@ class Grid(object):
         """
         warnings.warn("Grid.hide_column() is deprecated; please use "
                       "Grid.remove() instead.",
-                      DeprecationWarning)
+                      DeprecationWarning, stacklevel=2)
         self.remove(key)
 
     def hide_columns(self, *keys):
@@ -248,9 +432,6 @@ class Grid(object):
             if key in self.invisible:
                 self.invisible.remove(key)
 
-    def append(self, field):
-        self.columns.append(field)
-
     def insert_before(self, field, newfield):
         self.columns.insert_before(field, newfield)
 
@@ -262,56 +443,54 @@ class Grid(object):
         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)
-        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)
+        """ """
+        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
 
-    def set_searchable(self, key, searchable=True):
-        if searchable:
-            self.searchable[key] = True
-        else:
-            self.searchable.pop(key, None)
-
-    def is_searchable(self, key):
-        return self.searchable.get(key, False)
-
-    def remove_filter(self, key):
-        self.filters.pop(key, None)
-
-    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):
-        """
-        Returns the label text for given field key.
-        """
-        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)
+        # 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:
@@ -322,9 +501,6 @@ class Grid(object):
     def has_click_handler(self, key):
         return key in self.click_handlers
 
-    def set_renderer(self, key, renderer):
-        self.renderers[key] = renderer
-
     def set_raw_renderer(self, key, renderer):
         """
         Set or remove the "raw" renderer for the given field.
@@ -381,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)
@@ -404,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):
@@ -413,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)
@@ -429,7 +625,8 @@ class Grid(object):
 
     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):
         seconds = self.obtain_value(obj, column_name)
@@ -442,7 +639,8 @@ class Grid(object):
         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
@@ -452,48 +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['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.
@@ -538,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):
         """
@@ -575,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.
@@ -596,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):
@@ -604,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):
         """
@@ -625,88 +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)
-
-        def sorter(query, direction):
-            # TODO: this seems hacky..normally we expect a true query
-            # of course, but in some cases it may be a list instead.
-            # if so then we can't actually sort
-            if isinstance(query, list):
-                return query
-            return query.order_by(getattr(column, direction)())
-
-        return sorter
-
     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 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)
 
-    def get_default_pagesize(self):
         if self.default_pagesize:
             return self.default_pagesize
 
-        pagesize = self.request.rattail_config.getint('tailbone',
-                                                      'grid.default_pagesize',
-                                                      default=0)
-        if pagesize:
-            return pagesize
+        return self.get_pagesize()
 
-        options = self.get_pagesize_options()
-        return options[0]
+    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'))
 
-    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.
-        """
+        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.get_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():
@@ -714,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
@@ -742,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:
@@ -771,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']
 
@@ -791,21 +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)
         app = self.request.rattail_config.get_app()
-        return app.get_setting(Session(), key) is not None
+
+        # 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)
-            app = self.request.rattail_config.get_app()
-            value = app.get_setting(Session(), skey)
+            value = app.get_setting(session, f'{prefix}.{key}')
             settings[key] = normalize(value)
 
         if self.filterable:
@@ -815,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):
@@ -852,154 +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)
-                app = self.request.rattail_config.get_app()
                 app.save_setting(Session(), skey, value(key))
-            else: # to == session
+            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')
 
@@ -1023,96 +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):
-
-        # TODO: this seems hacky..normally we expect `data` to be a
-        # query of course, but in some cases it may be a list instead.
-        # if so then we can't use ORM pager
-        if isinstance(data, list):
-            import paginate
-            return paginate.Page(data,
-                                 items_per_page=self.pagesize,
-                                 page=self.page)
-
-        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()
@@ -1123,43 +1206,53 @@ 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
-
-        context['view_click_handler'] = None
-        if view and view.click_handler:
-            context['view_click_handler'] = view.click_handler
-
-        return render(template, context)
+                return getattr(action, 'click_handler', None)
 
     def set_filters_sequence(self, filters, only=False):
         """
@@ -1195,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():
@@ -1225,7 +1318,7 @@ class Grid(object):
                 '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,
@@ -1233,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}
@@ -1308,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"
@@ -1331,27 +1385,51 @@ 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,
-                'visible': name not in self.invisible,
-            })
+    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 = []
@@ -1365,6 +1443,7 @@ class Grid(object):
             count = len(raw_data)
 
         # iterate over data rows
+        checkable = self.checkboxes and self.checkable and callable(self.checkable)
         for i in range(count):
             rowobj = raw_data[i]
 
@@ -1372,32 +1451,54 @@ class Grid(object):
             # 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
             # the grid.  this can be used for front-end editing of row data for
             # 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)
@@ -1417,6 +1518,8 @@ class Grid(object):
 
         results = {
             'data': data,
+            'row_classes': status_map,
+            # TODO: deprecate / remove this
             'row_status_map': status_map,
         }
 
@@ -1424,11 +1527,15 @@ 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
@@ -1438,133 +1545,66 @@ class Grid(object):
         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.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 generate_header_link(self, column_number, column, label_text):
+        super().__init__(request, key, **kwargs)
 
-        # 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):
-        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.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,
-                 link_class=None, click_handler=None):
-        self.key = key
-        self.label = label or prettify(key)
-        self.icon = icon
-        self.url = url
         self.target = target
-        self.link_class = link_class
         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
-
-    def render_icon(self):
-        """
-        Render the HTML snippet for the action link icon.
-        """
-        return HTML.tag('i', class_='fas fa-{}'.format(self.icon))
-
-    def render_label(self):
-        """
-        Render the label "text" within the actions column of a grid
-        row.  Most actions have a static label that never varies, but
-        you can override this to add e.g. HTML content.  Note that the
-        return value will be treated / rendered as HTML whether or not
-        it contains any, so perhaps be careful that it is trusted
-        content.
-        """
-        return self.label
-
 
 class URLMaker(object):
     """
@@ -1579,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 2818b78a..7e52bb8d 100644
--- a/tailbone/grids/filters.py
+++ b/tailbone/grids/filters.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
@@ -117,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):
@@ -173,18 +171,25 @@ class GridFilter(object):
     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_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
@@ -272,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
 
@@ -308,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):
         """
@@ -332,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
@@ -410,12 +448,12 @@ class AlchemyGridFilter(GridFilter):
         if start_value:
             if self.value_invalid(start_value):
                 return query
-            query = query.filter(self.column >= start_value)
+            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 <= end_value)
+            query = query.filter(self.column <= self.encode_value(end_value))
 
         return query
 
@@ -429,9 +467,13 @@ 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',
                 'contains_any_of',
-                'equal', 'not_equal',
+                'equal', 'not_equal', 'equal_any_of',
                 'is_empty', 'is_not_empty',
                 'is_null', 'is_not_null',
                 'is_empty_or_null',
@@ -443,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):
         """
@@ -454,14 +500,17 @@ 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):
         """
@@ -490,24 +539,28 @@ class AlchemyStringFilter(AlchemyGridFilter):
 
         conditions = []
         for value in values:
-            conditions.append(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))
+            conditions.append(sa.and_(*criteria))
 
         return query.filter(sa.or_(*conditions))
 
     def filter_is_empty(self, query, value):
-        return query.filter(sa.func.trim(self.column) == self.encode_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.trim(self.column) != self.encode_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.trim(self.column) == self.encode_value(''),
+                sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
                 self.column == None))
 
+
 class AlchemyEmptyStringFilter(AlchemyStringFilter):
     """
     String filter with special logic to treat empty string values as NULL
@@ -517,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):
@@ -535,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
 
@@ -546,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):
         """
@@ -556,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):
@@ -571,10 +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', 'between',
-                     '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
@@ -586,47 +648,66 @@ class AlchemyNumericFilter(AlchemyGridFilter):
 
         # first just make sure it's somewhat numeric
         try:
-            float(value)
-        except ValueError:
+            self.parse_decimal(value)
+        except decimal.InvalidOperation:
             return True
 
-        return bool(value and len(six.text_type(value)) > 8)
+        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:
@@ -634,9 +715,10 @@ 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
 
@@ -646,6 +728,13 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
         return int(value)
 
 
+class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
+    """
+    BigInteger filter for SQLAlchemy.
+    """
+    bigint = True
+
+
 class AlchemyBooleanFilter(AlchemyGridFilter):
     """
     Boolean filter for SQLAlchemy.
@@ -1134,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):
         """
@@ -1142,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):
@@ -1186,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 a3d07f79..50b38c30 100644
--- a/tailbone/helpers.py
+++ b/tailbone/helpers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,22 +24,20 @@
 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)
 
 
diff --git a/tailbone/menus.py b/tailbone/menus.py
index 46f5c62a..09d6f3f0 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,378 +24,749 @@
 App Menus
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import re
 import logging
+import warnings
 
-from rattail.util import import_module_path, prettify, simple_error
+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
 
 
 log = logging.getLogger(__name__)
 
 
-def make_simple_menus(request):
+class TailboneMenuHandler(WuttaMenuHandler):
     """
-    Build the main menu list for the app.
+    Base class and default implementation for menu handler.
     """
-    # first try to make menus from config, but this is highly
-    # susceptible to failure, so try to warn user of problems
-    raw_menus = None
-    try:
-        raw_menus = make_menus_from_config(request)
-    except Exception as error:
-        # 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')
 
-    if not raw_menus:
+    ##############################
+    # internal methods
+    ##############################
 
-        # no config, so import/invoke code function to build them
-        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))
-        raw_menus = menus_module.simple_menus(request)
+    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
 
-    # now we have "simple" (raw) menus definition, but must refine
-    # that somewhat to produce our final menus
-    mark_allowed(request, raw_menus)
-    final_menus = []
-    for topitem in raw_menus:
+    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:
 
-        if topitem['allowed']:
+            # 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')
 
-            if topitem.get('type') == 'link':
-                final_menus.append(make_menu_entry(request, topitem))
+        # okay, no config, so menus will be built from code
+        return self.make_menus(request, **kwargs)
 
-            else: # assuming 'menu' type
+    def _make_menus_from_config(self, request, **kwargs):
+        """
+        Try to build a complete menu set from config/settings.
 
-                menu_items = []
-                for item in topitem['items']:
-                    if not item['allowed']:
-                        continue
+        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
 
-                    # nested submenu
-                    if item.get('type') == 'menu':
-                        submenu_items = []
-                        for subitem in item['items']:
-                            if subitem['allowed']:
-                                submenu_items.append(make_menu_entry(request, subitem))
-                        menu_items.append({
-                            'type': 'submenu',
-                            'title': item['title'],
-                            'items': submenu_items,
-                            'is_menu': True,
-                            'is_sep': False,
-                        })
+        model = self.app.model
+        menus = []
 
-                    elif item.get('type') == 'sep':
-                        # we only want to add a sep, *if* we already have some
-                        # menu items (i.e. there is something to separate)
-                        # *and* the last menu item is not a sep (avoid doubles)
-                        if menu_items and not menu_items[-1]['is_sep']:
-                            menu_items.append(make_menu_entry(request, item))
+        # 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):
 
-                    else: # standard menu item
-                        menu_items.append(make_menu_entry(request, item))
+            # 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))
 
-                # remove final separator if present
-                if menu_items and menu_items[-1]['is_sep']:
-                    menu_items.pop()
+        else: # read from config file only
+            for key in main_keys:
+                menus.append(self._make_single_menu_from_config(request, key))
 
-                # only add if we wound up with something
-                assert menu_items
-                if menu_items:
-                    group = {
-                        'type': 'menu',
-                        'key': topitem.get('key'),
-                        'title': topitem['title'],
-                        'items': menu_items,
-                        'is_menu': True,
-                        'is_link':  False,
-                    }
+        return menus
 
-                    # topitem w/ no key likely means it did not come
-                    # from config but rather explicit definition in
-                    # code.  so we are free to "invent" a (safe) key
-                    # for it, since that is only for editing config
-                    if not group['key']:
-                        group['key'] = make_menu_key(request.rattail_config,
-                                                     topitem['title'])
-
-                    final_menus.append(group)
-
-    return final_menus
-
-
-def make_menus_from_config(request):
-    """
-    Try to build a complete menu set from config/settings.
-
-    This essentially checks for the top-level menu list in config; if
-    found then it will build a full menu set from config.  If this
-    top-level list is not present in config then menus will be built
-    purely from code instead.  An example of this top-level list:
-
-    .. code-hightlight:: ini
-
-       [tailbone.menu]
-       menus = first, second, third, admin
-
-    Obviously much more config would be needed to define those menus
-    etc. but that is the option that determines whether the rest of
-    menu config is even read, or not.
-    """
-    config = request.rattail_config
-    main_keys = config.getlist('tailbone.menu', 'menus')
-    if not main_keys:
-        return
-
-    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 config.getbool('tailbone.menu', 'from_settings',
-                      default=False):
-        app = config.get_app()
-        model = config.get_model()
-
-        # fetch all menu-related settings at once
-        query = Session().query(model.Setting)\
-                         .filter(model.Setting.name.like('tailbone.menu.%'))
-        settings = app.cache_model(Session(), model.Setting,
-                                   query=query, key='name',
-                                   normalizer=lambda s: s.value)
-        for key in main_keys:
-            menus.append(make_single_menu_from_settings(request, key, settings))
-
-    else: # read from config file only
-        for key in main_keys:
-            menus.append(make_single_menu_from_config(request, key))
-
-    return menus
-
-
-def make_single_menu_from_config(request, key):
-    """
-    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.
-    """
-    config = request.rattail_config
-    menu = {
-        'key': key,
-        'type': 'menu',
-        'items': [],
-    }
-
-    # title
-    title = config.get('tailbone.menu',
-                       'menu.{}.label'.format(key),
-                       usedb=False)
-    menu['title'] = title or prettify(key)
-
-    # items
-    item_keys = 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 = config.get('tailbone.menu',
-                               'menu.{}.item.{}.label'.format(key, item_key),
-                               usedb=False)
-            item['title'] = title or prettify(item_key)
-
-            # route
-            route = config.get('tailbone.menu',
-                               'menu.{}.item.{}.route'.format(key, item_key),
-                               usedb=False)
-            if route:
-                item['route'] = route
-                item['url'] = request.route_url(route)
-
-            else:
-
-                # url
-                url = 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
-
-            # perm
-            perm = config.get('tailbone.menu',
-                              'menu.{}.item.{}.perm'.format(key, item_key),
-                              usedb=False)
-            item['perm'] = perm or '{}.list'.format(item_key)
-
-        menu['items'].append(item)
-
-    return menu
-
-
-def make_single_menu_from_settings(request, key, settings):
-    """
-    Makes a single top-level menu dict from DB settings.
-    """
-    config = request.rattail_config
-    menu = {
-        'key': key,
-        'type': 'menu',
-        'items': [],
-    }
-
-    # title
-    title = settings.get('tailbone.menu.menu.{}.label'.format(key))
-    menu['title'] = title or prettify(key)
-
-    # items
-    item_keys = 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
-
-
-def make_menu_key(config, value):
-    """
-    Generate a normalized menu key for the given value.
-    """
-    return re.sub(r'\W', '', value.lower())
-
-
-def make_menu_entry(request, item):
-    """
-    Convert a simple menu entry dict, into a proper menu-related object, for
-    use in constructing final menu.
-    """
-    # separator
-    if item.get('type') == 'sep':
-        return {
-            'type': 'sep',
-            'is_menu': False,
-            'is_sep': True,
+    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': [],
         }
 
-    # standard menu item
-    entry = {
-        'type': 'item',
-        'title': item['title'],
-        'perm': item.get('perm'),
-        'target': item.get('target'),
-        'is_link': True,
-        'is_menu': False,
-        'is_sep': False,
-    }
-    if item.get('route'):
-        entry['route'] = item['route']
-        entry['url'] = request.route_url(entry['route'])
-        entry['key'] = entry['route']
-    else:
-        if item.get('url'):
-            entry['url'] = item['url']
-        entry['key'] = make_menu_key(request.rattail_config, entry['title'])
-    return entry
+        # 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 = {}
 
-def is_allowed(request, item):
-    """
-    Logic to determine if a given menu item is "allowed" for current user.
-    """
-    perm = item.get('perm')
-    if perm:
-        return request.has_perm(perm)
-    return True
+            if item_key == 'SEP':
+                item['type'] = 'sep'
 
+            else:
+                item['type'] = 'item'
+                item['key'] = item_key
 
-def mark_allowed(request, menus):
-    """
-    Traverse the menu set, and mark each item as "allowed" (or not) based on
-    current user permissions.
-    """
-    for topitem in menus:
+                # title
+                title = self.config.get('tailbone.menu',
+                                        'menu.{}.item.{}.label'.format(key, item_key),
+                                        usedb=False)
+                item['title'] = title or prettify(item_key)
 
-        if topitem.get('type', 'menu') == 'menu':
-            topitem['allowed'] = False
-
-            for item in topitem['items']:
-
-                if item.get('type') == 'menu':
-                    for subitem in item['items']:
-                        subitem['allowed'] = is_allowed(request, subitem)
-
-                    item['allowed'] = False
-                    for subitem in item['items']:
-                        if subitem['allowed'] and subitem.get('type') != 'sep':
-                            item['allowed'] = True
-                            break
+                # 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)
 
                 else:
-                    item['allowed'] = is_allowed(request, item)
 
-            for item in topitem['items']:
-                if item['allowed'] and item.get('type') != 'sep':
-                    topitem['allowed'] = True
-                    break
+                    # 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
+
+                # perm
+                perm = self.config.get('tailbone.menu',
+                                       'menu.{}.item.{}.perm'.format(key, item_key),
+                                       usedb=False)
+                item['perm'] = perm or '{}.list'.format(item_key)
+
+            menu['items'].append(item)
+
+        return menu
+
+    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': [],
+        }
+
+        # title
+        title = settings.get('tailbone.menu.menu.{}.label'.format(key))
+        menu['title'] = title or prettify(key)
+
+        # 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/providers.py b/tailbone/providers.py
index baa2a15d..a538fa73 100644
--- a/tailbone/providers.py
+++ b/tailbone/providers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -48,6 +48,9 @@ class TailboneProvider(object):
     def get_provided_views(self):
         return {}
 
+    def make_integration_menu(self, request, **kwargs):
+        pass
+
 
 def get_all_providers(config):
     """
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/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/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/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.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 75037448..00000000
--- a/tailbone/static/js/tailbone.buefy.grid.js
+++ /dev/null
@@ -1,156 +0,0 @@
-
-const GridFilterNumericValue = {
-    template: '#grid-filter-numeric-value-template',
-    props: {
-        value: String,
-        wantsRange: Boolean,
-    },
-    data() {
-        return {
-            startValue: null,
-            endValue: null,
-        }
-    },
-    mounted() {
-        if (this.wantsRange) {
-            if (this.value.includes('|')) {
-                let values = this.value.split('|')
-                if (values.length == 2) {
-                    this.startValue = values[0]
-                    this.endValue = values[1]
-                } else {
-                    this.startValue = this.value
-                }
-            } else {
-                this.startValue = this.value
-            }
-        } else {
-            this.startValue = this.value
-        }
-    },
-    methods: {
-        focus() {
-            this.$refs.startValue.focus()
-        },
-        startValueChanged(value) {
-            if (this.wantsRange) {
-                value += '|' + this.endValue
-            }
-            this.$emit('input', value)
-        },
-        endValueChanged(value) {
-            value = this.startValue + '|' + value
-            this.$emit('input', value)
-        },
-    },
-}
-
-Vue.component('grid-filter-numeric-value', GridFilterNumericValue)
-
-
-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
-        },
-
-        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)
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.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/falafel/css/base.css b/tailbone/static/themes/falafel/css/base.css
deleted file mode 100644
index 0fa02dbb..00000000
--- a/tailbone/static/themes/falafel/css/base.css
+++ /dev/null
@@ -1,14 +0,0 @@
-
-/******************************
- * 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/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 de4b1ebe..00000000
--- a/tailbone/static/themes/falafel/css/forms.css
+++ /dev/null
@@ -1,61 +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%;
-}
-
-/******************************
- * field-wrappers
- ******************************/
-
-/* TODO: replace this with bulma equivalent */
-.field-wrapper {
-    clear: both;
-    min-height: 30px;
-    overflow: auto;
-    margin: 15px;
-}
-
-/* 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;
-}
-
-/* TODO: replace this with bulma equivalent */
-.field-wrapper .field {
-    display: table-cell;
-    line-height: 25px;
-}
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 db3ebaf8..00000000
--- a/tailbone/static/themes/falafel/css/layout.css
+++ /dev/null
@@ -1,133 +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
- ******************************/
-
-/* 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;
-}
-
-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;
-}
-
-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-helpers a {
-    white-space: nowrap;
-}
-
-.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 6f687b80..00000000
--- a/tailbone/static/themes/falafel/js/tailbone.feedback.js
+++ /dev/null
@@ -1,54 +0,0 @@
-
-let FeedbackForm = {
-    props: ['action', 'message'],
-    template: '#feedback-template',
-    mixins: [FormPosterMixin],
-    methods: {
-
-        pleaseReplyChanged(value) {
-            this.$nextTick(() => {
-                this.$refs.userEmail.focus()
-            })
-        },
-
-        showFeedback() {
-            this.showDialog = true
-            this.$nextTick(function() {
-                this.$refs.textarea.focus()
-            })
-        },
-
-        sendFeedback() {
-
-            let params = {
-                referrer: this.referrer,
-                user: this.userUUID,
-                user_name: this.userName,
-                please_reply_to: this.pleaseReply ? this.userEmail : null,
-                message: this.message.trim(),
-            }
-
-            this.submitForm(this.action, params, response => {
-
-                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/subscribers.py b/tailbone/subscribers.py
index 6e8e2d33..268d4818 100644
--- a/tailbone/subscribers.py
+++ b/tailbone/subscribers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,11 +24,10 @@
 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
 
@@ -37,147 +36,169 @@ 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.config import csrf_header_name, should_expose_websockets
-from tailbone.menus import make_simple_menus
-from tailbone.util import should_use_buefy
+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')
-    # TODO: why would this ever be null?
-    if rattail_config:
-        request.rattail_config = rattail_config
 
-    def user(request):
-        user = None
-        uuid = request.authenticated_userid
-        if uuid:
-            model = request.rattail_config.get_model()
-            user = Session.query(model.User).get(uuid)
-            if user:
-                Session().set_continuum_user(user)
-        return user
+    # invoke main upstream logic
+    # nb. this sets request.wutta_config
+    base.new_request(event)
 
-    request.set_property(user, reify=True)
+    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()
 
-    if rattail_config:
-        app = rattail_config.get_app()
-        auth = app.get_auth_handler()
-        request.tailbone_cached_permissions = auth.get_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()
-    rattail_config = request.rattail_config
+    config = request.wutta_config
+    app = config.get_app()
 
     renderer_globals = event
-    renderer_globals['rattail_app'] = request.rattail_config.get_app()
-    renderer_globals['app_title'] = request.rattail_config.app_title()
+
+    # 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(request.rattail_config)
+    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')
 
-        # buefy themes get some extra treatment
-        if should_use_buefy(request):
-
-            # declare vue.js and buefy versions to use.  the default
-            # values here are "quite conservative" as of this writing,
-            # perhaps too much so, but at least they should work fine.
-            renderer_globals['vue_version'] = request.rattail_config.get(
-                'tailbone', 'vue_version') or '2.6.10'
-            renderer_globals['buefy_version'] = request.rattail_config.get(
-                'tailbone', 'buefy_version') or '0.8.13'
-
-            # maybe set custom stylesheet
-            css = None
-            if request.user:
-                css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid),
-                                                 'buefy_css')
+        # maybe set custom stylesheet
+        css = None
+        if request.user:
+            css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
             if not css:
-                css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css')
-            renderer_globals['buefy_css'] = 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:
@@ -188,7 +209,7 @@ def before_render(event):
         renderer_globals['filter_verb_width'] = widths[1]
 
         # declare global support for websockets, or lack thereof
-        renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config)
+        renderer_globals['expose_websockets'] = should_expose_websockets(config)
 
 
 def add_inbox_count(event):
@@ -202,8 +223,9 @@ 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()
-        model = request.rattail_config.get_model()
         renderer_globals['inbox_count'] = Session.query(model.Message)\
                                                  .outerjoin(model.MessageRecipient)\
                                                  .filter(model.MessageRecipient.recipient == Session.merge(request.user))\
@@ -213,51 +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, **kwargs):
-        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
-            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 a80dafc2..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">
@@ -178,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',
@@ -221,78 +192,3 @@
 
   </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 8c84aedd..4c413757 100644
--- a/tailbone/templates/autocomplete.mako
+++ b/tailbone/templates/autocomplete.mako
@@ -1,63 +1,5 @@
 ## -*- 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>
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 43f3a1dd..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,154 +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('warning'):
-                  <div class="error-messages">
-                    % for msg in request.session.pop_flash('warning'):
-                        <div class="ui-state-error ui-corner-all">
-                          <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
-                          ${msg}
-                        </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>
 
@@ -185,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>
@@ -269,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 568782b7..b6376448 100644
--- a/tailbone/templates/base_meta.mako
+++ b/tailbone/templates/base_meta.mako
@@ -1,8 +1,7 @@
 ## -*- 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.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" />
@@ -11,9 +10,3 @@
 <%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>
-
-<%def name="footer()">
-  <p class="has-text-centered">
-    powered by ${h.link_to("Rattail", url('about'))}
-  </p>
-</%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 89358567..bea10a97 100644
--- a/tailbone/templates/batch/index.mako
+++ b/tailbone/templates/batch/index.mako
@@ -1,157 +1,80 @@
 ## -*- 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="refreshResultsButtonDisabled"
-                    icon-pack="fas"
-                    icon-left="fas fa-redo"
-                    @click="refreshResults()">
-            {{ refreshResultsButtonText }}
-          </b-button>
-          ${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')}
-          ${h.csrf_token(request)}
-          ${h.end_form()}
-      % 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()"
-                    icon-pack="fas"
-                    icon-left="arrow-circle-right"
-                    :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 />
-                <div class="form-wrapper">
-                  <div class="form">
-                    <${execute_form.component} ref="executeResultsForm"></${execute_form.component}>
-                  </div>
-                </div>
-              </section>
-
-              <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>
+          <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'):
+      ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.results_refreshable and master.has_perm('refresh'):
-      <script type="text/javascript">
+      <script>
 
         TailboneGridData.refreshResultsButtonText = "Refresh Results"
         TailboneGridData.refreshResultsButtonDisabled = false
@@ -165,9 +88,9 @@
       </script>
   % endif
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
+      <script>
 
-        ${execute_form.component_studly}.methods.submit = function() {
+        ${execute_form.vue_component}.methods.submit = function() {
             this.$refs.actualExecuteForm.submit()
         }
 
@@ -202,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">
-
-        ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
-
-        Vue.component('${execute_form.component}', ${execute_form.component_studly})
-
-      </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': 'actualExecuteForm'}, 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/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/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako
index 0d57053e..4f91cb02 100644
--- a/tailbone/templates/batch/vendorcatalog/configure.mako
+++ b/tailbone/templates/batch/vendorcatalog/configure.mako
@@ -39,14 +39,9 @@
   </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>
     ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako
index 19e91dd0..d9d62bd1 100644
--- a/tailbone/templates/batch/vendorcatalog/create.mako
+++ b/tailbone/templates/batch/vendorcatalog/create.mako
@@ -1,69 +1,16 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <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();
-                }
-            }
-        });
-
-    });
-  </script>
-  % endif
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n}
-
-    ${form.component_studly}Data.vendorName = null
-    ${form.component_studly}Data.vendorNameReplacement = null
-
-    ${form.component_studly}.watch.field_model_parser_key = function(val) {
+    ${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) {
@@ -77,11 +24,11 @@
         }
     }
 
-    ${form.component_studly}.methods.vendorLabelChanging = function(label) {
+    ${form.vue_component}.methods.vendorLabelChanging = function(label) {
         this.vendorNameReplacement = label
     }
 
-    ${form.component_studly}.methods.vendorChanged = function(uuid) {
+    ${form.vue_component}.methods.vendorChanged = function(uuid) {
         if (uuid) {
             this.vendorName = this.vendorNameReplacement
             this.vendorNameReplacement = null
@@ -90,6 +37,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/view_row.mako b/tailbone/templates/batch/vendorcatalog/view_row.mako
index 6aaf9bf4..0128e3b3 100644
--- a/tailbone/templates/batch/vendorcatalog/view_row.mako
+++ b/tailbone/templates/batch/vendorcatalog/view_row.mako
@@ -1,7 +1,7 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view_row.mako" />
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <tailbone-form></tailbone-form>
     <br />
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index 66a6881a..7c81ab0e 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -1,68 +1,8 @@
 ## -*- 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 and master.allow_worksheet(batch) and master.has_perm('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 master.has_perm('refresh'):
-            $('#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
-        % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-            $('.upload-worksheet').click(function() {
-                $('#upload-worksheet-dialog').dialog({
-                    title: "Upload Worksheet",
-                    width: 600,
-                    modal: true,
-                    buttons: [
-                        {
-                            text: "Upload & Update Batch",
-                            click: function(event) {
-                                var form = $('form[name="upload-worksheet"]');
-                                var field = form.find('input[type="file"]').get(0);
-                                if (!field.value) {
-                                    alert("Please choose a file to upload.");
-                                    return
-                                }
-                                disable_button(dialog_button(event));
-                                form.submit();
-                            }
-                        },
-                        {
-                            text: "Cancel",
-                            click: function() {
-                                $(this).dialog('close');
-                            }
-                        }
-                    ]
-                });
-            });
-        % endif
-    });
-
-  </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if use_buefy:
   <style type="text/css">
 
     .modal-card-body label {
@@ -74,19 +14,6 @@
     }
 
   </style>
-  % else:
-  <style type="text/css">
-
-    .grid-wrapper {
-        margin-top: 10px;
-    }
-
-    .complete form {
-        display: inline;
-    }
-    
-  </style>
-  % endif
 </%def>
 
 <%def name="buttons()">
@@ -99,52 +26,39 @@
 
 <%def name="leading_buttons()">
   % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      % if use_buefy:
-          <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>
-      % 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 master.has_perm('refresh'):
-      % if use_buefy:
-          ## 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>
-      % else:
-          <button type="button" class="button" id="refresh-data">Refresh Data</button>
-      % endif
+      ## 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="trailing_buttons()">
   % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      % if use_buefy:
-          <b-button tag="a"
-                    href="${master.get_action_url('download_worksheet', batch)}"
-                    icon-pack="fas"
-                    icon-left="fas fa-download">
-            Download Worksheet
-          </b-button>
-          <b-button type="is-primary"
-                    icon-pack="fas"
-                    icon-left="fas fa-upload"
-                    @click="$emit('show-upload')">
-            Upload Worksheet
-          </b-button>
-      % else:
-          ${h.link_to("Download Worksheet", master.get_action_url('download_worksheet', batch), class_='button')}
-          <button type="button" class="upload-worksheet">Upload Worksheet</button>
-      % endif
+      <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>
 
@@ -154,65 +68,42 @@
 </%def>
 
 <%def name="render_status_breakdown()">
-  % if use_buefy:
-      <div class="object-helper">
-        <h3>Row Status Breakdown</h3>
-        <div class="object-helper-content">
-          ${status_breakdown_grid}
-        </div>
+  <nav class="panel">
+    <p class="panel-heading">Row Status</p>
+    <div class="panel-block">
+      <div style="width: 100%;">
+        ${status_breakdown_grid}
       </div>
-  % elif 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 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>
-      </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 master.has_perm('execute'):
-              <p>Batch has not yet been executed.</p>
-              % if use_buefy:
-                  <br />
-                  <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>
+              <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:
+              % if execute_enabled:
                   <b-modal has-modal-card
                            :active.sync="showExecutionDialog">
                     <div class="modal-card">
@@ -228,8 +119,7 @@
                         <div class="markdown">
                           ${execution_described|n}
                         </div>
-                        <${execute_form.component} ref="executeBatchForm">
-                        </${execute_form.component}>
+                        ${execute_form.render_vue_tag(ref='executeBatchForm')}
                       </section>
 
                       <footer class="modal-card-foot">
@@ -245,111 +135,61 @@
 
                     </div>
                   </b-modal>
-                  % 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>
               % 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 master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      % if use_buefy:
-          <b-modal has-modal-card
-                   :active.sync="showUploadDialog">
-            <div class="modal-card">
+      <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>
+          <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.component} ref="uploadForm">
-                </${upload_worksheet_form.component}>
-              </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="fas fa-upload"
-                          :disabled="uploadButtonDisabled">
-                  {{ uploadButtonText }}
-                </b-button>
-              </footer>
-
-            </div>
-          </b-modal>
-      % else:
-          <div id="upload-worksheet-dialog" style="display: none;">
+          <section class="modal-card-body">
             <p>
-              This will <strong>update</strong> the batch data with the worksheet
-              file you provide.&nbsp; Please be certain to use the right one!
+              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>
-            ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'name': 'upload-worksheet'})|n}
-          </div>
-      % endif
-  % endif
+            <br />
+            ${upload_worksheet_form.render_vue_tag(ref='uploadForm')}
+          </section>
 
-  % if not use_buefy:
-      % if master.handler.executable(batch) and master.has_perm('execute'):
-          <div id="execution-options-dialog" style="display: none;">
-            ${execute_form.render_deform(form_kwargs={'name': 'batch-execution'}, buttons=False)|n}
-          </div>
-      % endif
+          <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_this_page_template()">
-  ${parent.render_this_page_template()}
-  % if use_buefy:
-      % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-          ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n}
-      % endif
-      % if master.handler.executable(batch) and master.has_perm('execute'):
-          ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
-      % endif
-  % endif
-</%def>
-
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <${form.component} @show-upload="showUploadDialog = true">
     </${form.component}>
@@ -358,7 +198,7 @@
 
 <%def name="render_row_grid_tools()">
   ${parent.render_row_grid_tools()}
-  % if use_buefy and master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
+  % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
       <b-button type="is-danger"
                 @click="deleteResultsInit()"
                 :disabled="!total"
@@ -409,9 +249,27 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%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>
+
+## 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>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n}
 
@@ -426,6 +284,10 @@
         })
     }
 
+    % 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
@@ -444,7 +306,7 @@
             form.submit()
         }
 
-        ${upload_worksheet_form.component_studly}.methods.submit = function() {
+        ${upload_worksheet_form.vue_component}.methods.submit = function() {
             this.$refs.actualUploadForm.submit()
         }
 
@@ -459,7 +321,7 @@
             this.$refs.executeBatchForm.submit()
         }
 
-        ${execute_form.component_studly}.methods.submit = function() {
+        ${execute_form.vue_component}.methods.submit = function() {
             this.$refs.actualExecuteForm.submit()
         }
 
@@ -467,9 +329,9 @@
 
     % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
 
-        ${rows_grid.component_studly}Data.deleteResultsShowDialog = false
+        ${rows_grid.vue_component}Data.deleteResultsShowDialog = false
 
-        ${rows_grid.component_studly}.methods.deleteResultsInit = function() {
+        ${rows_grid.vue_component}.methods.deleteResultsInit = function() {
             this.deleteResultsShowDialog = true
         }
 
@@ -478,28 +340,12 @@
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      <script type="text/javascript">
-
-        ## UploadForm
-        ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data }
-        Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly})
-
-      </script>
+      ${upload_worksheet_form.render_vue_finalize()}
   % endif
-
   % if execute_enabled and master.has_perm('execute'):
-      <script type="text/javascript">
-
-        ## ExecuteForm
-        ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
-        Vue.component('${execute_form.component}', ${execute_form.component_studly})
-
-      </script>
+      ${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
index 495b5c65..c7f46d21 100644
--- a/tailbone/templates/configure-menus.mako
+++ b/tailbone/templates/configure-menus.mako
@@ -14,6 +14,12 @@
 </%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>
@@ -182,11 +188,29 @@
 
   </div>
 
+  % else:
+      ## not root!
+
+      <b-notification type="is-warning">
+        You must become root to configure menus!
+      </b-notification>
+
+  % endif
+
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+## 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}
 
@@ -419,6 +443,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
index 2fe8ee72..e6b128fc 100644
--- a/tailbone/templates/configure.mako
+++ b/tailbone/templates/configure.mako
@@ -3,6 +3,15 @@
 
 <%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">
@@ -83,7 +92,7 @@
         <b-select name="${tmpl['setting_file']}"
                   v-model="inputFileTemplateSettings['${tmpl['setting_file']}']"
                   @input="settingsNeedSaved = true">
-          <option :value="null">-new-</option>
+          <option value="">-new-</option>
           <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']"
                   :key="option"
                   :value="option">
@@ -95,22 +104,40 @@
       <b-field label="Upload"
                v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']">
 
-        <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>
+        % 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>
 
@@ -134,6 +161,85 @@
   </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()">
@@ -174,15 +280,14 @@
         <b-button @click="purgeSettingsShowDialog = false">
           Cancel
         </b-button>
-        ${h.form(request.current_route_url())}
+        ${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"
-                  @click="purgingSettings = true">
+                  icon-left="trash">
           {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
         </b-button>
         ${h.end_form()}
@@ -196,62 +301,42 @@
   ${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>
 
     % if simple_settings is not Undefined:
         ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n}
     % endif
 
-    % 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
-        }
-    % 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
     }
 
-    % if input_file_template_settings is not Undefined:
-        ThisPage.methods.validateInputFileTemplateSettings = function() {
-            % for tmpl in six.itervalues(input_file_templates):
-                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
-        }
-    % endif
-
-    ThisPage.methods.validateSettings = function() {
-        let msg
-
-        % if input_file_template_settings is not Undefined:
-            msg = this.validateInputFileTemplateSettings()
-            if (msg) {
-                return msg
-            }
-        % endif
-    }
+    ThisPage.methods.validateSettings = function() {}
 
     ThisPage.methods.saveSettings = function() {
-        let msg = this.validateSettings()
+        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
@@ -282,8 +367,65 @@
         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>
-
-
-${parent.body()}
diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako
index f465fdf5..1a6dca8b 100644
--- a/tailbone/templates/customers/configure.mako
+++ b/tailbone/templates/customers/configure.mako
@@ -6,15 +6,70 @@
   <h3 class="block is-size-3">General</h3>
   <div class="block" style="padding-left: 2rem;">
 
-    <b-field message="If not set, customer chooser is an autocomplete field.">
+    <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">
-        Show customer chooser as dropdown (select) element
+        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>
@@ -33,5 +88,26 @@
 
 </%def>
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-${parent.body()}
+    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
index e9e54c99..1cea9d1f 100644
--- a/tailbone/templates/customers/pending/view.mako
+++ b/tailbone/templates/customers/pending/view.mako
@@ -106,9 +106,9 @@
   % 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>
 
     ThisPageData.resolvePersonShowDialog = false
     ThisPageData.resolvePersonUUID = null
@@ -139,5 +139,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako
index 81e05aaa..490e4757 100644
--- a/tailbone/templates/customers/view.mako
+++ b/tailbone/templates/customers/view.mako
@@ -2,47 +2,33 @@
 <%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>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <tailbone-form @detach-person="detachPerson">
     </tailbone-form>
   </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>
 
-    ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n}
+    % 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 we will add that once
-        ## we can assume a Buefy theme is present, to avoid having to
-        ## implement the logic in old jquery...
+        ## 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
         }
@@ -50,5 +36,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako
index 6d51e433..16d26d21 100644
--- a/tailbone/templates/custorders/configure.mako
+++ b/tailbone/templates/custorders/configure.mako
@@ -24,29 +24,38 @@
       </b-checkbox>
     </b-field>
 
-    <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_choice']"
+         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>
+      <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>
 
-    <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 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>
@@ -79,15 +88,6 @@
       </b-checkbox>
     </b-field>
 
-    <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>
-
     <b-field>
       <b-checkbox name="rattail.custorders.allow_item_discounts"
                   v-model="simpleSettings['rattail.custorders.allow_item_discounts']"
@@ -97,6 +97,29 @@
       </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']"
@@ -107,6 +130,51 @@
     </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>
 
 
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index cdbf584c..382a121f 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -4,22 +4,16 @@
 
 <%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()">
@@ -33,18 +27,18 @@
                     @click="submitOrder()"
                     :disabled="submittingOrder"
                     icon-pack="fas"
-                    icon-left="fas fa-upload">
-            {{ submitOrderButtonText }}
+                    icon-left="upload">
+            {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }}
           </b-button>
           <b-button @click="startOverEntirely()"
                     icon-pack="fas"
-                    icon-left="fas fa-redo">
+                    icon-left="redo">
             Start Over Entirely
           </b-button>
           <b-button @click="cancelOrder()"
                     type="is-danger"
                     icon-pack="fas"
-                    icon-left="fas fa-trash">
+                    icon-left="trash">
             Cancel this Order
           </b-button>
         </div>
@@ -53,44 +47,82 @@
   </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()" -->
@@ -114,23 +146,28 @@
 
               <div :style="{'flex-grow': contactNotes.length ? 0 : 1}">
 
-                <b-field label="Customer" grouped>
-                  <b-field style="margin-left: 1rem;"
-                           :expanded="!contactUUID">
+                <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"
-                                           :initial-label="contactDisplay"
                                            % if new_order_requires_customer:
                                            serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}"
                                            % else:
                                            serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}"
                                            % endif
-                                           @input="contactChanged">
+                                           % if request.use_oruga:
+                                               :assigned-label="contactDisplay"
+                                               @update:model-value="contactChanged"
+                                           % else:
+                                               :initial-label="contactDisplay"
+                                               @input="contactChanged"
+                                           % endif
+                                           >
                     </tailbone-autocomplete>
-                  </b-field>
-                  <div v-if="contactUUID">
-                    <b-button v-if="contactProfileURL"
+                    <b-button v-if="contactUUID && contactProfileURL"
                               type="is-primary"
                               tag="a" target="_blank"
                               :href="contactProfileURL"
@@ -138,8 +175,8 @@
                               icon-left="external-link-alt">
                       View Profile
                     </b-button>
-                    &nbsp;
-                    <b-button @click="refreshContact"
+                    <b-button v-if="contactUUID"
+                              @click="refreshContact"
                               icon-pack="fas"
                               icon-left="redo"
                               :disabled="refreshingContact">
@@ -183,8 +220,13 @@
                                 Edit
                               </b-button>
 
-                              <b-modal has-modal-card
-                                       :active.sync="editPhoneNumberShowDialog">
+                              <${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">
@@ -238,7 +280,7 @@
                                     </b-button>
                                   </footer>
                                 </div>
-                              </b-modal>
+                              </${b}-modal>
 
                             </div>
                         % endif
@@ -276,8 +318,13 @@
                                         icon-left="edit">
                                 Edit
                               </b-button>
-                              <b-modal has-modal-card
-                                       :active.sync="editEmailAddressShowDialog">
+                              <${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">
@@ -331,7 +378,7 @@
                                     </b-button>
                                   </footer>
                                 </div>
-                              </b-modal>
+                              </${b}-modal>
                             </div>
                         % endif
                       </div>
@@ -406,8 +453,13 @@
                 </b-notification>
               </div>
 
-              <b-modal has-modal-card
-                       :active.sync="editNewCustomerShowDialog">
+              <${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">
@@ -449,61 +501,85 @@
                     </b-button>
                   </footer>
                 </div>
-              </b-modal>
+              </${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 v-html="itemsPanelHeader"></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>
             <div class="buttons">
               <b-button type="is-primary"
                         icon-pack="fas"
-                        icon-left="fas fa-plus"
+                        icon-left="plus"
                         @click="showAddItemDialog()">
                 Add Item
               </b-button>
               % if allow_past_item_reorder:
               <b-button v-if="contactUUID"
                         icon-pack="fas"
-                        icon-left="fas fa-plus"
+                        icon-left="plus"
                         @click="showAddPastItem()">
                 Add Past Item
               </b-button>
               % endif
             </div>
 
-            <b-modal :active.sync="showingItemDialog">
+            <${b}-modal
+              % if request.use_oruga:
+                  v-model:active="showingItemDialog"
+              % else:
+                  :active.sync="showingItemDialog"
+              % endif
+              >
               <div class="card">
                 <div class="card-content">
 
-                  <b-tabs type="is-boxed is-toggle"
-                          v-model="itemDialogTabIndex"
-                          :animated="false">
+                  <${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">
+                    <${b}-tab-item label="Product"
+                                   value="product">
 
                       <div class="field">
                         <b-radio v-model="productIsKnown"
@@ -513,107 +589,82 @@
                       </div>
 
                       <div v-show="productIsKnown"
-                           style="padding-left: 5rem;">
+                           style="padding-left: 3rem; display: flex; gap: 1rem;">
 
-                        <b-field grouped>
-                          <p class="label control">
-                            Product
-                          </p>
-                          <b-field :expanded="!productUUID">
-                            <tailbone-autocomplete ref="productAutocomplete"
-                                                   v-model="productUUID"
-                                                   placeholder="Enter UPC or brand, description etc."
-                                                   :assigned-label="productDisplay"
-                                                   serviceUrl="${url('{}.product_autocomplete'.format(route_prefix))}"
-                                                   @input="productChanged">
-                            </tailbone-autocomplete>
+                        <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>
 
-                          <b-button type="is-primary"
-                                    v-if="!productUUID"
-                                    @click="productFullLookup()"
-                                    icon-pack="fas"
-                                    icon-left="search">
-                            Full Lookup
-                          </b-button>
+                          <div v-if="productUUID">
 
-                          <b-button v-if="productUUID"
-                                    type="is-primary"
-                                    tag="a" target="_blank"
-                                    :href="productURL"
-                                    :disabled="!productURL"
-                                    icon-pack="fas"
-                                    icon-left="external-link-alt">
-                            View Product
-                          </b-button>
-                        </b-field>
+                            <b-field grouped>
+                              <b-field :label="productKeyLabel">
+                                <span>{{ productKey }}</span>
+                              </b-field>
 
-                        <div v-if="productUUID">
+                              <b-field label="Unit Size">
+                                <span>{{ productSize || '' }}</span>
+                              </b-field>
 
-                          <div class="is-pulled-right has-text-centered">
-                            <img :src="productImageURL"
-                                 style="height: 150px; width: 150px; "/>
-                            ## <p>{{ productKey }}</p>
+                              <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>
-
-                          <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>
 
+                        <img v-if="productUUID"
+                             :src="productImageURL"
+                             style="max-height: 150px; max-width: 150px; "/>
+
                       </div>
 
                       <br />
@@ -632,18 +683,29 @@
 
                         <b-field grouped>
 
-                          <b-field label="Brand">
+                          <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"
-                                   :type="pendingProduct.description ? null : 'is-danger'">
+                                   % 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">
+                          <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>
@@ -652,12 +714,20 @@
 
                         <b-field grouped>
 
-                          <b-field :label="productKeyLabel">
+                          <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">
+                          <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"
@@ -668,34 +738,33 @@
                             </b-select>
                           </b-field>
 
-                          <b-field label="Unit Reg. Price">
-                            <b-input v-model="pendingProduct.regular_price_amount"
-                                     type="number" step="0.01">
-                            </b-input>
-                          </b-field>
-
                         </b-field>
 
                         <b-field grouped>
 
-                          <b-field label="Vendor">
+                          <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">
+                          <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="Unit Cost">
-                            <b-input v-model="pendingProduct.unit_cost"
-                                     type="number" step="0.01"
-                                     style="width: 10rem;">
-                            </b-input>
-                          </b-field>
-
-                          <b-field label="Case Size">
+                          <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;">
@@ -704,132 +773,181 @@
 
                         </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">
-                          </b-input>
+                                   type="textarea"
+                                   expanded />
                         </b-field>
 
                       </div>
-                    </b-tab-item>
-                    <b-tab-item label="Quantity">
+                    </${b}-tab-item>
+                    <${b}-tab-item label="Quantity"
+                                   value="quantity">
 
-                      <div class="is-pulled-right has-text-centered">
-                        <img :src="productImageURL"
-                             style="height: 150px; width: 150px; "/>
-                      </div>
+                      <div style="display: flex; gap: 1rem; white-space: nowrap;">
 
-                      <b-field grouped>
-                        <b-field label="Product" horizontal>
-                          <span :class="productIsKnown ? null : 'has-text-success'">
-                            {{ 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 }}
-                          </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">
-                          </numeric-input>
-                        </b-field>
-
-                        <b-select v-model="productUOM">
-                          <option v-for="choice in productUnitChoices"
-                                  :key="choice.key"
-                                  :value="choice.key"
-                                  v-html="choice.value">
-                          </option>
-                        </b-select>
-
-                      </b-field>
-
-                      <b-field grouped>
-                        % if allow_item_discounts:
-                            <b-field label="Discount" horizontal>
-                              <div class="level">
-                                <div class="level-item">
-                                      <numeric-input v-model="productDiscountPercent"
-                                                     style="width: 5rem;">
-                                      </numeric-input>
-                                </div>
-                                <div class="level-item">
-                                  <span>&nbsp;%</span>
-                                </div>
-                              </div>
+                        <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>
-                        % endif
-                        <b-field label="Total Price" horizontal expanded>
-                          <span :class="productSalePriceDisplay ? 'has-background-warning': null">
-                            {{ getItemTotalPriceDisplay() }}
-                          </span>
-                        </b-field>
-                      </b-field>
+                          </b-field>
 
-                    </b-tab-item>
-                  </b-tabs>
+                          <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">
@@ -840,105 +958,167 @@
                               :disabled="itemDialogSaveDisabled"
                               icon-pack="fas"
                               icon-left="save">
-                      {{ itemDialogSaveButtonText }}
+                      {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }}
                     </b-button>
                   </div>
 
                 </div>
               </div>
-            </b-modal>
+            </${b}-modal>
 
-            <tailbone-product-lookup ref="productLookup"
-                                     @canceled="productLookupCanceled"
-                                     @selected="productLookupSelected">
-            </tailbone-product-lookup>
+            % 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 :active.sync="pastItemsShowDialog">
+            <${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"
-                           :selected.sync="pastItemsSelected"
-                           sortable
-                           paginated
-                           per-page="5"
-                           :debounce-search="1000">
-                    <template slot-scope="props">
+                  <${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"
-                                      sortable>
-                        {{ props.row.key }}
-                      </b-table-column>
+                    <${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"
-                                      sortable
-                                      searchable>
-                        {{ props.row.brand_name }}
-                      </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"
-                                      sortable
-                                      searchable>
-                        {{ props.row.description }}
-                        {{ props.row.size }}
-                      </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"
-                                      sortable>
-                        {{ props.row.unit_price_display }}
-                      </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"
-                                      sortable>
-                        <span class="has-background-warning">
-                          {{ props.row.sale_price_display }}
-                        </span>
-                      </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"
-                                      sortable>
-                        <span class="has-background-warning">
-                          {{ props.row.sale_ends_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"
-                                      sortable
-                                      searchable>
-                        {{ props.row.department_name }}
-                      </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"
-                                      sortable
-                                      searchable>
-                        {{ props.row.vendor_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>
-                    <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>
                         <p>Nothing here.</p>
                       </div>
                     </template>
-                  </b-table>
+                  </${b}-table>
 
                   <div class="buttons">
                     <b-button @click="pastItemsShowDialog = false">
@@ -955,93 +1135,125 @@
 
                 </div>
               </div>
-            </b-modal>
+            </${b}-modal>
             % endif
 
-            <b-table v-if="items.length"
+            <${b}-table v-if="items.length"
                      :data="items"
                      :row-class="(row, i) => row.product_uuid ? null : 'has-text-success'">
-              <template slot-scope="props">
 
-                <b-table-column :label="productKeyLabel">
-                  {{ props.row.product_key }}
-                </b-table-column>
+              <${b}-table-column :label="productKeyLabel"
+                              v-slot="props">
+                {{ props.row.product_key }}
+              </${b}-table-column>
 
-                <b-table-column label="Brand">
-                  {{ props.row.product_brand }}
-                </b-table-column>
+              <${b}-table-column label="Brand"
+                              v-slot="props">
+                {{ props.row.product_brand }}
+              </${b}-table-column>
 
-                <b-table-column label="Description">
-                  {{ props.row.product_description }}
-                </b-table-column>
+              <${b}-table-column label="Description"
+                              v-slot="props">
+                {{ props.row.product_description }}
+              </${b}-table-column>
 
-                <b-table-column label="Size">
-                  {{ props.row.product_size }}
-                </b-table-column>
+              <${b}-table-column label="Size"
+                              v-slot="props">
+                {{ props.row.product_size }}
+              </${b}-table-column>
 
-                <b-table-column label="Department">
-                  {{ props.row.department_display }}
-                </b-table-column>
+              <${b}-table-column label="Department"
+                              v-slot="props">
+                {{ props.row.department_display }}
+              </${b}-table-column>
 
-                <b-table-column label="Quantity">
-                  <span v-html="props.row.order_quantity_display"></span>
-                </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">
-                  <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>
+              <${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">
-                      {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }}
-                    </b-table-column>
-                % endif
+              % 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">
-                  <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="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">
-                  {{ props.row.vendor_display }}
-                </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">
-                  <a href="#" class="grid-action"
-                     @click.prevent="showEditItemDialog(props.row)">
-                    <i class="fas fa-edit"></i>
-                    Edit
-                  </a>
-                  &nbsp;
+              <${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="#" class="grid-action has-text-danger"
-                     @click.prevent="deleteItem(props.index)">
-                    <i class="fas fa-trash"></i>
-                    Delete
-                  </a>
-                  &nbsp;
-                </b-table-column>
+                <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>
 
-              </template>
-            </b-table>
+            </${b}-table>
           </div>
         </div>
-      </b-collapse>
+      </${b}-collapse>
 
       ${self.order_form_buttons()}
 
@@ -1052,15 +1264,11 @@
 
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  ${product_lookup.tailbone_product_lookup_component()}
-  <script type="text/javascript">
+  <script>
 
     const CustomerOrderCreator = {
         template: '#customer-order-creator-template',
+        mixins: [SimpleRequestMixin],
         data() {
 
             let defaultUnitChoices = ${json.dumps(default_uom_choices)|n}
@@ -1124,7 +1332,11 @@
                 editingItem: null,
                 showingItemDialog: false,
                 itemDialogSaving: false,
-                itemDialogTabIndex: 0,
+                % if request.use_oruga:
+                    itemDialogTab: 'product',
+                % else:
+                    itemDialogTabIndex: 0,
+                % endif
                 % if allow_past_item_reorder:
                 pastItemsShowDialog: false,
                 pastItemsLoading: false,
@@ -1132,6 +1344,7 @@
                 pastItemsSelected: null,
                 % endif
                 productIsKnown: true,
+                selectedProduct: null,
                 productUUID: null,
                 productDisplay: null,
                 productKey: null,
@@ -1161,14 +1374,20 @@
                 % endif
 
                 % if allow_item_discounts:
-                    productDiscountPercent: null,
+                    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
 
-                ## 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},
+                // nb. hack to force refresh for vue3
+                refreshProductDescription: 1,
+                refreshTotalPrice: 1,
 
                 submittingOrder: false,
             }
@@ -1200,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'
                     }
                 }
             },
@@ -1344,38 +1561,53 @@
                 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 {
-                    if (!this.pendingProduct.description) {
-                        return true
+                    for (let field of this.pendingProductRequiredFields) {
+                        if (!this.pendingProduct[field]) {
+                            return true
+                        }
                     }
                 }
+
                 if (!this.productUOM) {
                     return true
                 }
+
                 return false
             },
-
-            itemDialogSaveButtonText() {
-                if (this.itemDialogSaving) {
-                    return "Working, please wait..."
-                }
-                return this.editingItem ? "Update Item" : "Add Item"
-            },
-
-            submitOrderButtonText() {
-                if (this.submittingOrder) {
-                    return "Working, please wait..."
-                }
-                return "Submit this Order"
-            },
         },
         mounted() {
             if (this.customerStatusType) {
@@ -1403,6 +1635,18 @@
                     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()
+                }
+            },
         },
         methods: {
 
@@ -1458,31 +1702,11 @@
             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 (response.data.error) {
-                        this.$buefy.toast.open({
-                            message: response.data.error,
-                            type: 'is-danger',
-                            duration: 2000, // 2 seconds
-                        })
-                        if (failure) {
-                            failure(response)
-                        }
-                    } else if (success) {
+                this.simplePOST(url, params, response => {
+                    if (success) {
                         success(response)
                     }
                 }, response => {
-                    this.$buefy.toast.open({
-                        message: "Unexpected error occurred",
-                        type: 'is-danger',
-                        duration: 2000, // 2 seconds
-                    })
                     if (failure) {
                         failure(response)
                     }
@@ -1526,22 +1750,21 @@
                         uuid: this.contactUUID,
                     }
                 }
-                let that = this
-                this.submitBatchData(params, function(response) {
+                this.submitBatchData(params, response => {
                     % if new_order_requires_customer:
-                    that.contactUUID = response.data.customer_uuid
+                    this.contactUUID = response.data.customer_uuid
                     % else:
-                    that.contactUUID = response.data.person_uuid
+                    this.contactUUID = response.data.person_uuid
                     % endif
-                    that.contactDisplay = response.data.contact_display
-                    that.orderPhoneNumber = response.data.phone_number
-                    that.orderEmailAddress = response.data.email_address
-                    that.addOtherPhoneNumber = response.data.add_phone_number
-                    that.addOtherEmailAddress = response.data.add_email_address
-                    that.contactProfileURL = response.data.contact_profile_url
-                    that.contactPhones = response.data.contact_phones
-                    that.contactEmails = response.data.contact_emails
-                    that.contactNotes = response.data.contact_notes
+                    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()
                     }
@@ -1773,20 +1996,12 @@
                 }
             },
 
-            productFullLookup() {
-                this.showingItemDialog = false
-                let term = this.$refs.productAutocomplete.getUserInput()
-                this.$refs.productLookup.showDialog(term)
-            },
-
-            productLookupCanceled() {
-                this.showingItemDialog = true
-            },
-
             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.uuid)
-                this.showingItemDialog = true
+                this.productChanged(selected)
             },
 
             copyPendingProductAttrs(from, to) {
@@ -1809,6 +2024,7 @@
                 this.customerPanelOpen = false
                 this.editingItem = null
                 this.productIsKnown = true
+                this.selectedProduct = null
                 this.productUUID = null
                 this.productDisplay = null
                 this.productKey = null
@@ -1835,13 +2051,17 @@
                 % endif
 
                 % if allow_item_discounts:
-                    this.productDiscountPercent = null
+                    this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
                 % endif
 
-                this.itemDialogTabIndex = 0
+                % if request.use_oruga:
+                    this.itemDialogTab = 'product'
+                % else:
+                    this.itemDialogTabIndex = 0
+                % endif
                 this.showingItemDialog = true
                 this.$nextTick(() => {
-                    this.$refs.productAutocomplete.focus()
+                    this.$refs.productLookup.focus()
                 })
             },
 
@@ -1894,7 +2114,15 @@
                 this.productPriceNeedsConfirmation = false
                 % endif
 
-                this.itemDialogTabIndex = 1
+                // 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
             },
 
@@ -1905,12 +2133,25 @@
 
                 this.productIsKnown = !!row.product_uuid
                 this.productUUID = row.product_uuid
-                this.pendingProduct = {}
-                if (row.pending_product) {
-                    this.copyPendingProductAttrs(row.pending_product,
-                                                 this.pendingProduct)
+
+                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
@@ -1938,7 +2179,15 @@
                     this.productDiscountPercent = row.discount_percent
                 % endif
 
-                this.itemDialogTabIndex = 1
+                // 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
             },
 
@@ -1983,6 +2232,10 @@
                 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
@@ -2003,11 +2256,11 @@
                 }
             },
 
-            productChanged(uuid) {
-                if (uuid) {
+            productChanged(product) {
+                if (product) {
                     let params = {
                         action: 'get_product_info',
-                        uuid: uuid,
+                        uuid: product.uuid,
                     }
                     // nb. it is possible for the handler to "swap"
                     // the product selection, i.e. user chooses a "per
@@ -2016,6 +2269,8 @@
                     // 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
@@ -2029,6 +2284,11 @@
                         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)
@@ -2037,7 +2297,15 @@
                         this.productPriceNeedsConfirmation = false
                         % endif
 
-                        this.itemDialogTabIndex = 1
+                        % 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()
@@ -2047,7 +2315,7 @@
                 }
             },
 
-            itemDialogSave() {
+            itemDialogAttemptSave() {
                 this.itemDialogSaving = true
 
                 let params = {
@@ -2095,15 +2363,44 @@
 
                     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
index 030b0ade..4cc92bbf 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -1,7 +1,7 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <${form.component} ref="mainForm"
                        % if master.has_perm('confirm_price'):
@@ -9,6 +9,7 @@
                        % endif
                        % if master.has_perm('change_status'):
                        @change-status="showChangeStatus"
+                       @mark-received="markReceivedInit"
                        % endif
                        % if master.has_perm('add_note'):
                        @add-note="showAddNote"
@@ -61,6 +62,67 @@
   % 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">
@@ -106,47 +168,50 @@
                        :checked-rows.sync="changeStatusCheckedRows"
                        narrowed 
                        class="is-size-7">
-                <template slot-scope="props">
-                  <b-table-column field="product_brand" label="Brand">
-                    <span v-html="props.row.product_brand"></span>
-                  </b-table-column>
-                  <b-table-column field="product_description" label="Product">
-                    <span v-html="props.row.product_description"></span>
-                  </b-table-column>
-                  <!-- <b-table-column field="quantity" label="Quantity"> -->
-                  <!--   <span v-html="props.row.quantity"></span> -->
-                  <!-- </b-table-column> -->
-                  <b-table-column field="product_case_quantity" label="cPack">
-                    <span v-html="props.row.product_case_quantity"></span>
-                  </b-table-column>
-                  <b-table-column field="order_quantity" label="oQty">
-                    <span v-html="props.row.order_quantity"></span>
-                  </b-table-column>
-                  <b-table-column field="order_uom" label="UOM">
-                    <span v-html="props.row.order_uom"></span>
-                  </b-table-column>
-                  <b-table-column field="department_name" label="Department">
-                    <span v-html="props.row.department_name"></span>
-                  </b-table-column>
-                  <b-table-column field="product_barcode" label="Product Barcode">
-                    <span v-html="props.row.product_barcode"></span>
-                  </b-table-column>
-                  <b-table-column field="unit_price" label="Unit $">
-                    <span v-html="props.row.unit_price"></span>
-                  </b-table-column>
-                  <b-table-column field="total_price" label="Total $">
-                    <span v-html="props.row.total_price"></span>
-                  </b-table-column>
-                  <b-table-column field="order_date" label="Order Date">
-                    <span v-html="props.row.order_date"></span>
-                  </b-table-column>
-                  <b-table-column field="status_code" label="Status">
-                    <span v-html="props.row.status_code"></span>
-                  </b-table-column>
-                  <!-- <b-table-column field="flagged" label="Flagged"> -->
-                  <!--   <span v-html="props.row.flagged"></span> -->
-                  <!-- </b-table-column> -->
-                </template>
+                <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 />
@@ -215,7 +280,7 @@
                       :disabled="addNoteSaveDisabled"
                       icon-pack="fas"
                       icon-left="save">
-              {{ addNoteSubmitText }}
+              {{ addNoteSubmitting ? "Working, please wait..." : "Save Note" }}
             </b-button>
             <b-button @click="showAddNoteDialog = false">
               Cancel
@@ -226,11 +291,11 @@
   % 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>
 
-    ${form.component_studly}Data.notesData = ${json.dumps(notes_data)|n}
+    ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n}
 
     % if master.has_perm('confirm_price'):
 
@@ -269,8 +334,20 @@
 
     % 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 six.iteritems(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}
 
@@ -315,6 +392,12 @@
             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'):
@@ -323,7 +406,6 @@
         ThisPageData.newNoteText = null
         ThisPageData.newNoteApplyAll = false
         ThisPageData.addNoteSubmitting = false
-        ThisPageData.addNoteSubmitText = "Save Note"
 
         ThisPage.computed.addNoteSaveDisabled = function() {
             if (!this.newNoteText) {
@@ -346,43 +428,19 @@
 
         ThisPage.methods.addNoteSave = function() {
             this.addNoteSubmitting = true
-            this.addNoteSubmitText = "Working, please wait..."
 
             let url = '${url('{}.add_note'.format(route_prefix), uuid=instance.uuid)}'
-
             let params = {
                 note: this.newNoteText,
                 apply_all: this.newNoteApplyAll,
             }
 
-            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.success) {
-                    this.$refs.mainForm.notesData = data.notes
-                    this.showAddNoteDialog = false
-                } else {
-                    this.$buefy.toast.open({
-                        message: "Save failed:  " + (data.error || "(unknown error)"),
-                        type: 'is-danger',
-                        duration: 4000, // 4 seconds
-                    })
-                }
+            this.simplePOST(url, params, response => {
+                this.$refs.mainForm.eventsData = response.data.events
+                this.showAddNoteDialog = false
                 this.addNoteSubmitting = false
-                this.addNoteSubmitText = "Save Note"
-            }).catch((error) => {
-                // TODO: should handle this better somehow..?
-                this.$buefy.toast.open({
-                    message: "Save failed:  (unknown error)",
-                    type: 'is-danger',
-                    duration: 4000, // 4 seconds
-                })
+            }, response => {
                 this.addNoteSubmitting = false
-                this.addNoteSubmitText = "Save Note"
             })
         }
 
@@ -390,5 +448,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
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 e92c3c3c..86f5c121 100644
--- a/tailbone/templates/datasync/changes/index.mako
+++ b/tailbone/templates/datasync/changes/index.mako
@@ -1,57 +1,34 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if request.has_perm('datasync.status'):
-      <li>${h.link_to("View DataSync Status", url('datasync.status'))}</li>
-  % endif
-</%def>
-
 <%def name="grid_tools()">
   ${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
@@ -73,6 +50,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 014668be..2e444fb5 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -1,6 +1,15 @@
 ## -*- 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">
@@ -48,7 +57,12 @@
   ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})}
 
   <b-notification type="is-warning"
-                  :active.sync="showConfigFilesNote">
+                  % 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
@@ -69,8 +83,8 @@
   </b-notification>
 
   <b-field>
-    <b-checkbox name="use_profile_settings"
-                v-model="useProfileSettings"
+    <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
@@ -85,7 +99,7 @@
     </div>
     <div class="level-right">
       <div class="level-item"
-           v-show="useProfileSettings">
+           v-show="simpleSettings['rattail.datasync.use_profile_settings']">
         <b-button type="is-primary"
                   @click="newProfile()"
                   icon-pack="fas"
@@ -101,61 +115,89 @@
     </div>
   </div>
 
-  <b-table :data="filteredProfilesData"
-           :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
-      <template slot-scope="props">
-        <b-table-column field="key" label="Watcher Key">
-          {{ props.row.key }}
-        </b-table-column>
-        <b-table-column field="watcher_spec" label="Watcher Spec">
-          {{ props.row.watcher_spec }}
-        </b-table-column>
-        <b-table-column field="watcher_dbkey" label="DB Key">
-          {{ props.row.watcher_dbkey }}
-        </b-table-column>
-        <b-table-column field="watcher_delay" label="Loop Delay">
-          {{ props.row.watcher_delay }} sec
-        </b-table-column>
-        <b-table-column field="watcher_retry_attempts" label="Attempts / Delay">
-          {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec
-        </b-table-column>
-        <b-table-column field="watcher_default_runas" label="Default Runas">
-          {{ props.row.watcher_default_runas }}
-        </b-table-column>
-        <b-table-column label="Consumers">
-          {{ consumerShortList(props.row) }}
-        </b-table-column>
-##         <b-table-column field="notes" label="Notes">
+  <${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">
-          {{ props.row.enabled ? "Yes" : "No" }}
-        </b-table-column>
-        <b-table-column label="Actions"
-                        v-if="useProfileSettings">
-          <a href="#"
-             class="grid-action"
-             @click.prevent="editProfile(props.row)">
-            <i class="fas fa-edit"></i>
-            Edit
-          </a>          
-          &nbsp;
-          <a href="#"
-             class="grid-action has-text-danger"
-             @click.prevent="deleteProfile(props.row)">
-            <i class="fas fa-trash"></i>
-            Delete
-          </a>
-        </b-table-column>
-      </template>
-      <template slot="empty">
+##         </${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="fas fa-sad-tear"
+                 icon="sad-tear"
                  size="is-large">
               </b-icon>
             </p>
@@ -163,7 +205,7 @@
           </div>
         </section>
       </template>
-  </b-table>
+  </${b}-table>
 
   <b-modal :active.sync="editProfileShowDialog">
     <div class="card">
@@ -185,12 +227,12 @@
 
         </b-field>
 
-        <b-field grouped>
+        <b-field grouped expanded>
 
           <b-field label="Watcher Spec" 
                    :type="editingProfileWatcherSpec ? null : 'is-danger'"
                    expanded>
-            <b-input v-model="editingProfileWatcherSpec">
+            <b-input v-model="editingProfileWatcherSpec" expanded>
             </b-input>
           </b-field>
 
@@ -279,37 +321,54 @@
           </div>
 
 
-          <b-table :data="editingProfilePendingWatcherKwargs"
+          <${b}-table :data="editingProfilePendingWatcherKwargs"
                    style="margin-left: 1rem;">
-            <template slot-scope="props">
-              <b-table-column field="key" label="Key">
-                {{ props.row.key }}
-              </b-table-column>
-              <b-table-column field="value" label="Value">
-                {{ props.row.value }}
-              </b-table-column>
-              <b-table-column label="Actions">
-                <a href="#"
-                   @click.prevent="editProfileWatcherKwarg(props.row)">
-                  <i class="fas fa-edit"></i>
-                  Edit
-                </a>
-                &nbsp;
-                <a href="#"
-                   class="has-text-danger"
-                   @click.prevent="deleteProfileWatcherKwarg(props.row)">
-                  <i class="fas fa-trash"></i>
-                  Delete
-                </a>
-              </b-table-column>
-            </template>
-            <template slot="empty">
+            <${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="fas fa-sad-tear"
+                      icon="sad-tear"
                       size="is-large">
                     </b-icon>
                   </p>
@@ -317,7 +376,7 @@
                 </div>
               </section>
             </template>
-          </b-table>
+          </${b}-table>
 
         </div>
 
@@ -333,39 +392,55 @@
               </b-checkbox>
             </b-field>
 
-            <b-table :data="editingProfilePendingConsumers"
+            <${b}-table :data="editingProfilePendingConsumers"
                      v-if="!editingProfileWatcherConsumesSelf"
                      :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
-              <template slot-scope="props">
-                <b-table-column field="key" label="Consumer">
-                  {{ props.row.key }}
-                </b-table-column>
-                <b-table-column style="white-space: nowrap;">
-                  {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }}
-                </b-table-column>
-                <b-table-column label="Actions">
-                  <a href="#"
-                     class="grid-action"
-                     @click.prevent="editProfileConsumer(props.row)">
-                    <i class="fas fa-edit"></i>
-                    Edit
-                  </a>          
-                  &nbsp;
-                  <a href="#"
-                     class="grid-action has-text-danger"
-                     @click.prevent="deleteProfileConsumer(props.row)">
-                    <i class="fas fa-trash"></i>
-                    Delete
-                  </a>
-                </b-table-column>
-              </template>
-              <template slot="empty">
+              <${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="fas fa-sad-tear"
+                        icon="sad-tear"
                         size="is-large">
                       </b-icon>
                     </p>
@@ -373,7 +448,7 @@
                   </div>
                 </section>
               </template>
-            </b-table>
+            </${b}-table>
 
           </div>
 
@@ -505,31 +580,41 @@
   <b-field label="Supervisor Process Name"
            message="This should be the complete name, including group - e.g. poser:poser_datasync"
            expanded>
-    <b-input name="supervisor_process_name"
-             v-model="supervisorProcessName"
-             @input="settingsNeedSaved = true">
+    <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="restart_command"
-             v-model="restartCommand"
-             @input="settingsNeedSaved = true">
+    <b-input name="tailbone.datasync.restart"
+             v-model="simpleSettings['tailbone.datasync.restart']"
+             @input="settingsNeedSaved = true"
+             expanded>
     </b-input>
   </b-field>
 
 </%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.showConfigFilesNote = false
     ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
     ThisPageData.showDisabledProfiles = false
-    ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n}
 
     ThisPageData.editProfileShowDialog = false
     ThisPageData.editingProfile = null
@@ -554,22 +639,6 @@
     ThisPageData.editingConsumerRunas = null
     ThisPageData.editingConsumerEnabled = true
 
-    ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
-    ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
-
-    ThisPage.computed.filteredProfilesData = function() {
-        if (this.showDisabledProfiles) {
-            return this.profilesData
-        }
-        let data = []
-        for (let row of this.profilesData) {
-            if (row.enabled) {
-                data.push(row)
-            }
-        }
-        return data
-    }
-
     ThisPage.computed.updateConsumerDisabled = function() {
         if (!this.editingConsumerKey) {
             return true
@@ -597,6 +666,15 @@
         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) {
@@ -612,7 +690,7 @@
     }
 
     ThisPage.methods.newProfile = function() {
-        this.editingProfile = {}
+        this.editingProfile = {watcher_kwargs_data: []}
         this.editingConsumer = null
         this.editingWatcherKwargs = false
 
@@ -661,16 +739,9 @@
 
         this.editingProfilePendingConsumers = []
         for (let consumer of row.consumers_data) {
-            let pending = {
+            const pending = {
+                ...consumer,
                 original_key: consumer.key,
-                key: consumer.key,
-                consumer_spec: consumer.consumer_spec,
-                consumer_dbkey: consumer.consumer_dbkey,
-                consumer_delay: consumer.consumer_delay,
-                consumer_retry_attempts: consumer.consumer_retry_attempts,
-                consumer_retry_delay: consumer.consumer_retry_delay,
-                consumer_runas: consumer.consumer_runas,
-                enabled: consumer.enabled,
             }
             this.editingProfilePendingConsumers.push(pending)
         }
@@ -718,8 +789,8 @@
         this.editingProfilePendingWatcherKwargs.splice(i, 1)
     }
 
-    ThisPage.methods.findOriginalConsumer = function(key) {
-        for (let consumer of this.editingProfile.consumers_data) {
+    ThisPage.methods.findConsumer = function(profileConsumers, key) {
+        for (const consumer of profileConsumers) {
             if (consumer.key == key) {
                 return consumer
             }
@@ -727,11 +798,15 @@
     }
 
     ThisPage.methods.updateProfile = function() {
-        let row = this.editingProfile
+        const row = this.editingProfile
 
-        if (!row.key) {
+        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
@@ -779,7 +854,8 @@
         for (let pending of this.editingProfilePendingConsumers) {
             persistentConsumers.push(pending.key)
             if (pending.original_key) {
-                let consumer = this.findOriginalConsumer(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
@@ -806,10 +882,31 @@
             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)
@@ -846,8 +943,10 @@
     }
 
     ThisPage.methods.updateConsumer = function() {
-        let pending = this.editingConsumer
-        let isNew = !pending.key
+        const pending = this.findConsumer(
+            this.editingProfilePendingConsumers,
+            this.editingConsumer.key)
+        const isNew = !pending.key
 
         pending.key = this.editingConsumerKey
         pending.consumer_spec = this.editingConsumerSpec
@@ -888,6 +987,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako
index 0d0f5994..e14686f8 100644
--- a/tailbone/templates/datasync/status.mako
+++ b/tailbone/templates/datasync/status.mako
@@ -5,13 +5,6 @@
 
 <%def name="content_title()"></%def>
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if request.has_perm('datasync_changes.list'):
-      <li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li>
-  % endif
-</%def>
-
 <%def name="page_content()">
   % if expose_websockets and not supervisor_error:
       <b-notification type="is-warning"
@@ -47,73 +40,84 @@
     </div>
   </b-field>
 
-  <b-field label="Watcher Status">
-    <b-table :data="watchers">
-      <template slot-scope="props">
-        <b-table-column field="key"
-                        label="Watcher">
-           {{ props.row.key }}
-        </b-table-column>
-        <b-table-column field="spec"
-                        label="Spec">
-           {{ props.row.spec }}
-        </b-table-column>
-        <b-table-column field="dbkey"
-                        label="DB Key">
-           {{ props.row.dbkey }}
-        </b-table-column>
-        <b-table-column field="delay"
-                        label="Delay">
-           {{ props.row.delay }} second(s)
-        </b-table-column>
-        <b-table-column field="lastrun"
-                        label="Last Watched">
-           <span v-html="props.row.lastrun"></span>
-        </b-table-column>
-        <b-table-column field="status"
-                        label="Status"
-                        :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
-           {{ props.row.status }}
-        </b-table-column>
-      </template>
-    </b-table>
-  </b-field>
+  <h3 class="is-size-3">Watcher Status</h3>
 
-  <b-field label="Consumer Status">
-    <b-table :data="consumers">
-      <template slot-scope="props">
-        <b-table-column field="key"
-                        label="Consumer">
-           {{ props.row.key }}
-        </b-table-column>
-        <b-table-column field="spec"
-                        label="Spec">
-           {{ props.row.spec }}
-        </b-table-column>
-        <b-table-column field="dbkey"
-                        label="DB Key">
-           {{ props.row.dbkey }}
-        </b-table-column>
-        <b-table-column field="delay"
-                        label="Delay">
-           {{ props.row.delay }} second(s)
-        </b-table-column>
-        <b-table-column field="changes"
-                        label="Pending Changes">
-           {{ props.row.changes }}
-        </b-table-column>
-        <b-table-column field="status"
-                        label="Status"
-                        :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
-           {{ props.row.status }}
-        </b-table-column>
-      </template>
-    </b-table>
-  </b-field>
+    <${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_this_page_vars()">
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.processInfo = ${json.dumps(process_info)|n}
 
@@ -142,8 +146,8 @@
         ThisPage.mounted = function() {
 
             ## TODO: should be a cleaner way to get this url?
-            let url = '${request.route_url('ws.datasync.status')}'
-            url = url.replace(/^https?:/, 'wss:')
+            let url = '${url('ws.datasync.status')}'
+            url = url.replace(/^http(s?):/, 'ws$1:')
 
             this.ws = new WebSocket(url)
             let that = this
@@ -168,6 +172,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt
index dd9a6084..7a15c7f0 100644
--- a/tailbone/templates/deform/autocomplete_jquery.pt
+++ b/tailbone/templates/deform/autocomplete_jquery.pt
@@ -3,109 +3,10 @@
                  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}"
diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt
index db4a49e0..b30d1d63 100644
--- a/tailbone/templates/deform/cases_units.pt
+++ b/tailbone/templates/deform/cases_units.pt
@@ -2,38 +2,11 @@
 <div tal:define="oid oid|field.oid;
                  name name|field.name;
                  css_class css_class|field.widget.css_class;
-                 style style|field.widget.style;
-                 use_buefy use_buefy|0;"
+                 style style|field.widget.style;"
      i18n:domain="deform"
      tal:omit-tag="">
 
-  <div tal:condition="not use_buefy" 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>
-    <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>
-
-  <div tal:condition="use_buefy"
-       tal:define="vmodel vmodel|'field_model_' + name;"
+  <div tal:define="vmodel vmodel|'field_model_' + name;"
        tal:omit-tag="">
 
     ${field.start_mapping()}
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/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 0539b99a..c55021b9 100644
--- a/tailbone/templates/deform/date_jquery.pt
+++ b/tailbone/templates/deform/date_jquery.pt
@@ -3,40 +3,10 @@
                  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 3cb83a5d..af78eaf9 100644
--- a/tailbone/templates/deform/file_upload.pt
+++ b/tailbone/templates/deform/file_upload.pt
@@ -3,30 +3,13 @@
                        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 40aa71f1..d76e5848 100644
--- a/tailbone/templates/deform/percentinput.pt
+++ b/tailbone/templates/deform/percentinput.pt
@@ -8,26 +8,7 @@
                   autocomplete autocomplete|field.widget.autocomplete|'off';"
       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;
-                           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_' + field_name;">
+  <div tal:define="vmodel vmodel|'field_model_' + field_name;">
     <!-- TODO: need to handle mask somehow? -->
     <b-input name="${field_name}"
              id="${oid}"
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/select.pt b/tailbone/templates/deform/select.pt
index 4295380b..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;
diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt
index a0ee1daf..712830d1 100644
--- a/tailbone/templates/deform/select_dynamic.pt
+++ b/tailbone/templates/deform/select_dynamic.pt
@@ -26,7 +26,7 @@
     <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 52873cb7..47621654 100644
--- a/tailbone/templates/deform/textinput.pt
+++ b/tailbone/templates/deform/textinput.pt
@@ -4,29 +4,10 @@
                   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 tal:attributes="name name;
                              v-model vmodel;
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 1575b3fa..8fa6cbe7 100644
--- a/tailbone/templates/deform/time_jquery.pt
+++ b/tailbone/templates/deform/time_jquery.pt
@@ -4,32 +4,10 @@
                   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 20c2a266..c5c39cbb 100644
--- a/tailbone/templates/departments/view.mako
+++ b/tailbone/templates/departments/view.mako
@@ -1,27 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="page_content()">
-  ${parent.page_content()}
-  % if not use_buefy:
-  <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
-  % endif
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n}
-
+<%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 d24f3a00..f8372c88 100644
--- a/tailbone/templates/email-bounces/view.mako
+++ b/tailbone/templates/email-bounces/view.mako
@@ -1,38 +1,8 @@
 ## -*- 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()}
-  % if not use_buefy:
-  <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>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if use_buefy:
   <style type="text/css">
     .email-message-body {
         border: 1px solid #000000;
@@ -40,81 +10,46 @@
         height: 500px;
     }
   </style>
-  % else:
-  <style type="text/css">
-    #message {
-        border: 1px solid #000000;
-        height: 400px;
-        overflow: auto;
-        padding: 4px;
-    }
-  </style>
-  % endif
 </%def>
 
 <%def name="object_helpers()">
   ${parent.object_helpers()}
-  % if use_buefy:
-      <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
+  <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
-          </div>
-        </div>
-      </nav>
-  % endif
-</%def>
-
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if not use_buefy:
-  % 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
-  % endif
-</%def>
-
-<%def name="page_content()">
-  ${parent.page_content()}
-  % if not use_buefy:
-  <pre id="message">
-    ${message}
-  </pre>
-  % 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="render_this_page()">
   ${parent.render_this_page()}
-  % if use_buefy:
-      <pre class="email-message-body">
-        ${message}
-      </pre>
-  % endif
+  <pre class="email-message-body">${message}</pre>
 </%def>
 
+
 ${parent.body()}
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 11d4d6ae..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">
-    <${form.component}></${form.component}>
+    ${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()">
@@ -51,25 +90,25 @@
 
 <%def name="before_object_helpers()"></%def>
 
-<%def name="render_this_page_template()">
-  % if form is not Underined:
-      ${self.render_form()}
+<%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
index 885ac6c2..d566a467 100644
--- a/tailbone/templates/formposter.mako
+++ b/tailbone/templates/formposter.mako
@@ -3,12 +3,43 @@
 <%def name="declare_formposter_mixin()">
   <script type="text/javascript">
 
-    let FormPosterMixin = {
+    let SimpleRequestMixin = {
         methods: {
 
-            submitForm(action, params, success, failure) {
+            simpleGET(url, params, success, failure) {
 
-                let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
+                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,
@@ -16,10 +47,7 @@
 
                 this.$http.post(action, params, {headers: headers}).then(response => {
 
-                    if (response.data.ok) {
-                        success(response)
-
-                    } else {
+                    if (response.data.error) {
                         this.$buefy.toast.open({
                             message: "Submit failed:  " + (response.data.error ||
                                                            "(unknown error)"),
@@ -29,6 +57,9 @@
                         if (failure) {
                             failure(response)
                         }
+
+                    } else {
+                        success(response)
                     }
 
                 }, response => {
@@ -45,5 +76,9 @@
         },
     }
 
+    // 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 ede55f12..2100b460 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -1,99 +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" />
+  % 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
+      },
+      watch: {},
+      computed: {},
+      methods: {
+
+          ## 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
-      % if getattr(form, 'show_cancel', True):
-          ${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))}
-      % endif
-    </div>
-% endif
 
-% if not readonly:
-${h.end_form()}
-% 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 c387d965..00000000
--- a/tailbone/templates/forms/deform_buefy.mako
+++ /dev/null
@@ -1,116 +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>
-    % if form_body is not Undefined and form_body:
-        ${form_body|n}
-    % else:
-    % 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:
-            ${form.render_buefy_field(field)}
-        % endif
-
-    % endfor
-    % endif
-  </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">
-        % 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 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
-      </div>
-  % endif
-
-  % if not form.readonly:
-  ${h.end_form()}
-  % endif
-  </div>
-</script>
-
-<script type="text/javascript">
-
-  let ${form.component_studly} = {
-      template: '#${form.component}-template',
-      mixins: [FormPosterMixin],
-      components: {},
-      props: {},
-      watch: {},
-      computed: {},
-      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: should find a better way to handle CSRF token
-      csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
-
-      ## 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/util.mako b/tailbone/templates/forms/util.mako
deleted file mode 100644
index 22e7f918..00000000
--- a/tailbone/templates/forms/util.mako
+++ /dev/null
@@ -1,7 +0,0 @@
-## -*- coding: utf-8; -*-
-
-## TODO: deprecate / remove this
-## (tried to add deprecation warning here but it didn't seem to work)
-<%def name="render_buefy_field(field, bfield_kwargs={})">
-  ${form.render_buefy_field(field.name, bfield_attrs=bfield_kwargs)}
-</%def>
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
index acd1db2f..0f2a9f7b 100644
--- a/tailbone/templates/generate_feature.mako
+++ b/tailbone/templates/generate_feature.mako
@@ -5,21 +5,6 @@
 
 <%def name="content_title()"></%def>
 
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-
-    .content.result p {
-        margin-bottom: 1rem;
-    }
-
-    .content.result .codehilite {
-        margin-bottom: 2rem;
-    }
-
-  </style>
-</%def>
-
 <%def name="page_content()">
 
   <b-field horizontal label="App Prefix"
@@ -102,7 +87,7 @@
                   <div class="level-item">
                     <b-button type="is-primary"
                               icon-pack="fas"
-                              icon-left="fas fa-plus"
+                              icon-left="plus"
                               @click="addColumn()">
                       New Column
                     </b-button>
@@ -112,7 +97,7 @@
                   <div class="level-item">
                     <b-button type="is-danger"
                               icon-pack="fas"
-                              icon-left="fas fa-trash"
+                              icon-left="trash"
                               @click="new_table.columns = []"
                               :disabled="!new_table.columns.length">
                       Delete All
@@ -121,47 +106,68 @@
                 </div>
               </div>
 
-              <b-table
+              <${b}-table
                  :data="new_table.columns">
-                <template slot-scope="props">
 
-                  <b-table-column field="name" label="Name">
-                    {{ props.row.name }}
-                  </b-table-column>
+                <${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">
-                    {{ props.row.data_type }}
-                  </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">
-                    {{ props.row.nullable }}
-                  </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">
-                    {{ props.row.description }}
-                  </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">
-                    <a href="#" class="grid-action"
-                       @click.prevent="editColumnRow(props.row)">
-                      <i class="fas fa-edit"></i>
-                      Edit
-                    </a>
-                    &nbsp;
+                <${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)">
-                      <i class="fas fa-trash"></i>
-                      Delete
-                    </a>
-                    &nbsp;
-                  </b-table-column>
+                  <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>
 
-                </template>
-              </b-table>
+              </${b}-table>
 
-              <b-modal has-modal-card
-                       :active.sync="showingEditColumn">
+              <${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">
@@ -171,11 +177,13 @@
                   <section class="modal-card-body">
 
                     <b-field label="Name">
-                      <b-input v-model="editingColumnName"></b-input>
+                      <b-input v-model="editingColumnName"
+                               expanded />
                     </b-field>
 
                     <b-field label="Data Type">
-                      <b-input v-model="editingColumnDataType"></b-input>
+                      <b-input v-model="editingColumnDataType"
+                               expanded />
                     </b-field>
 
                     <b-field label="Nullable">
@@ -186,7 +194,8 @@
                     </b-field>
 
                     <b-field label="Description">
-                      <b-input v-model="editingColumnDescription"></b-input>
+                      <b-input v-model="editingColumnDescription"
+                               expanded />
                     </b-field>
 
                   </section>
@@ -201,7 +210,7 @@
                     </b-button>
                   </footer>
                 </div>
-              </b-modal>
+              </${b}-modal>
 
             </div>
           </b-field>
@@ -261,15 +270,15 @@
       </p>
     </header>
     <div class="card-content">
-      <div class="content result">${rendered_result or ""|n}</div>
+      <div class="content result rendered-markdown">${rendered_result or ""|n}</div>
     </div>
   </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>
 
     ThisPageData.featureType = ${json.dumps(feature_type)|n}
     ThisPageData.resultGenerated = ${json.dumps(bool(result))|n}
@@ -287,7 +296,7 @@
         % endfor
     }
 
-    % for key, form in six.iteritems(feature_forms):
+    % for key, form in feature_forms.items():
         <% safekey = key.replace('-', '_') %>
         ThisPageData.${safekey} = {
             <% dform = feature_forms[key].make_deform_form() %>
@@ -322,6 +331,7 @@
 
     ThisPageData.showingEditColumn = false
     ThisPageData.editingColumn = null
+    ThisPageData.editingColumnIndex = null
     ThisPageData.editingColumnName = null
     ThisPageData.editingColumnDataType = null
     ThisPageData.editingColumnNullable = null
@@ -329,6 +339,7 @@
 
     ThisPage.methods.addColumn = function(column) {
         this.editingColumn = null
+        this.editingColumnIndex = null
         this.editingColumnName = null
         this.editingColumnDataType = null
         this.editingColumnNullable = true
@@ -336,8 +347,10 @@
         this.showingEditColumn = true
     }
 
-    ThisPage.methods.editColumnRow = function(column) {
+    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
@@ -347,7 +360,7 @@
 
     ThisPage.methods.saveColumn = function() {
         if (this.editingColumn) {
-            column = this.editingColumn
+            column = this.new_table.columns[this.editingColumnIndex]
         } else {
             column = {}
             this.new_table.columns.push(column)
@@ -372,6 +385,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/generate_project.mako b/tailbone/templates/generate_project.mako
deleted file mode 100644
index 72caa83c..00000000
--- a/tailbone/templates/generate_project.mako
+++ /dev/null
@@ -1,461 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/page.mako" />
-
-<%def name="title()">Generate Project</%def>
-
-<%def name="content_title()"></%def>
-
-<%def name="page_content()">
-  <b-field horizontal label="Project Type">
-    <b-select v-model="projectType">
-      <option value="rattail">rattail</option>
-      <option value="rattail_integration">rattail-integration</option>
-      <option value="tailbone_integration">tailbone-integration</option>
-      ## <option value="byjove">byjove</option>
-      <option value="fabric">fabric</option>
-    </b-select>
-  </b-field>
-
-  <div v-if="projectType == 'rattail'">
-    ${h.form(request.current_route_url(), ref='rattailForm')}
-    ${h.csrf_token(request)}
-    ${h.hidden('project_type', value='rattail')}
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Naming</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Name"
-                   message="The &quot;canonical&quot; name generally used to refer to this project">
-            <b-input name="name" v-model="rattail.name"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Slug"
-                   message="Used for e.g. naming the project source code folder">
-            <b-input name="slug" v-model="rattail.slug"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Organization"
-                   message="For use with &quot;branding&quot; etc.">
-            <b-input name="organization" v-model="rattail.organization"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Package Name for PyPI"
-                   message="It&apos;s a good idea to use org name as namespace prefix here">
-            <b-input name="python_project_name" v-model="rattail.python_project_name"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Package Name in Python"
-                   :message="`For example, ~/src/${'$'}{rattail.slug}/${'$'}{rattail.python_package_name}/__init__.py`">
-            <b-input name="python_name" v-model="rattail.python_package_name"></b-input>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Database</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Has Rattail DB"
-                   message="Note that a DB is required for the Web App">
-            <b-checkbox name="has_db"
-                        v-model="rattail.has_rattail_db"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-          <b-field horizontal label="Extends Rattail DB Schema"
-                   message="For adding custom tables/columns to the core schema">
-            <b-checkbox name="extends_db"
-                        v-model="rattail.extends_rattail_db_schema"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-          <b-field horizontal label="Uses Rattail Batch Schema"
-                   v-show="false"
-                   message="Needed for &quot;dynamic&quot; (e.g. import/export) batches">
-            <b-checkbox name="has_batch_schema"
-                        v-model="rattail.uses_rattail_batch_schema"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Web App</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Has Tailbone Web App">
-            <b-checkbox name="has_web"
-                        v-model="rattail.has_tailbone_web_app"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-          <b-field horizontal label="Has Tailbone Web API"
-                   v-show="false"
-                   message="Needed for e.g. Vue.js SPA mobile apps">
-            <b-checkbox name="has_web_api"
-                        v-model="rattail.has_tailbone_web_api"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Integrations</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Integrates w/ Catapult"
-                   message="Add schema, import/export logic etc. for ECRS Catapult">
-            <b-checkbox name="integrates_catapult"
-                        v-model="rattail.integrates_with_catapult"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-          <b-field horizontal label="Integrates w/ CORE-POS"
-                   v-show="false">
-            <b-checkbox name="integrates_corepos"
-                        v-model="rattail.integrates_with_corepos"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-          <b-field horizontal label="Integrates w/ LOC SMS"
-                   message="Add schema, import/export logic etc. for LOC SMS">
-            <b-checkbox name="integrates_locsms"
-                        v-model="rattail.integrates_with_locsms"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-          <b-field horizontal label="Has DataSync Service"
-                   v-show="false">
-            <b-checkbox name="has_datasync"
-                        v-model="rattail.has_datasync_service"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Deployment</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Uses Fabric">
-            <b-checkbox name="uses_fabric"
-                        v-model="rattail.uses_fabric"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    ${h.end_form()}
-  </div>
-
-  <div v-if="projectType == 'rattail_integration'">
-    ${h.form(request.current_route_url(), ref='rattail_integrationForm')}
-    ${h.csrf_token(request)}
-    ${h.hidden('project_type', value='rattail_integration')}
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Naming</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Integration Name"
-                   message="Name of the system to be integrated">
-            <b-input name="integration_name" v-model="rattail_integration.integration_name"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Integration URL"
-                   message="Reference URL for the system to be integrated">
-            <b-input name="integration_url" v-model="rattail_integration.integration_url"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Package Name for PyPI"
-                   message="Also will be used as slug, e.g. for folder name">
-            <b-input name="python_project_name" v-model="rattail_integration.python_project_name"></b-input>
-          </b-field>
-
-          ${h.hidden('slug', **{'v-model': 'rattail_integration.python_project_name'})}
-
-          <b-field horizontal label="Package Name in Python"
-                   :message="`For example, ~/src/${'$'}{rattail_integration.python_project_name}/${'$'}{rattail_integration.python_package_name}/__init__.py`">
-            <b-input name="python_name" v-model="rattail_integration.python_package_name"></b-input>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Options</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Extends Config"
-                   message="Adds custom config extension">
-            <b-checkbox name="extends_config"
-                        v-model="rattail_integration.extends_config"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-          <b-field horizontal label="Extends Rattail Schema"
-                   message="Adds custom tables/columns to the Rattail DB schema">
-            <b-checkbox name="extends_db"
-                        v-model="rattail_integration.extends_db"
-                        native-value="true">
-            </b-checkbox>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    ${h.end_form()}
-  </div>
-
-  <div v-if="projectType == 'tailbone_integration'">
-    ${h.form(request.current_route_url(), ref='tailbone_integrationForm')}
-    ${h.csrf_token(request)}
-    ${h.hidden('project_type', value='tailbone_integration')}
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Naming</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Integration Name"
-                   message="Name of the system to be integrated">
-            <b-input name="integration_name" v-model="tailbone_integration.integration_name"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Integration URL"
-                   message="Reference URL for the system to be integrated">
-            <b-input name="integration_url" v-model="tailbone_integration.integration_url"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Package Name for PyPI"
-                   message="Also will be used as slug, e.g. for folder name">
-            <b-input name="python_project_name" v-model="tailbone_integration.python_project_name"></b-input>
-          </b-field>
-
-          ${h.hidden('slug', **{'v-model': 'tailbone_integration.python_project_name'})}
-
-          <b-field horizontal label="Package Name in Python"
-                   :message="`For example, ~/src/${'$'}{tailbone_integration.python_project_name}/${'$'}{tailbone_integration.python_package_name}/__init__.py`">
-            <b-input name="python_name" v-model="tailbone_integration.python_package_name"></b-input>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-    ${h.end_form()}
-  </div>
-
-  <div v-if="projectType == 'byjove'">
-    ${h.form(request.current_route_url(), ref='byjoveForm')}
-    ${h.csrf_token(request)}
-    ${h.hidden('project_type', value='byjove')}
-
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Naming</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Name">
-            <b-input name="name" v-model="byjove.name"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Slug">
-            <b-input name="slug" v-model="byjove.slug"></b-input>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-
-    ${h.end_form()}
-  </div>
-
-  <div v-if="projectType == 'fabric'">
-    ${h.form(request.current_route_url(), ref='fabricForm')}
-    ${h.csrf_token(request)}
-    ${h.hidden('project_type', value='fabric')}
-
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Naming</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Name"
-                   message="The &quot;canonical&quot; name generally used to refer to this project">
-            <b-input name="name" v-model="fabric.name"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Slug"
-                   message="Used for e.g. naming the project source code folder">
-            <b-input name="slug" v-model="fabric.slug"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Organization"
-                   message="For use with &quot;branding&quot; etc.">
-            <b-input name="organization" v-model="fabric.organization"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Package Name for PyPI"
-                   message="It&apos;s a good idea to use org name as namespace prefix here">
-            <b-input name="python_project_name" v-model="fabric.python_project_name"></b-input>
-          </b-field>
-
-          <b-field horizontal label="Package Name in Python"
-                   :message="`For example, ~/src/${'$'}{fabric.slug}/${'$'}{fabric.python_package_name}/__init__.py`">
-            <b-input name="python_name" v-model="fabric.python_package_name"></b-input>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-
-    <br />
-    <div class="card">
-      <header class="card-header">
-        <p class="card-header-title">Theo</p>
-      </header>
-      <div class="card-content">
-        <div class="content">
-
-          <b-field horizontal label="Integrates With"
-                   message="Which POS system should Theo integrate with, if any">
-            <b-select name="integrates_with" v-model="fabric.integrates_with">
-              <option value="">(nothing)</option>
-              <option value="catapult">ECRS Catapult</option>
-              <option value="corepos">CORE-POS</option>
-              ## <option value="locsms">LOC SMS</option>
-            </b-select>
-          </b-field>
-
-        </div>
-      </div>
-    </div>
-
-    ${h.end_form()}
-  </div>
-
-  <br />
-  <div class="buttons" style="padding-left: 8rem;">
-    <b-button type="is-primary"
-              @click="submitProjectForm()">
-      Generate Project
-    </b-button>
-  </div>
-
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ThisPageData.projectType = 'rattail'
-
-    ThisPageData.rattail = {
-        name: "Okay-Then",
-        slug: "okay-then",
-        organization: "Acme Foods",
-        python_project_name: "Acme-Okay-Then",
-        python_package_name: "okay_then",
-        has_rattail_db: true,
-        extends_rattail_db_schema: true,
-        uses_rattail_batch_schema: false,
-        has_tailbone_web_app: true,
-        has_tailbone_web_api: false,
-        has_datasync_service: false,
-        integrates_with_catapult: false,
-        integrates_with_corepos: false,
-        integrates_with_locsms: false,
-        uses_fabric: true,
-    }
-
-    ThisPageData.rattail_integration = {
-        integration_name: "Foo",
-        integration_url: "https://www.example.com/",
-        python_project_name: "rattail-foo",
-        python_package_name: "rattail_foo",
-        extends_config: true,
-        extends_db: true,
-    }
-
-    ThisPageData.tailbone_integration = {
-        integration_name: "Foo",
-        integration_url: "https://www.example.com/",
-        python_project_name: "tailbone-foo",
-        python_package_name: "tailbone_foo",
-    }
-
-    ThisPageData.byjove = {
-        name: "Okay-Then-Mobile",
-        slug: "okay-then-mobile",
-    }
-
-    ThisPageData.fabric = {
-        name: "AcmeFab",
-        slug: "acmefab",
-        organization: "Acme Foods",
-        python_project_name: "Acme-Fabric",
-        python_package_name: "acmefab",
-        integrates_with: '',
-    }
-
-    ThisPage.methods.submitProjectForm = function() {
-        let form = this.$refs[this.projectType + 'Form']
-        form.submit()
-    }
-
-  </script>
-</%def>
-
-
-${parent.body()}
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 26e86359..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
@@ -20,67 +20,67 @@
    % 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>
-          % elif grid.has_click_handler(column['field']):
-              <span>
-                <a href="#"
-                   @click.prevent="${grid.click_handlers[column['field']]}"
-                   v-html="props.row.${column['field']}">
-                </a>
-              </span>
-          % 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.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
-                 >
-                <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>
@@ -98,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 9d8359d9..00000000
--- a/tailbone/templates/grids/buefy.mako
+++ /dev/null
@@ -1,578 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<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 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"
-         style="align-items: start;">
-
-      <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"
-               style="align-items: start;">
-
-        <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>
-
-      </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 buttons is-grouped is-pulled-right">
-                ## TODO: stop using |n filter
-                ${tools|n}
-              </div>
-          % endif
-        </div>
-
-      </div>
-
-    </div>
-
-    <b-table
-       :data="visibleData"
-       ## :columns="columns"
-       :loading="loading"
-       :row-class="getRowClass"
-
-       ## TODO: this should be more configurable, maybe auto-detect based
-       ## on buefy version??  probably cannot do that, but this feature
-       ## is only supported with buefy 0.8.13 and newer
-       % if request.rattail_config.getbool('tailbone', 'sticky_headers'):
-       sticky-header
-       height="600px"
-       % endif
-
-       :checkable="checkable"
-       % if grid.checkboxes:
-       :checked-rows.sync="checkedRows"
-       % if grid.clicking_row_checks_box:
-       @click="rowClick"
-       % endif
-       % 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="${json.dumps(column['sortable'])}"
-                            % if grid.is_searchable(column['field']):
-                            searchable
-                            % endif
-                            cell-class="${column['field']}"
-                            % if grid.has_click_handler(column['field']):
-                            @click.native="${grid.click_handlers[column['field']]}"
-                            % endif
-                            :visible="${json.dumps(column['visible'])}">
-              % if column['field'] in grid.raw_renderers:
-                  ${grid.raw_renderers[column['field']]()}
-              % elif 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">
-              ## 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.main_actions + grid.more_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 action.click_handler:
-                     @click.prevent="${action.click_handler}"
-                     % endif
-                     >
-                    ${action.render_icon()|n}
-                    ${action.render_label()|n}
-                  </a>
-                  &nbsp;
-              % endfor
-            </b-table-column>
-        % endif
-      </template>
-
-      <template #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',
-
-      mixins: [FormPosterMixin],
-
-      props: {
-          csrftoken: String,
-      },
-
-      computed: {
-
-          // 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
-          },
-      },
-
-      methods: {
-
-          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]
-          },
-
-          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()
-          },
-
-          // 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() {
-
-              // 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
-          },
-
-          // 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 grid.check_handler:
-              this.${grid.check_handler}(this.checkedRows, row)
-              % endif
-          },
-      }
-  }
-
-</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/importing/configure.mako b/tailbone/templates/importing/configure.mako
index cbe8463c..2445341d 100644
--- a/tailbone/templates/importing/configure.mako
+++ b/tailbone/templates/importing/configure.mako
@@ -6,52 +6,73 @@
 
   <h3 class="is-size-3">Designated Handlers</h3>
 
-  <b-table :data="handlersData"
+  <${b}-table :data="handlersData"
            narrowed
            icon-pack="fas"
            :default-sort="['host_title', 'asc']">
-      <template slot-scope="props">
-        <b-table-column field="host_title" label="Data Source" sortable>
-          {{ props.row.host_title }}
-        </b-table-column>
-        <b-table-column field="local_title" label="Data Target" sortable>
-          {{ props.row.local_title }}
-        </b-table-column>
-        <b-table-column field="direction" label="Direction" sortable>
-          {{ props.row.direction_display }}
-        </b-table-column>
-        <b-table-column field="handler_spec" label="Handler Spec" sortable>
-          {{ props.row.handler_spec }}
-        </b-table-column>
-        <b-table-column field="cmd" label="Command" sortable>
-          {{ props.row.command }} {{ props.row.subcommand }}
-        </b-table-column>
-        <b-table-column field="runas" label="Default Runas" sortable>
-          {{ props.row.default_runas }}
-        </b-table-column>
-        <b-table-column label="Actions">
-          <a href="#" class="grid-action"
-             @click.prevent="editHandler(props.row)">
-            <i class="fas fa-edit"></i>
-            Edit
-          </a>          
-        </b-table-column>
-      </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>
-  </b-table>
+    <${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">
@@ -123,9 +144,9 @@
   </b-modal>
 </%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.handlersData = ${json.dumps(handlers_data)|n}
 
@@ -182,6 +203,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako
index 2bc2a4e9..a9625bc3 100644
--- a/tailbone/templates/importing/runjob.mako
+++ b/tailbone/templates/importing/runjob.mako
@@ -63,28 +63,26 @@
   </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>
 
-    ${form.component_studly}Data.submittingRun = false
-    ${form.component_studly}Data.submittingExplain = false
-    ${form.component_studly}Data.runJob = false
+    ${form.vue_component}Data.submittingRun = false
+    ${form.vue_component}Data.submittingExplain = false
+    ${form.vue_component}Data.runJob = false
 
-    ${form.component_studly}.methods.submitRun = function() {
+    ${form.vue_component}.methods.submitRun = function() {
         this.submittingRun = true
         this.runJob = true
         this.$nextTick(() => {
-            this.$refs.${form.component_studly}.submit()
+            this.$refs.${form.vue_component}.submit()
         })
     }
 
-    ${form.component_studly}.methods.submitExplain = function() {
+    ${form.vue_component}.methods.submitExplain = function() {
         this.submittingExplain = true
-        this.$refs.${form.component_studly}.submit()
+        this.$refs.${form.vue_component}.submit()
     }
 
   </script>
 </%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
index bac57b75..de364828 100644
--- a/tailbone/templates/luigi/configure.mako
+++ b/tailbone/templates/luigi/configure.mako
@@ -22,45 +22,56 @@
   </div>
   <div class="block" style="padding-left: 2rem; display: flex;">
 
-    <b-table :data="overnightTasks">
-      <template slot-scope="props">
-        <!-- <b-table-column field="key" -->
-        <!--                 label="Key" -->
-        <!--                 sortable> -->
-        <!--   {{ props.row.key }} -->
-        <!-- </b-table-column> -->
-        <b-table-column field="key"
-                        label="Key">
-          {{ props.row.key }}
-        </b-table-column>
-        <b-table-column field="description"
-                        label="Description">
-          {{ props.row.description }}
-        </b-table-column>
-        <b-table-column field="class_name"
-                        label="Class Name">
-          {{ props.row.class_name }}
-        </b-table-column>
-        <b-table-column field="script"
-                        label="Script">
-          {{ props.row.script }}
-        </b-table-column>
-        <b-table-column label="Actions">
-          <a href="#"
-             @click.prevent="overnightTaskEdit(props.row)">
-            <i class="fas fa-edit"></i>
-            Edit
-          </a>
-          &nbsp;
-          <a href="#"
-             class="has-text-danger"
-             @click.prevent="overnightTaskDelete(props.row)">
-            <i class="fas fa-trash"></i>
-            Delete
-          </a>
-        </b-table-column>
-      </template>
-    </b-table>
+    <${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">
@@ -74,31 +85,31 @@
           <b-field label="Key"
                    :type="overnightTaskKey ? null : 'is-danger'">
             <b-input v-model.trim="overnightTaskKey"
-                     ref="overnightTaskKey">
-            </b-input>
+                     ref="overnightTaskKey"
+                     expanded />
           </b-field>
           <b-field label="Description"
                    :type="overnightTaskDescription ? null : 'is-danger'">
             <b-input v-model.trim="overnightTaskDescription"
-                     ref="overnightTaskDescription">
-            </b-input>
+                     ref="overnightTaskDescription"
+                     expanded />
           </b-field>
           <b-field label="Module">
-            <b-input v-model.trim="overnightTaskModule">
-            </b-input>
+            <b-input v-model.trim="overnightTaskModule"
+                     expanded />
           </b-field>
           <b-field label="Class Name">
-            <b-input v-model.trim="overnightTaskClass">
-            </b-input>
+            <b-input v-model.trim="overnightTaskClass"
+                     expanded />
           </b-field>
           <b-field label="Script">
-            <b-input v-model.trim="overnightTaskScript">
-            </b-input>
+            <b-input v-model.trim="overnightTaskScript"
+                     expanded />
           </b-field>
           <b-field label="Notes">
             <b-input v-model.trim="overnightTaskNotes"
-                     type="textarea">
-            </b-input>
+                     type="textarea"
+                     expanded />
           </b-field>
         </section>
 
@@ -136,44 +147,56 @@
   </div>
   <div class="block" style="padding-left: 2rem; display: flex;">
 
-    <b-table :data="backfillTasks">
-      <template slot-scope="props">
-        <b-table-column field="key"
-                        label="Key">
-          {{ props.row.key }}
-        </b-table-column>
-        <b-table-column field="description"
-                        label="Description">
-          {{ props.row.description }}
-        </b-table-column>
-        <b-table-column field="script"
-                        label="Script">
-          {{ props.row.script }}
-        </b-table-column>
-        <b-table-column field="forward"
-                        label="Orientation">
-          {{ props.row.forward ? "Forward" : "Backward" }}
-        </b-table-column>
-        <b-table-column field="target_date"
-                        label="Target Date">
-          {{ props.row.target_date }}
-        </b-table-column>
-        <b-table-column label="Actions">
-          <a href="#"
-             @click.prevent="backfillTaskEdit(props.row)">
-            <i class="fas fa-edit"></i>
-            Edit
-          </a>
-          &nbsp;
-          <a href="#"
-             class="has-text-danger"
-             @click.prevent="backfillTaskDelete(props.row)">
-            <i class="fas fa-trash"></i>
-            Delete
-          </a>
-        </b-table-column>
-      </template>
-    </b-table>
+    <${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">
@@ -187,19 +210,19 @@
           <b-field label="Key"
                    :type="backfillTaskKey ? null : 'is-danger'">
             <b-input v-model.trim="backfillTaskKey"
-                     ref="backfillTaskKey">
-            </b-input>
+                     ref="backfillTaskKey"
+                     expanded />
           </b-field>
           <b-field label="Description"
                    :type="backfillTaskDescription ? null : 'is-danger'">
             <b-input v-model.trim="backfillTaskDescription"
-                     ref="backfillTaskDescription">
-            </b-input>
+                     ref="backfillTaskDescription"
+                     expanded />
           </b-field>
           <b-field label="Script"
                    :type="backfillTaskScript ? null : 'is-danger'">
-            <b-input v-model.trim="backfillTaskScript">
-            </b-input>
+            <b-input v-model.trim="backfillTaskScript"
+                     expanded />
           </b-field>
           <b-field grouped>
             <b-field label="Orientation">
@@ -215,8 +238,8 @@
           </b-field>
           <b-field label="Notes">
             <b-input v-model.trim="backfillTaskNotes"
-                     type="textarea">
-            </b-input>
+                     type="textarea"
+                     expanded />
           </b-field>
         </section>
 
@@ -245,7 +268,8 @@
              expanded>
       <b-input name="rattail.luigi.url"
                v-model="simpleSettings['rattail.luigi.url']"
-               @input="settingsNeedSaved = true">
+               @input="settingsNeedSaved = true"
+               expanded>
       </b-input>
     </b-field>
 
@@ -254,7 +278,8 @@
              expanded>
       <b-input name="rattail.luigi.scheduler.supervisor_process_name"
                v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']"
-               @input="settingsNeedSaved = true">
+               @input="settingsNeedSaved = true"
+               expanded>
       </b-input>
     </b-field>
 
@@ -263,7 +288,8 @@
              expanded>
       <b-input name="rattail.luigi.scheduler.restart_command"
                v-model="simpleSettings['rattail.luigi.scheduler.restart_command']"
-               @input="settingsNeedSaved = true">
+               @input="settingsNeedSaved = true"
+               expanded>
       </b-input>
     </b-field>
 
@@ -271,9 +297,9 @@
 
 </%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.overnightTasks = ${json.dumps(overnight_tasks)|n}
     ThisPageData.overnightTaskShowDialog = false
@@ -399,6 +425,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako
index 6faade8d..0dd72d01 100644
--- a/tailbone/templates/luigi/index.mako
+++ b/tailbone/templates/luigi/index.mako
@@ -49,87 +49,95 @@
       % endif
     </div>
 
-    % if  master.has_perm('launch_overnight'):
+    % if master.has_perm('launch_overnight'):
 
         <h3 class="block is-size-3">Overnight Tasks</h3>
 
-        <b-table :data="overnightTasks" hoverable>
-          <template slot-scope="props">
-            <b-table-column field="description"
-                            label="Description">
-              {{ props.row.description }}
-            </b-table-column>
-            <b-table-column field="script"
-                            label="Command">
-              {{ props.row.script || props.row.class_name }}
-            </b-table-column>
-            <b-table-column field="last_date"
-                            label="Last Date"
-                            :class="overnightTextClass(props.row)">
+        <${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!" }}
-            </b-table-column>
-            <b-table-column label="Actions">
-              <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
-                       :active.sync="overnightTaskShowLaunchDialog">
-                <div class="modal-card">
+            </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>
+                <header class="modal-card-head">
+                  <p class="modal-card-title">Launch Overnight Task</p>
+                </header>
 
-                  <section class="modal-card-body"
-                           v-if="overnightTask">
+                <section class="modal-card-body"
+                         v-if="overnightTask">
 
-                    <b-field label="Task" horizontal>
-                      <span>{{ overnightTask.description }}</span>
-                    </b-field>
+                  <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="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>
+                  <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>
+                  <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>
+                </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>
+                <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>
+        </${b}-table>
 
     % endif
 
@@ -137,45 +145,55 @@
 
         <h3 class="block is-size-3">Backfill Tasks</h3>
 
-        <b-table :data="backfillTasks" hoverable>
-          <template slot-scope="props">
-            <b-table-column field="description"
-                            label="Description">
-              {{ props.row.description }}
-            </b-table-column>
-            <b-table-column field="script"
-                            label="Script">
-              {{ props.row.script }}
-            </b-table-column>
-            <b-table-column field="forward"
-                            label="Orientation">
-              {{ props.row.forward ? "Forward" : "Backward" }}
-            </b-table-column>
-            <b-table-column field="last_date"
-                            label="Last Date"
-                            :class="backfillTextClass(props.row)">
+        <${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 }}
-            </b-table-column>
-            <b-table-column field="target_date"
-                            label="Target Date">
-              {{ props.row.target_date }}
-            </b-table-column>
-            <b-table-column label="Actions">
-              <b-button type="is-primary"
-                        icon-pack="fas"
-                        icon-left="arrow-circle-right"
-                        @click="backfillTaskLaunch(props.row)">
-                Launch
-              </b-button>
-            </b-table-column>
-          </template>
+            </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}-table>
 
-        <b-modal has-modal-card
-                 :active.sync="backfillTaskShowLaunchDialog">
+        <${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">
@@ -230,16 +248,16 @@
               </b-button>
             </footer>
           </div>
-        </b-modal>
+        </${b}-modal>
 
     % endif
 
   </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>
 
     % if master.has_perm('restart_scheduler'):
 
@@ -356,6 +374,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
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/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 dded378f..d2f517d9 100644
--- a/tailbone/templates/master/delete.mako
+++ b/tailbone/templates/master/delete.mako
@@ -3,84 +3,45 @@
 
 <%def name="title()">Delete ${model_title}: ${instance_title}</%def>
 
-<%def name="context_menu_items()">
-  % if not use_buefy and master.viewable and master.has_perm('view'):
-      <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li>
-  % endif
-  % if not use_buefy and master.editable and master.has_perm('edit'):
-      <li>${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}</li>
-  % endif
-  % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'):
-      % 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 de0cb524..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 not use_buefy and master.viewable and master.has_perm('view'):
-      <li>${h.link_to("View this {}".format(model_title), action_url('view', instance))}</li>
-  % endif
-  ${self.context_menu_item_delete()}
-  % if not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'):
-      % 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/form.mako b/tailbone/templates/master/form.mako
index a37e3f91..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 not use_buefy and master.deletable and instance_deletable and master.has_perm('delete'):
-      % 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 053e09fb..a2d26c60 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -12,449 +12,264 @@
 
 <%def name="content_title()"></%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-    $(function() {
-
-        % if download_results_rows_path:
-            function downloadResultsRowsRedirect() {
-                location.href = '${url('{}.download_results_rows'.format(route_prefix))}?filename=${h.os.path.basename(download_results_rows_path)}';
-            }
-            // we give this 1 second before attempting the redirect; so this
-            // way the page should fully render before redirecting
-            window.setTimeout(downloadResultsRowsRedirect, 1000);
-        % endif
-
-        $('.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.has_rows and master.results_rows_downloadable:
-
-            $('#download-row-results-button').click(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?")) {
-                    disable_button(this);
-                    var form = $(this).parents('form');
-                    form.submit();
-                }
-            });
-
-        % 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 not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'):
-      % 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.has_input_file_templates and master.has_perm('create'):
-      % for template in six.itervalues(input_file_templates):
-          <li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li>
-      % endfor
-  % endif
-</%def>
-
 <%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 master.results_downloadable and master.has_perm('download_results'):
-      % if use_buefy:
-          <b-button type="is-primary"
-                    icon-pack="fas"
-                    icon-left="fas fa-download"
-                    @click="showDownloadResultsDialog = true"
-                    :disabled="!total">
-            Download Results
-          </b-button>
+  % 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()}
+        ${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">
+        <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 />
+            <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>
+              <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 style="display: flex; justify-content: space-between">
 
-                  <div>
-                    <b-field horizontal label="Format">
-                      <b-select v-model="downloadResultsFormat">
-                        % for key, label in six.iteritems(master.download_results_supported_formats()):
-                        <option value="${key}">${label}</option>
-                        % endfor
-                      </b-select>
-                    </b-field>
+                <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>
+                  <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'"
-                         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
-                                      ref="downloadResultsExcludedFields">
-                              <option v-for="field in downloadResultsFieldsAvailable"
-                                      v-if="!downloadResultsFieldsIncluded.includes(field)"
-                                      :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
-                                      ref="downloadResultsIncludedFields">
-                              <option v-for="field in downloadResultsFieldsIncluded"
-                                      :key="field"
-                                      :value="field">
-                                {{ field }}
-                              </option>
-                            </b-select>
-                          </b-field>
-                        </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> <!-- 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="fas fa-download"
-                             :disabled="!downloadResultsFieldsIncluded.length"
-                             text="Download Results">
-                </once-button>
-              </footer>
-            </div>
-          </b-modal>
-      % endif
+                </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 master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'):
-      % if use_buefy:
-          <b-button type="is-primary"
-                    icon-pack="fas"
-                    icon-left="fas fa-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()}
-      % else:
-          ${h.form(url('{}.download_results_rows'.format(route_prefix)))}
-          ${h.csrf_token(request)}
-          <button type="button" id="download-row-results-button">
-            Download Rows for Results
-          </button>
-          ${h.end_form()}
-      % endif
+  % 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 master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)):
+  % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
 
-      % 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
       ${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
+      <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()">
 
   % if download_results_path:
@@ -473,7 +288,7 @@
 
   ${self.render_grid_component()}
 
-  % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
+  % 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()}
@@ -481,42 +296,53 @@
 </%def>
 
 <%def name="render_grid_component()">
-  <${grid.component} ref="grid" :csrftoken="csrftoken"
-     % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
-     @deleteActionClicked="deleteObject"
-     % endif
-     >
-  </${grid.component}>
+  ${grid.render_vue_tag()}
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+##############################
+## 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>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
-  ## TODO: stop using |n filter
-  ${grid.render_buefy(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+        ${grid.vue_component}.methods.appliedFiltersHook = function() {
+            this.gridTotalsDisplay = null
+            this.gridTotalsFetching = false
+        }
+    % endif
 
     ## maybe auto-redirect to download latest results file
-    % if download_results_path and use_buefy:
+    % if download_results_path:
         ThisPage.methods.downloadResultsRedirect = function() {
             location.href = '${url('{}.download_results'.format(route_prefix))}?filename=${h.os.path.basename(download_results_path)}';
         }
@@ -529,7 +355,7 @@
     % endif
 
     ## maybe auto-redirect to download latest "rows for results" file
-    % if download_results_rows_path and use_buefy:
+    % 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)}';
         }
@@ -541,20 +367,19 @@
         }
     % endif
 
-    ## TODO: stop checking for buefy here once we only have the one session.pop()
-    % if use_buefy and request.session.pop('{}.results_csv.generated'.format(route_prefix), False):
+    % 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 use_buefy and request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False):
+    % 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
@@ -565,16 +390,19 @@
     % endif
 
     ## download results
-    % if master.results_downloadable and master.has_perm('download_results'):
+    % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
 
-        ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}'
-        ${grid.component_studly}Data.showDownloadResultsDialog = false
-        ${grid.component_studly}Data.downloadResultsFieldsMode = 'default'
-        ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n}
-        ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
-        ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
+        ${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.component_studly}.computed.downloadResultsFieldsExcluded = function() {
+        ${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)) {
@@ -584,70 +412,73 @@
             return excluded
         }
 
-        ${grid.component_studly}.methods.downloadResultsExcludeFields = function() {
-            let selected = this.$refs.downloadResultsIncludedFields.selected
+        ${grid.vue_component}.methods.downloadResultsExcludeFields = function() {
+            const selected = Array.from(this.downloadResultsIncludedFieldsSelected)
             if (!selected) {
                 return
             }
-            selected = Array.from(selected)
-            selected.forEach(field => {
 
-                // de-select the entry within "included" field input
-                let index = this.$refs.downloadResultsIncludedFields.selected.indexOf(field)
-                if (index > -1) {
-                    this.$refs.downloadResultsIncludedFields.selected.splice(index, 1)
+            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 official "included" list
+                // remove field from included
+                // nb. excluded list will reflect this change too
                 index = this.downloadResultsFieldsIncluded.indexOf(field)
-                if (index > -1) {
+                if (index >= 0) {
                     this.downloadResultsFieldsIncluded.splice(index, 1)
                 }
-            }, this)
+            })
         }
 
-        ${grid.component_studly}.methods.downloadResultsIncludeFields = function() {
-            let selected = this.$refs.downloadResultsExcludedFields.selected
+        ${grid.vue_component}.methods.downloadResultsIncludeFields = function() {
+            const selected = Array.from(this.downloadResultsExcludedFieldsSelected)
             if (!selected) {
                 return
             }
-            selected = Array.from(selected)
-            selected.forEach(field => {
 
-                // de-select the entry within "excluded" field input
-                let index = this.$refs.downloadResultsExcludedFields.selected.indexOf(field)
-                if (index > -1) {
-                    this.$refs.downloadResultsExcludedFields.selected.splice(index, 1)
+            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 official "included" list
+                // add field to included
+                // nb. excluded list will reflect this change too
                 this.downloadResultsFieldsIncluded.push(field)
-
-            }, this)
+            })
         }
 
-        ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() {
+        ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() {
             this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault)
             this.downloadResultsFieldsMode = 'default'
         }
 
-        ${grid.component_studly}.methods.downloadResultsUseAllFields = function() {
+        ${grid.vue_component}.methods.downloadResultsUseAllFields = function() {
             this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable)
             this.downloadResultsFieldsMode = 'all'
         }
 
-        ${grid.component_studly}.methods.downloadResultsSubmit = function() {
+        ${grid.vue_component}.methods.downloadResultsSubmit = function() {
             this.$refs.download_results_form.submit()
         }
     % endif
 
     ## download rows for results
-    % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+    % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
 
-        ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false
-        ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results"
+        ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false
+        ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results"
 
-        ${grid.component_studly}.methods.downloadResultsRows = function() {
+        ${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?")) {
@@ -659,12 +490,12 @@
     % 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
             }
@@ -674,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.")
@@ -689,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
             }
@@ -702,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.")
@@ -720,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
             }
@@ -735,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.")
@@ -751,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
             }
@@ -766,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
@@ -779,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..."
         }
@@ -792,45 +623,10 @@
   </script>
 </%def>
 
-
-% if use_buefy:
-    ${parent.body()}
-
-% else:
-    ## no buefy, so do the traditional thing
-
-    % if download_results_rows_path:
-        <div class="flash-messages">
-          <div class="ui-state-highlight ui-corner-all">
-            <span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span>
-            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)))}
-          </div>
-        </div>
-    % endif
-
-    ${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
-
-    ## TODO: can stop checking for buefy above once this legacy chunk is gone
-    % if request.session.pop('{}.results_csv.generated'.format(route_prefix), False):
-        <script type="text/javascript">
-          $(function() {
-              location.href = '${url('{}.results_csv_download'.format(route_prefix))}';
-          });
-        </script>
-    % endif
-    % if request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False):
-        <script type="text/javascript">
-          $(function() {
-              location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}';
-          });
-        </script>
-    % 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 7b0b2de5..118c028c 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -3,141 +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()">
-  ## TODO: either make this configurable, or just lose it.
-  ## nobody seems to ever find it useful in practice.
-  ## <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 not use_buefy and master.editable and instance_editable and master.has_perm('edit'):
-      <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 not use_buefy and master.creatable and master.show_create_link and master.has_perm('create'):
-      % 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 not use_buefy and master.cloneable and master.has_perm('clone'):
-      <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), master.get_action_url('touch', instance))}</li>
-  % endif
-  % if not use_buefy and master.has_rows and master.rows_downloadable_csv and master.has_perm('row_results_csv'):
-      <li>${h.link_to("Download row results as CSV", master.get_action_url('row_results_csv', instance))}</li>
-  % endif
-  % if not use_buefy and 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 use_buefy:
-      % 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
+  % 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 />
-          % if rows_title:
-              <h4 class="block is-size-4">${rows_title}</h4>
-          % endif
-          ${self.render_row_grid_component()}
-      % 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_row_grid_component()">
-  <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid>
+  ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')}
 </%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}
+<%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
-  ${parent.render_this_page_template()}
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
-  % if master.has_rows:
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneGrid.data = function() { return TailboneGridData }
+    % if getattr(master, 'touchable', False) and master.has_perm('touch'):
 
-    Vue.component('tailbone-grid', TailboneGrid)
+        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
 </%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 255caf69..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 not use_buefy and instance_deletable and master.has_perm('delete_row'):
-      <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 5dbcd15d..dfe03a64 100644
--- a/tailbone/templates/master/view_version.mako
+++ b/tailbone/templates/master/view_version.mako
@@ -19,102 +19,39 @@
 </%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 -->
-
-<div class="versions-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">${render_old_value(version, 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">${render_old_value(version, field)}</td>
-                 <td class="value new-value">${render_new_value(version, field, 'dirty')}</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">${render_new_value(version, field, 'new')}</td>
-               </tr>
-            % endfor
-          </tbody>
-        </table>
-    % endif
-
-% endfor
-</div>
+  <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/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/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 ff0fc836..8f2d5e27 100644
--- a/tailbone/templates/ordering/create.mako
+++ b/tailbone/templates/ordering/create.mako
@@ -1,82 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  ${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>
-  % endif
-</%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 f0e6380a..34a6085f 100644
--- a/tailbone/templates/ordering/view.mako
+++ b/tailbone/templates/ordering/view.mako
@@ -21,14 +21,14 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%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="fas fa-play"
+                    icon-left="play"
                     @click="startScanning()">
             Start Scanning
           </b-button>
@@ -111,7 +111,7 @@
                         <div class="buttons">
                           <b-button type="is-primary"
                                     icon-pack="fas"
-                                    icon-left="fas fa-save"
+                                    icon-left="save"
                                     @click="saveCurrentRow()">
                             Save
                           </b-button>
@@ -185,10 +185,10 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
-      <script type="text/javascript">
+      <script>
 
         let OrderingScanner = {
             template: '#ordering-scanner-template',
@@ -204,7 +204,7 @@
                     saving: 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},
                 }
             },
             computed: {
@@ -408,16 +408,11 @@
   % endif
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
-      <script type="text/javascript">
-
+      <script>
         Vue.component('ordering-scanner', OrderingScanner)
-
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
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 321e60d7..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,65 +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',
-        mixins: [FormPosterMixin],
-        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
index 377063b8..cd6fddf1 100644
--- a/tailbone/templates/people/index.mako
+++ b/tailbone/templates/people/index.mako
@@ -3,8 +3,7 @@
 
 <%def name="grid_tools()">
 
-  % if master.mergeable and master.has_perm('request_merge'):
-      % if use_buefy:
+  % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
       <b-button @click="showMergeRequest()"
                 icon-pack="fas"
                 icon-left="object-ungroup"
@@ -22,20 +21,21 @@
           <section class="modal-card-body">
             <b-table :data="mergeRequestRows"
                      striped hoverable>
-              <template slot-scope="props">
-                <b-table-column field="customer_number"
-                                label="Customer #">
-                  <span v-html="props.row.customer_number"></span>
-                </b-table-column>
-                <b-table-column field="first_name"
-                                label="First Name">
-                  <span v-html="props.row.first_name"></span>
-                </b-table-column>
-                <b-table-column field="last_name"
-                                label="Last Name">
-                  <span v-html="props.row.last_name"></span>
-                </b-table-column>
-              </template>
+              <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>
 
@@ -56,43 +56,42 @@
           </footer>
         </div>
       </b-modal>
-      % endif
   % endif
 
   ${parent.grid_tools()}
 </%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 master.mergeable and master.has_perm('request_merge'):
+    % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
 
-        ${grid.component_studly}Data.mergeRequestShowDialog = false
-        ${grid.component_studly}Data.mergeRequestRows = []
-        ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request"
-        ${grid.component_studly}Data.mergeRequestSubmitting = false
+        ${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.component_studly}.computed.mergeRequestRemovingUUID = function() {
+        ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() {
             if (this.mergeRequestRows.length) {
                 return this.mergeRequestRows[0].uuid
             }
             return null
         }
 
-        ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() {
+        ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() {
             if (this.mergeRequestRows.length) {
                 return this.mergeRequestRows[1].uuid
             }
             return null
         }
 
-        ${grid.component_studly}.methods.showMergeRequest = function() {
+        ${grid.vue_component}.methods.showMergeRequest = function() {
             this.mergeRequestRows = this.checkedRows
             this.mergeRequestShowDialog = true
         }
 
-        ${grid.component_studly}.methods.submitMergeRequest = function() {
+        ${grid.vue_component}.methods.submitMergeRequest = function() {
             this.mergeRequestSubmitting = true
             this.mergeRequestSubmitText = "Working, please wait..."
         }
@@ -101,5 +100,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako
index 5dcbea03..e2db1476 100644
--- a/tailbone/templates/people/merge-requests/view.mako
+++ b/tailbone/templates/people/merge-requests/view.mako
@@ -4,7 +4,6 @@
 <%def name="page_content()">
   ${parent.page_content()}
   % if not instance.merged and request.has_perm('people.merge'):
-      % if use_buefy:
       ${h.form(url('people.merge'), **{'@submit': 'submitMergeForm'})}
       ${h.csrf_token(request)}
       ${h.hidden('uuids', value=','.join([instance.removing_uuid, instance.keeping_uuid]))}
@@ -16,14 +15,13 @@
         {{ mergeFormButtonText }}
       </b-button>
       ${h.end_form()}
-      % endif
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if not instance.merged and request.has_perm('people.merge'):
-      <script type="text/javascript">
+      <script>
 
         ThisPageData.mergeFormButtonText = "Perform Merge"
         ThisPageData.mergeFormSubmitting = false
@@ -36,5 +34,3 @@
       </script>
   % endif
 </%def>
-
-${parent.body()}
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 51ecaed0..00000000
--- a/tailbone/templates/people/view_profile_buefy.mako
+++ /dev/null
@@ -1,1674 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/master/view.mako" />
-
-<%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>
-
-<%def name="content_title()">
-  ${dynamic_content_title}
-</%def>
-
-<%def name="page_content()">
-  <profile-info @change-content-title="changeContentTitle">
-  </profile-info>
-</%def>
-
-<%def name="render_this_page()">
-  ${self.page_content()}
-</%def>
-
-<%def name="render_personal_name_card()">
-  <div class="card personal">
-    <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>
-
-            <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
-                       :active.sync="editNameShowDialog">
-                <div class="modal-card">
-
-                  <header class="modal-card-head">
-                    <p class="modal-card-title">Edit Name</p>
-                  </header>
-
-                  <section class="modal-card-body">
-                    <b-field label="First Name">
-                      <b-input v-model.trim="personFirstName"
-                               :maxlength="maxLengths.person_first_name || null">
-                      </b-input>
-                    </b-field>
-                    <b-field label="Middle Name">
-                      <b-input v-model.trim="personMiddleName"
-                               :maxlength="maxLengths.person_middle_name || null">
-                      </b-input>
-                    </b-field>
-                    <b-field label="Last Name">
-                      <b-input v-model.trim="personLastName"
-                               :maxlength="maxLengths.person_last_name || null">
-                      </b-input>
-                    </b-field>
-                  </section>
-
-                  <footer class="modal-card-foot">
-                    <once-button type="is-primary"
-                                 @click="editNameSave()"
-                                 :disabled="editNameSaveDisabled"
-                                 icon-left="save"
-                                 text="Save">
-                    </once-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">
-    <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
-                       :active.sync="editAddressShowDialog">
-                <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="personStreet1"
-                               :maxlength="maxLengths.address_street || null">
-                      </b-input>
-                    </b-field>
-
-                    <b-field label="Street 2" expanded>
-                      <b-input v-model.trim="personStreet2"
-                               :maxlength="maxLengths.address_street2 || null">
-                      </b-input>
-                    </b-field>
-
-                    <b-field label="Zipcode">
-                      <b-input v-model.trim="personZipcode"
-                               :maxlength="maxLengths.address_zipcode || null">
-                      </b-input>
-                    </b-field>
-
-                    <b-field grouped>
-                      <b-field label="City">
-                        <b-input v-model.trim="personCity"
-                                 :maxlength="maxLengths.address_city || null">
-                        </b-input>
-                      </b-field>
-                      <b-field label="State">
-                        <b-input v-model.trim="personState"
-                                 :maxlength="maxLengths.address_state || null">
-                        </b-input>
-                      </b-field>
-                    </b-field>
-
-                    <b-field label="Invalid">
-                      <b-checkbox v-model="personInvalidAddress"
-                                  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="is-pulled-right">
-              <b-button type="is-primary"
-                        icon-pack="fas"
-                        icon-left="plus"
-                        @click="addPhoneInit()">
-                Add Phone
-              </b-button>
-            </div>
-            <b-modal has-modal-card
-                     :active.sync="editPhoneShowDialog">
-              <div class="modal-card">
-
-                <header class="modal-card-head">
-                  <p class="modal-card-title">
-                    {{ phoneUUID ? "Edit Phone" : "Add Phone" }}
-                  </p>
-                </header>
-
-                <section class="modal-card-body">
-                  <b-field grouped>
-
-                    <b-field label="Type" expanded>
-                      <b-select v-model="phoneType" expanded>
-                        <option v-for="option in phoneTypeOptions"
-                                :key="option.value"
-                                :value="option.value">
-                          {{ option.label }}
-                        </option>
-                      </b-select>
-                    </b-field>
-
-                    <b-field label="Number" expanded>
-                      <b-input v-model.trim="phoneNumber"
-                               ref="editPhoneInput">
-                      </b-input>
-                    </b-field>
-                  </b-field>
-
-                  <b-field label="Preferred?">
-                    <b-checkbox v-model="phonePreferred">
-                    </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">
-                    {{ editPhoneSaveText }}
-                  </b-button>
-                  <b-button @click="editPhoneShowDialog = false">
-                    Cancel
-                  </b-button>
-                </footer>
-              </div>
-            </b-modal>
-        % endif
-
-        <b-table :data="person.phones">
-          <template slot-scope="props">
-
-            <b-table-column field="preference" label="Preferred">
-              {{ props.row.preferred ? "Yes" : "" }}
-            </b-table-column>
-
-            <b-table-column field="type" label="Type">
-              {{ props.row.type }}
-            </b-table-column>
-
-            <b-table-column field="number" label="Number">
-              {{ props.row.number }}
-            </b-table-column>
-
-            % if request.has_perm('people_profile.edit_person'):
-                <b-table-column label="Actions">
-                  <a href="#" @click.prevent="editPhoneInit(props.row)">
-                    <i class="fas fa-edit"></i>
-                    Edit
-                  </a>
-                  <a href="#" @click.prevent="deletePhone(props.row)"
-                     class="has-text-danger">
-                    <i class="fas fa-trash"></i>
-                    Delete
-                  </a>
-                  <a href="#" @click.prevent="setPreferredPhone(props.row)"
-                     v-if="!props.row.preferred">
-                    <i class="fas fa-star"></i>
-                    Set Preferred
-                  </a>
-                </b-table-column>
-            % endif
-
-          </template>
-        </b-table>
-
-      </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="is-pulled-right">
-              <b-button type="is-primary"
-                        icon-pack="fas"
-                        icon-left="plus"
-                        @click="addEmailInit()">
-                Add Email
-              </b-button>
-            </div>
-            <b-modal has-modal-card
-                     :active.sync="editEmailShowDialog">
-              <div class="modal-card">
-
-                <header class="modal-card-head">
-                  <p class="modal-card-title">
-                    {{ emailUUID ? "Edit Email" : "Add Email" }}
-                  </p>
-                </header>
-
-                <section class="modal-card-body">
-                  <b-field grouped>
-
-                    <b-field label="Type" expanded>
-                      <b-select v-model="emailType" expanded>
-                        <option v-for="option in emailTypeOptions"
-                                :key="option.value"
-                                :value="option.value">
-                          {{ option.label }}
-                        </option>
-                      </b-select>
-                    </b-field>
-
-                    <b-field label="Address" expanded>
-                      <b-input v-model.trim="emailAddress"
-                               ref="editEmailInput">
-                      </b-input>
-                    </b-field>
-
-                  </b-field>
-
-                  <b-field v-if="!emailUUID"
-                           label="Preferred?">
-                    <b-checkbox v-model="emailPreferred">
-                    </b-checkbox>
-                  </b-field>
-
-                  <b-field v-if="emailUUID"
-                           label="Invalid?">
-                    <b-checkbox v-model="emailInvalid"
-                                :type="emailInvalid ? '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">
-                    {{ editEmailSaveText }}
-                  </b-button>
-                  <b-button @click="editEmailShowDialog = false">
-                    Cancel
-                  </b-button>
-                </footer>
-              </div>
-            </b-modal>
-        % endif
-
-        <b-table :data="person.emails">
-          <template slot-scope="props">
-
-            <b-table-column field="preference" label="Preferred">
-              {{ props.row.preferred ? "Yes" : "" }}
-            </b-table-column>
-
-            <b-table-column field="type" label="Type">
-              {{ props.row.type }}
-            </b-table-column>
-
-            <b-table-column field="address" label="Address">
-              {{ props.row.address }}
-            </b-table-column>
-
-            <b-table-column field="invalid" label="Invalid">
-              <span v-if="props.row.invalid" class="has-text-danger">Yes</span>
-            </b-table-column>
-
-            % if request.has_perm('people_profile.edit_person'):
-                <b-table-column label="Actions">
-                  <a href="#" @click.prevent="editEmailInit(props.row)">
-                    <i class="fas fa-edit"></i>
-                    Edit
-                  </a>
-                  <a href="#" @click.prevent="deleteEmail(props.row)"
-                     class="has-text-danger">
-                    <i class="fas fa-trash"></i>
-                    Delete
-                  </a>
-                  <a href="#" @click.prevent="setPreferredEmail(props.row)"
-                     v-if="!props.row.preferred">
-                    <i class="fas fa-star"></i>
-                    Set Preferred
-                  </a>
-                </b-table-column>
-            % endif
-
-          </template>
-        </b-table>
-
-      </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 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')}
-        % endif
-      </div>
-
-    </div>
-  </script>
-</%def>
-
-<%def name="render_personal_tab()">
-  <b-tab-item label="Personal"
-              icon-pack="fas"
-              icon="check">
-    <personal-tab :person="person"
-                  :member="member"
-                  :max-lengths="maxLengths"
-                  :phone-type-options="phoneTypeOptions"
-                  :email-type-options="emailTypeOptions"
-                  @person-updated="personUpdated"
-                  @change-content-title="changeContentTitle">
-    </personal-tab>
-  </b-tab-item>
-</%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_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">
-
-            <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="initEditEmployeeID()">
-                          Edit ID
-                        </b-button>
-                        <b-modal has-modal-card
-                                 :active.sync="showEditEmployeeIDDialog">
-                          <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="newEmployeeID"></b-input>
-                              </b-field>
-                            </section>
-
-                            <footer class="modal-card-foot">
-                              <b-button @click="showEditEmployeeIDDialog = false">
-                                Cancel
-                              </b-button>
-                              <b-button type="is-primary"
-                                        icon-pack="fas"
-                                        icon-left="save"
-                                        :disabled="updatingEmployeeID"
-                                        @click="updateEmployeeID()">
-                                {{ editEmployeeIDSaveButtonText }}
-                              </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>
-
-            <br />
-            <p><strong>Employee History</strong></p>
-            <br />
-
-            <b-table :data="employeeHistory">
-              <template slot-scope="props">
-
-                <b-table-column field="start_date" label="Start Date">
-                  {{ props.row.start_date }}
-                </b-table-column>
-
-                <b-table-column field="end_date" label="End Date">
-                  {{ props.row.end_date }}
-                </b-table-column>
-
-                % if request.has_perm('people_profile.edit_employee_history'):
-                    <b-table-column field="actions" label="Actions">
-                      <a href="#" @click.prevent="editEmployeeHistory(props.row)">
-                        <i class="fas fa-edit"></i>
-                        Edit
-                      </a>
-                    </b-table-column>
-                % endif
-
-              </template>
-            </b-table>
-
-          </div>
-
-          <p v-if="!employee.uuid">
-            ${person} has never been an employee.
-          </p>
-
-        </div>
-
-        <div>
-          <div class="buttons">
-
-            % 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="showStopEmployeeDialog = true">
-                  ${person} is no longer an Employee
-                </b-button>
-
-                <b-modal has-modal-card
-                         :active.sync="startEmployeeShowDialog">
-                  <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="employeeID"></b-input>
-                      </b-field>
-                      <b-field label="Start Date">
-                        <tailbone-datepicker v-model="employeeStartDate"></tailbone-datepicker>
-                      </b-field>
-                    </section>
-
-                    <footer class="modal-card-foot">
-                      <b-button @click="startEmployeeShowDialog = false">
-                        Cancel
-                      </b-button>
-                      <once-button type="is-primary"
-                                   @click="startEmployee()"
-                                   :disabled="!employeeStartDate"
-                                   text="Save">
-                      </once-button>
-                    </footer>
-                  </div>
-                </b-modal>
-
-                <b-modal has-modal-card
-                         :active.sync="showStopEmployeeDialog">
-                  <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="employeeEndDate ? null : 'is-danger'">
-                        <tailbone-datepicker v-model="employeeEndDate"></tailbone-datepicker>
-                      </b-field>
-                      <b-field label="Revoke Internal App Access">
-                        <b-checkbox v-model="employeeRevokeAccess">
-                        </b-checkbox>
-                      </b-field>
-                    </section>
-
-                    <footer class="modal-card-foot">
-                      <b-button @click="showStopEmployeeDialog = false">
-                        Cancel
-                      </b-button>
-                      <once-button type="is-primary"
-                                   @click="endEmployee()"
-                                   :disabled="!employeeEndDate"
-                                   text="Save">
-                      </once-button>
-                    </footer>
-                  </div>
-                </b-modal>
-            % endif
-
-            % if request.has_perm('people_profile.edit_employee_history'):
-                <b-modal has-modal-card
-                         :active.sync="showEditEmployeeHistoryDialog">
-                  <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="employeeHistoryStartDate"></tailbone-datepicker>
-                      </b-field>
-                      <b-field label="End Date">
-                        <tailbone-datepicker v-model="employeeHistoryEndDate"
-                                             :disabled="!employeeHistoryEndDateRequired">
-                        </tailbone-datepicker>
-                      </b-field>
-                    </section>
-
-                    <footer class="modal-card-foot">
-                      <b-button @click="showEditEmployeeHistoryDialog = false">
-                        Cancel
-                      </b-button>
-                      <once-button type="is-primary"
-                                   @click="saveEmployeeHistory()"
-                                   :disabled="!employeeHistoryStartDate || (employeeHistoryEndDateRequired && !employeeHistoryEndDate)"
-                                   text="Save">
-                      </once-button>
-                    </footer>
-                  </div>
-                </b-modal>
-            % endif
-
-            % 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>
-    </div>
-  </script>
-</%def>
-
-<%def name="render_employee_tab()">
-  <b-tab-item label="Employee"
-              icon-pack="fas"
-              :icon="employee.current ? 'check' : null">
-    <employee-tab :employee="employee"
-                  :employee-history="employeeHistory"
-                  @employee-updated="employeeUpdated"
-                  @employee-history-updated="employeeHistoryUpdated"
-                  @change-content-title="changeContentTitle">
-    </employee-tab>
-  </b-tab-item>
-</%def>
-
-<%def name="render_user_tab()">
-  <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 -->
-</%def>
-
-<%def name="render_profile_tabs()">
-  ${self.render_personal_tab()}
-  ${self.render_customer_tab()}
-  ${self.render_member_tab()}
-  ${self.render_employee_tab()}
-  ${self.render_user_tab()}
-</%def>
-
-<%def name="render_profile_info_template()">
-  <script type="text/x-template" id="profile-info-template">
-    <div>
-      <b-tabs v-model="activeTab" type="is-boxed">
-        ${self.render_profile_tabs()}
-      </b-tabs>
-    </div>
-  </script>
-</%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  ${self.render_personal_tab_template()}
-  ${self.render_employee_tab_template()}
-  ${self.render_profile_info_template()}
-</%def>
-
-<%def name="declare_personal_tab_vars()">
-  <script type="text/javascript">
-
-    let PersonalTabData = {
-
-        editNameShowDialog: false,
-        personFirstName: null,
-        personMiddleName: null,
-        personLastName: null,
-
-        editAddressShowDialog: false,
-        personStreet1: null,
-        personStreet2: null,
-        personCity: null,
-        personState: null,
-        personZipcode: null,
-        personInvalidAddress: false,
-
-        editPhoneShowDialog: false,
-        phoneUUID: null,
-        phoneType: null,
-        phoneNumber: null,
-        phonePreferred: false,
-        savingPhone: false,
-
-        editEmailShowDialog: false,
-        emailUUID: null,
-        emailType: null,
-        emailAddress: null,
-        emailPreferred: null,
-        emailInvalid: false,
-        editEmailSaving: false,
-    }
-
-    let PersonalTab = {
-        template: '#personal-tab-template',
-        mixins: [SubmitMixin],
-        props: {
-            person: Object,
-            member: Object,
-            phoneTypeOptions: Array,
-            emailTypeOptions: Array,
-            maxLengths: Object,
-        },
-        computed: {
-            % if request.has_perm('people_profile.edit_person'):
-                editNameSaveDisabled: function() {
-
-                    // first and last name are required
-                    if (!this.personFirstName || !this.personLastName) {
-                        return true
-                    }
-
-                    // otherwise don't disable; let user save
-                    return false
-                },
-
-                editAddressSaveDisabled: function() {
-
-                    // TODO: should require anything here?
-
-                    // otherwise don't disable; let user save
-                    return false
-                },
-
-                editPhoneSaveText() {
-                    if (this.savingPhone) {
-                        return "Working..."
-                    }
-                    return "Save"
-                },
-
-                editPhoneSaveDisabled: function() {
-                    if (this.savingPhone) {
-                        return true
-                    }
-
-                    // phone type is required
-                    if (!this.phoneType) {
-                        return true
-                    }
-
-                    // phone number is required
-                    if (!this.phoneNumber) {
-                        return true
-                    }
-
-                    // otherwise don't disable; let user save
-                    return false
-                },
-
-                editEmailSaveText() {
-                    if (this.editEmailSaving) {
-                        return "Working, please wait..."
-                    }
-                    return "Save"
-                },
-
-                editEmailSaveDisabled: function() {
-
-                    // disable if currently submitting form
-                    if (this.editEmailSaving) {
-                        return true
-                    }
-
-                    // email type is required
-                    if (!this.emailType) {
-                        return true
-                    }
-
-                    // email address is required
-                    if (!this.emailAddress) {
-                        return true
-                    }
-
-                    // otherwise don't disable; let user save
-                    return false
-                },
-            % endif
-        },
-        methods: {
-
-            changeContentTitle(newTitle) {
-                this.$emit('change-content-title', newTitle)
-            },
-
-            % if request.has_perm('people_profile.edit_person'):
-
-                editNameAllowed() {
-                    return true
-                },
-
-                editNameInit() {
-                    this.personFirstName = this.person.first_name
-                    this.personMiddleName = this.person.middle_name
-                    this.personLastName = this.person.last_name
-                    this.editNameShowDialog = true
-                },
-
-                editNameSave() {
-                    let url = '${url('people.profile_edit_name', uuid=person.uuid)}'
-
-                    let params = {
-                        first_name: this.personFirstName,
-                        middle_name: this.personMiddleName,
-                        last_name: this.personLastName,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editNameShowDialog = false
-                        // TODO: not sure this is standard upstream, or just in bespoke?
-                        if (response.data.dynamic_content_title) {
-                            that.$emit('change-content-title', response.data.dynamic_content_title)
-                        }
-                    })
-                },
-
-                editAddressInit() {
-                    let address = this.person.address
-                    this.personStreet1 = address ? address.street : null
-                    this.personStreet2 = address ? address.street2 : null
-                    this.personCity = address ? address.city : null
-                    this.personState = address ? address.state : null
-                    this.personZipcode = address ? address.zipcode : null
-                    this.personInvalidAddress = address ? address.invalid : false
-                    this.editAddressShowDialog = true
-                },
-
-                editAddressSave() {
-                    let url = '${url('people.profile_edit_address', uuid=person.uuid)}'
-
-                    let params = {
-                        street: this.personStreet1,
-                        street2: this.personStreet2,
-                        city: this.personCity,
-                        state: this.personState,
-                        zipcode: this.personZipcode,
-                        invalid: this.personInvalidAddress,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editAddressShowDialog = false
-                    })
-                },
-
-                addPhoneInit() {
-                    this.editPhoneInit({
-                        uuid: null,
-                        type: 'Home',
-                        number: null,
-                        preferred: false,
-                    })
-                },
-
-                editPhoneInit(phone) {
-                    this.phoneUUID = phone.uuid
-                    this.phoneType = phone.type
-                    this.phoneNumber = phone.number
-                    this.phonePreferred = phone.preferred
-                    this.editPhoneShowDialog = true
-                    this.$nextTick(function() {
-                        this.$refs.editPhoneInput.focus()
-                    })
-                },
-
-                editPhoneSave() {
-                    this.savingPhone = true
-
-                    let url
-                    let params = {
-                        phone_number: this.phoneNumber,
-                        phone_type: this.phoneType,
-                        phone_preferred: this.phonePreferred,
-                    }
-
-                    if (this.phoneUUID) {
-                        url = '${url('people.profile_update_phone', uuid=person.uuid)}'
-                        params.phone_uuid = this.phoneUUID
-                    } else {
-                        url = '${url('people.profile_add_phone', uuid=person.uuid)}'
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editPhoneShowDialog = false
-                        that.savingPhone = false
-                    })
-                },
-
-                deletePhone(phone) {
-                    let url = '${url('people.profile_delete_phone', uuid=person.uuid)}'
-
-                    let params = {
-                        phone_uuid: phone.uuid,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Phone number was deleted.",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
-                    })
-                },
-
-                setPreferredPhone(phone) {
-                    let url = '${url('people.profile_set_preferred_phone', uuid=person.uuid)}'
-
-                    let params = {
-                        phone_uuid: phone.uuid,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Phone preference updated!",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
-                    })
-                },
-
-                addEmailInit() {
-                    this.editEmailInit({
-                        uuid: null,
-                        type: 'Home',
-                        address: null,
-                        invalid: false,
-                        preferred: false,
-                    })
-                },
-
-                editEmailInit(email) {
-                    this.emailUUID = email.uuid
-                    this.emailType = email.type
-                    this.emailAddress = email.address
-                    this.emailInvalid = email.invalid
-                    this.emailPreferred = email.preferred
-                    this.editEmailShowDialog = true
-                    this.$nextTick(function() {
-                        this.$refs.editEmailInput.focus()
-                    })
-                },
-
-                editEmailSave() {
-                    this.editEmailSaving = true
-
-                    let url = null
-                    let params = {
-                        email_address: this.emailAddress,
-                        email_type: this.emailType,
-                    }
-
-                    if (this.emailUUID) {
-                        url = '${url('people.profile_update_email', uuid=person.uuid)}'
-                        params.email_uuid = this.emailUUID
-                        params.email_invalid = this.emailInvalid
-                    } else {
-                        url = '${url('people.profile_add_email', uuid=person.uuid)}'
-                        params.email_preferred = this.emailPreferred
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.editEmailShowDialog = false
-                        that.editEmailSaving = false
-                    }, function(error) {
-                        that.editEmailSaving = false
-                    })
-                },
-
-                deleteEmail(email) {
-                    let url = '${url('people.profile_delete_email', uuid=person.uuid)}'
-
-                    let params = {
-                        email_uuid: email.uuid,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Email address was deleted.",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
-                    })
-                },
-
-                setPreferredEmail(email) {
-                    let url = '${url('people.profile_set_preferred_email', uuid=person.uuid)}'
-
-                    let params = {
-                        email_uuid: email.uuid,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('person-updated', response.data.person)
-                        that.$buefy.toast.open({
-                            message: "Email preference updated!",
-                            type: 'is-info',
-                            duration: 3000, // 3 seconds
-                        })
-                    })
-                },
-
-            % 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)
-
-  </script>
-</%def>
-
-<%def name="declare_employee_tab_vars()">
-  <script type="text/javascript">
-
-    let EmployeeTabData = {
-
-        startEmployeeShowDialog: false,
-        employeeID: null,
-        employeeStartDate: null,
-        showStopEmployeeDialog: false,
-        employeeEndDate: null,
-        employeeRevokeAccess: false,
-        showEditEmployeeHistoryDialog: false,
-        employeeHistoryUUID: null,
-        employeeHistoryStartDate: null,
-        employeeHistoryEndDate: null,
-        employeeHistoryEndDateRequired: false,
-
-        % if request.has_perm('employees.edit'):
-        showEditEmployeeIDDialog: false,
-        newEmployeeID: null,
-        updatingEmployeeID: false,
-        % endif
-    }
-
-    let EmployeeTab = {
-        template: '#employee-tab-template',
-        mixins: [SubmitMixin],
-        props: {
-            employee: Object,
-            employeeHistory: Array,
-        },
-
-        computed: {
-
-            % if request.has_perm('employees.edit'):
-
-                editEmployeeIDSaveButtonText() {
-                    if (this.updatingEmployeeID) {
-                        return "Working, please wait..."
-                    }
-                    return "Save"
-                },
-
-            % endif
-        },
-
-        methods: {
-
-            changeContentTitle(newTitle) {
-                this.$emit('change-content-title', newTitle)
-            },
-
-            % if request.has_perm('employees.edit'):
-
-                initEditEmployeeID() {
-                    this.newEmployeeID = this.employee.id
-                    this.updatingEmployeeID = false
-                    this.showEditEmployeeIDDialog = true
-                },
-
-                updateEmployeeID() {
-                    this.updatingEmployeeID = true
-
-                    let url = '${url('people.profile_update_employee_id', uuid=instance.uuid)}'
-
-                    let params = {
-                        'employee_id': this.newEmployeeID,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('employee-updated', response.data.employee)
-                        that.showEditEmployeeIDDialog = false
-                        that.updatingEmployeeID = false
-                    })
-                },
-
-            % endif
-
-            % if request.has_perm('people_profile.toggle_employee'):
-
-                startEmployeeInit() {
-                    this.employeeID = this.employee.id || null
-                    this.startEmployeeShowDialog = true
-                },
-
-                startEmployee() {
-                    let url = '${url('people.profile_start_employee', uuid=person.uuid)}'
-
-                    let params = {
-                        id: this.employeeID,
-                        start_date: this.employeeStartDate,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.startEmployeeSuccess(response.data)
-                    })
-                },
-
-                startEmployeeSuccess(data) {
-                    this.$emit('employee-updated', data.employee)
-                    this.$emit('employee-history-updated', data.employee_history_data)
-                    this.$emit('change-content-title', data.dynamic_content_title)
-
-                    // let derived component do more here if needed
-                    this.startEmployeeSuccessExtra(data)
-
-                    this.startEmployeeShowDialog = false
-                },
-
-                startEmployeeSuccessExtra(data) {},
-
-                endEmployee() {
-                    let url = '${url('people.profile_end_employee', uuid=person.uuid)}'
-
-                    let params = {
-                        end_date: this.employeeEndDate,
-                        revoke_access: this.employeeRevokeAccess,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.endEmployeeSuccess(response.data)
-                    })
-                },
-
-                endEmployeeSuccess(data) {
-                    this.$emit('employee-updated', data.employee)
-                    this.$emit('employee-history-updated', data.employee_history_data)
-                    this.$emit('change-content-title', data.dynamic_content_title)
-
-                    // let derived component do more here if needed
-                    this.startEmployeeSuccessExtra(data)
-
-                    this.showStopEmployeeDialog = false
-                },
-
-                endEmployeeSuccessExtra(data) {},
-
-            % endif
-
-            % if request.has_perm('people_profile.edit_employee_history'):
-
-                editEmployeeHistory(row) {
-                    this.employeeHistoryUUID = row.uuid
-                    this.employeeHistoryStartDate = row.start_date
-                    this.employeeHistoryEndDate = row.end_date
-                    this.employeeHistoryEndDateRequired = !!row.end_date
-                    this.showEditEmployeeHistoryDialog = true
-                },
-
-                saveEmployeeHistory() {
-                    let url = '${url('people.profile_edit_employee_history', uuid=person.uuid)}'
-
-                    let params = {
-                        uuid: this.employeeHistoryUUID,
-                        start_date: this.employeeHistoryStartDate,
-                        end_date: this.employeeHistoryEndDate,
-                    }
-
-                    let that = this
-                    this.submitData(url, params, function(response) {
-                        that.$emit('employee-updated', response.data.employee)
-                        that.$emit('employee-history-updated', response.data.employee_history_data)
-                        that.showEditEmployeeHistoryDialog = 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)
-
-  </script>
-</%def>
-
-<%def name="declare_profile_info_vars()">
-  <script type="text/javascript">
-
-    let ProfileInfoData = {
-        activeTab: 0,
-        person: ${json.dumps(person_data)|n},
-        customers: ${json.dumps(customers_data)|n},
-        member: null,           // TODO
-        members: ${json.dumps(members_data)|n},
-        employee: ${json.dumps(employee_data)|n},
-        employeeHistory: ${json.dumps(employee_history_data)|n},
-        phoneTypeOptions: ${json.dumps(phone_type_options)|n},
-        emailTypeOptions: ${json.dumps(email_type_options)|n},
-        maxLengths: ${json.dumps(max_lengths)|n},
-    }
-
-    let ProfileInfo = {
-        template: '#profile-info-template',
-        mixins: [FormPosterMixin],
-        computed: {},
-        methods: {
-            personUpdated(person) {
-                this.person = person
-            },
-            employeeUpdated(employee) {
-                this.employee = employee
-            },
-            employeeHistoryUpdated(employeeHistory) {
-                this.employeeHistory = employeeHistory
-            },
-            changeContentTitle(newTitle) {
-                this.$emit('change-content-title', newTitle)
-            },
-        },
-    }
-
-  </script>
-</%def>
-
-<%def name="make_profile_info_component()">
-  ${self.declare_profile_info_vars()}
-  <script type="text/javascript">
-
-    ProfileInfo.data = function() { return ProfileInfoData }
-
-    Vue.component('profile-info', ProfileInfo)
-
-  </script>
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ThisPage.methods.changeContentTitle = function(newTitle) {
-        this.$emit('change-content-title', newTitle)
-    }
-
-    var SubmitMixin = {
-        data() {
-            return {
-                csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
-            }
-        },
-
-        methods: {
-            submitData(url, params, success, failure) {
-                let headers = {
-                    'X-CSRF-TOKEN': this.csrftoken,
-                }
-                this.$http.post(url, params, {headers: headers}).then((response) => {
-                    if (response.data.success) {
-                        if (success) {
-                            success(response)
-                        }
-                    } else {
-                        this.$buefy.toast.open({
-                            message: "Save failed:  " + (response.data.error || "(unknown error)"),
-                            type: 'is-danger',
-                            duration: 4000, // 4 seconds
-                        })
-                        if (failure) {
-                            failure()
-                        }
-                    }
-                }).catch((error) => {
-                    this.$buefy.toast.open({
-                        message: "Save failed:  (unknown error)",
-                        type: 'is-danger',
-                        duration: 4000, // 4 seconds
-                    })
-                    if (failure) {
-                        failure()
-                    }
-                })
-            },
-        },
-    }
-
-  </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  ${self.make_personal_tab_component()}
-  ${self.make_employee_tab_component()}
-  ${self.make_profile_info_component()}
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako
index aac0c7ae..cb8b51aa 100644
--- a/tailbone/templates/poser/reports/view.mako
+++ b/tailbone/templates/poser/reports/view.mako
@@ -62,19 +62,13 @@
   <br />
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('replace'):
-  <script type="text/javascript">
-
-    ${form.component_studly}Data.showUploadForm = false
-
-    ${form.component_studly}Data.uploadFile = null
-
-    ${form.component_studly}Data.uploadSubmitting = false
-
-  </script>
+      <script>
+        ${form.vue_component}Data.showUploadForm = false
+        ${form.vue_component}Data.uploadFile = null
+        ${form.vue_component}Data.uploadSubmitting = false
+      </script>
   % endif
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako
index 8d01bb33..239e7db2 100644
--- a/tailbone/templates/poser/setup.mako
+++ b/tailbone/templates/poser/setup.mako
@@ -118,14 +118,9 @@
   % 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>
     ThisPageData.setupSubmitting = false
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako
index f4d75779..cdde15c5 100644
--- a/tailbone/templates/poser/views/configure.mako
+++ b/tailbone/templates/poser/views/configure.mako
@@ -9,7 +9,7 @@
 
   % 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 six.iteritems(topgroup):
+      % 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)}
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 be055b50..db029e5a 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -7,66 +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.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(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>
@@ -74,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...
@@ -159,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
@@ -178,6 +115,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako
index 612b8d36..a43a85d4 100644
--- a/tailbone/templates/products/configure.mako
+++ b/tailbone/templates/products/configure.mako
@@ -36,12 +36,21 @@
       </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 lokkup">
+    <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"
@@ -50,6 +59,15 @@
       </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>
@@ -77,9 +95,9 @@
   </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>
 
     ThisPage.methods.getTitleForKey = function(key) {
         switch (key) {
@@ -100,6 +118,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako
index 8eada2fc..5ffa9512 100644
--- a/tailbone/templates/products/index.mako
+++ b/tailbone/templates/products/index.mako
@@ -1,133 +1,26 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  % if not use_buefy:
-  <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>
-  % endif
-</%def>
-
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy and label_profiles and master.has_perm('print_labels'):
-      <script type="text/javascript">
-
-      $(function() {
-
-          $('.grid-wrapper .grid-header .tools select').selectmenu();
-
-          $('.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 threshold = ${json.dumps(quick_label_speedbump_threshold)|n};
-                  if (threshold && parseInt(quantity) >= threshold) {
-                      if (!confirm("Are you sure you want to print " + quantity + " labels?")) {
-                          return false;
-                      }
-                  }
-
-                  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;
-          });
-      });
-
-      </script>
-  % endif
-</%def>
-
 <%def name="grid_tools()">
   ${parent.grid_tools()}
   % if label_profiles and master.has_perm('print_labels'):
-      % if use_buefy:
-          <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>
-      % else:
-      <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
+      <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>
 
@@ -143,16 +36,16 @@
   </${grid.component}>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if label_profiles and master.has_perm('print_labels'):
-      <script type="text/javascript">
+      <script>
 
-        ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n}
-        ${grid.component_studly}Data.quickLabelQuantity = 1
-        ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n}
+        ${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.component_studly}.methods.quickLabelPrint = function(row) {
+        ${grid.vue_component}.methods.quickLabelPrint = function(row) {
 
             let quantity = parseInt(this.quickLabelQuantity)
             if (isNaN(quantity)) {
@@ -190,6 +83,3 @@
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako
index 10620749..bb9590b2 100644
--- a/tailbone/templates/products/lookup.mako
+++ b/tailbone/templates/products/lookup.mako
@@ -2,8 +2,54 @@
 
 <%def name="tailbone_product_lookup_template()">
   <script type="text/x-template" id="tailbone-product-lookup-template">
-    <div>
-      <b-modal :active.sync="showingDialog">
+    <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">
 
@@ -11,8 +57,10 @@
 
               <b-input v-model="searchTerm" 
                        ref="searchTermInput"
-                       @keydown.native="searchTermInputKeydown">
-              </b-input>
+                       % if not request.use_oruga:
+                           @keydown.native="searchTermInputKeydown"
+                       % endif
+                       />
 
               <b-button class="control"
                         type="is-primary"
@@ -47,81 +95,103 @@
 
             </b-field>
 
-            <b-table :data="searchResults"
-                     narrowed
-                     icon-pack="fas"
-                     :loading="searchResultsLoading"
-                     :selected.sync="searchResultSelected">
-              <template slot-scope="props">
+            <${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">
-                  {{ props.row.product_key }}
-                </b-table-column>
+              <${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">
-                  {{ props.row.brand_name }}
-                </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">
+              <${b}-table-column label="Description"
+                              field="description"
+                              v-slot="props">
+                <span :class="{organic: props.row.organic}">
                   {{ props.row.description }}
                   {{ props.row.size }}
-                </b-table-column>
+                </span>
+              </${b}-table-column>
 
-                <b-table-column label="Unit Price"
-                                field="unit_price">
-                  {{ props.row.unit_price_display }}
-                </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">
-                  <span class="has-background-warning">
-                    {{ props.row.sale_price_display }}
-                  </span>
-                </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">
-                  <span class="has-background-warning">
-                    {{ props.row.sale_ends_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">
-                  {{ props.row.department_name }}
-                </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">
-                  {{ props.row.vendor_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">
-                  <a :href="props.row.url"
-                     target="_blank"
-                     class="grid-action">
-                    <i class="fas fa-external-link-alt"></i>
-                    View
-                  </a>
-                </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>
-              <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>
                   <p>Nothing here.</p>
                 </div>
               </template>
-            </b-table>
+            </${b}-table>
 
             <br />
             <div class="level">
@@ -150,6 +220,7 @@
           </div>
         </div>
       </b-modal>
+
     </div>
   </script>
 </%def>
@@ -159,12 +230,27 @@
 
     const TailboneProductLookup = {
         template: '#tailbone-product-lookup-template',
+        props: {
+            product: {
+                type: Object,
+            },
+            autocompleteUrl: {
+                type: String,
+                default: '${url('products.autocomplete')}',
+            },
+        },
         data() {
             return {
-                showingDialog: false,
+                autocompleteValue: '',
+                autocompleteOptions: [],
+
+                lookupShowDialog: false,
 
                 searchTerm: null,
                 searchTermLastUsed: null,
+                % if request.use_oruga:
+                    searchTermInputElement: null,
+                % endif
 
                 searchProductKey: true,
                 searchVendorItemCode: true,
@@ -178,25 +264,93 @@
                 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: {
 
-            showDialog(term) {
+            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
 
-                if (term !== undefined) {
-                    this.searchTerm = term
-                    // perform search if invoked with new term
-                    if (term != this.searchTermLastUsed) {
+                this.$nextTick(() => {
+
+                    this.searchTerm = this.autocompleteValue
+                    if (this.searchTerm != this.searchTermLastUsed) {
                         this.searchTermLastUsed = null
                         this.performSearch()
                     }
-                } else {
-                    this.searchTerm = this.searchTermLastUsed
-                }
 
-                this.showingDialog = true
-                this.$nextTick(() => {
                     this.$refs.searchTermInput.focus()
                 })
             },
@@ -207,17 +361,6 @@
                 }
             },
 
-            cancelDialog() {
-                this.searchResultSelected = null
-                this.showingDialog = false
-                this.$emit('canceled')
-            },
-
-            selectResult() {
-                this.showingDialog = false
-                this.$emit('selected', this.searchResultSelected)
-            },
-
             performSearch() {
                 if (this.searchResultsLoading) {
                     return
@@ -248,10 +391,21 @@
                     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
index 90d9c687..72c9c76d 100644
--- a/tailbone/templates/products/pending/view.mako
+++ b/tailbone/templates/products/pending/view.mako
@@ -1,46 +1,17 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
+<%namespace name="product_lookup" file="/products/lookup.mako" />
 
-<%def name="object_helpers()">
-  ${parent.object_helpers()}
-  % if instance.custorder_item_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 Order Items:
-            </p>
-            <ul class="list">
-              % for item in instance.custorder_item_records:
-                  <li class="list-item">
-                    ${h.link_to('#{}-{}'.format(item.order.id, item.sequence), url('custorders.items.view', uuid=item.uuid))}
-                  </li>
-              % endfor
-            </ul>
-          </div>
-        </div>
-      </nav>
+<%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 instance.status_code == enum.PENDING_PRODUCT_STATUS_PENDING and master.has_perm('resolve_product'):
-      <nav class="panel">
-        <p class="panel-heading">Tools</p>
-        <div class="panel-block">
-          <div style="display: flex; flex-direction: column;">
-            <div class="buttons">
-              <b-button type="is-primary"
-                        @click="resolveProductInit()"
-                        icon-pack="fas"
-                        icon-left="object-ungroup">
-                Resolve Product
-              </b-button>
-            </div>
-          </div>
-        </div>
-      </nav>
-
+  % 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">
@@ -53,25 +24,24 @@
 
           <section class="modal-card-body">
             <p class="block">
-              If this Product already exists, you can declare that by
+              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 grouped>
-              <b-field label="Pending">
-                <span>${instance.full_description}</span>
-              </b-field>
-              <b-field label="Actual Product" expanded>
-                <tailbone-autocomplete name="product_uuid"
-                                       v-model="resolveProductUUID"
-                                       ref="resolveProductAutocomplete"
-                                       service-url="${url('products.autocomplete')}">
-                </tailbone-autocomplete>
-              </b-field>
+            <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">
@@ -92,39 +62,69 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${product_lookup.tailbone_product_lookup_template()}
+</%def>
 
-    ThisPageData.resolveProductShowDialog = false
-    ThisPageData.resolveProductUUID = null
-    ThisPageData.resolveProductSubmitting = false
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    ThisPage.computed.resolveProductSubmitDisabled = function() {
-        if (this.resolveProductSubmitting) {
-            return true
+    % 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()
         }
-        if (!this.resolveProductUUID) {
-            return true
+
+    % 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
         }
-        return false
-    }
 
-    ThisPage.methods.resolveProductInit = function() {
-        this.resolveProductUUID = null
-        this.resolveProductShowDialog = true
-        this.$nextTick(() => {
-            this.$refs.resolveProductAutocomplete.focus()
-        })
-    }
+        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()
-    }
+        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>
 
-
-${parent.body()}
+<%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 cac17f1a..66ca3128 100644
--- a/tailbone/templates/products/view.mako
+++ b/tailbone/templates/products/view.mako
@@ -1,148 +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(product_key_field)}
-  ${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('average_weight')}
-  ${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>
 
@@ -159,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)">
@@ -201,33 +90,20 @@
 </%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)">
@@ -235,145 +111,54 @@
 </%def>
 
 <%def name="lookup_codes_grid()">
-  % if use_buefy:
-      ${lookup_codes['grid'].render_buefy_table_element(data_prop='lookupCodesData')|n}
-  % else:
-  <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>
-  % endif
+  ${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()">
-  % if use_buefy:
-      ${vendor_sources['grid'].render_buefy_table_element(data_prop='vendorSourcesData')|n}
-  % else:
-  <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>
-  % endif
+  ${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>
@@ -382,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>
@@ -393,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">
@@ -412,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">
@@ -431,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">
@@ -450,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">
@@ -463,92 +248,43 @@
 </%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()}
-  <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}
@@ -556,7 +292,7 @@
     % 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() {
@@ -585,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() {
@@ -615,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() {
@@ -644,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() {
@@ -675,6 +411,3 @@
     % 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
index 9f4a6c3b..a36dde43 100644
--- a/tailbone/templates/receiving/configure.mako
+++ b/tailbone/templates/receiving/configure.mako
@@ -24,7 +24,16 @@
                   v-model="simpleSettings['rattail.batch.purchase.allow_receiving_from_invoice']"
                   native-value="true"
                   @input="settingsNeedSaved = true">
-        From Invoice
+        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>
 
@@ -60,12 +69,12 @@
   <h3 class="block is-size-3">Vendors</h3>
   <div class="block" style="padding-left: 2rem;">
 
-    <b-field message="If set, user must choose a &quot;supported&quot; vendor; otherwise they may choose &quot;any&quot; vendor.">
-      <b-checkbox name="rattail.batch.purchase.supported_vendors_only"
-                  v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']"
+    <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">
-        Only allow batch for "supported" vendors
+        Allow receiving for <span class="has-text-weight-bold">any</span> vendor
       </b-checkbox>
     </b-field>
 
@@ -106,6 +115,15 @@
       </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']"
@@ -142,6 +160,15 @@
       </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>
diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako
index c31cb849..35ee878a 100644
--- a/tailbone/templates/receiving/create.mako
+++ b/tailbone/templates/receiving/create.mako
@@ -1,80 +1,6 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  ${self.func_show_batch_type()}
-  <script type="text/javascript">
-
-    % if master.handler.allow_truck_dump_receiving():
-    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>
-  % endif
-</%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 84d4dbec..a377e270 100644
--- a/tailbone/templates/receiving/declare_credit.mako
+++ b/tailbone/templates/receiving/declare_credit.mako
@@ -1,6 +1,5 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
-<%namespace file="/forms/util.mako" import="render_buefy_field" />
 
 <%def name="title()">Declare Credit for Row #${row.sequence}</%def>
 
@@ -11,36 +10,7 @@
   % endif
 </%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    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();
-        }
-    }
-
-    $(function() {
-
-        toggleFields();
-
-        $('select[name="credit_type"]').on('selectmenuchange', function(event, ui) {
-            toggleFields(ui.item.value);
-        });
-
-    });
-  </script>
-  % endif
-</%def>
-
-<%def name="render_buefy_form()">
+<%def name="render_form()">
 
   <p class="block">
     Please select the "state" of the product, and enter the
@@ -60,45 +30,22 @@
     if you need to "receive" instead of "convert" the product.
   </p>
 
-  ${parent.render_buefy_form()}
+  ${parent.render_form()}
 
 </%def>
 
-<%def name="buefy_form_body()">
+<%def name="form_body()">
 
-  ${render_buefy_field(dform['credit_type'])}
+  ${form.render_field_complete('credit_type')}
 
-  ${render_buefy_field(dform['quantity'])}
+  ${form.render_field_complete('quantity')}
 
-  ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_credit_type == 'expired'"})}
+  ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})}
 
 </%def>
 
-<%def name="render_form()">
-  % if use_buefy:
-
-      ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n}
-
-  % else:
-
-    <p style="padding: 1em;">
-      Please select the "state" of the product, and enter the appropriate
-      quantity.
-    </p>
-
-    <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>
-
-    <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>
-
-    ${parent.render_form()}
-
-  % endif
+<%def name="render_form_template()">
+  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n}
 </%def>
 
 
diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako
index b17b118a..48dc6755 100644
--- a/tailbone/templates/receiving/receive_row.mako
+++ b/tailbone/templates/receiving/receive_row.mako
@@ -1,6 +1,5 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
-<%namespace file="/forms/util.mako" import="render_buefy_field" />
 
 <%def name="title()">Receive for Row #${row.sequence}</%def>
 
@@ -11,36 +10,7 @@
   % endif
 </%def>
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy:
-  <script type="text/javascript">
-
-    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();
-        }
-    }
-
-    $(function() {
-
-        toggleFields();
-
-        $('select[name="mode"]').on('selectmenuchange', function(event, ui) {
-            toggleFields(ui.item.value);
-        });
-
-    });
-  </script>
-  % endif
-</%def>
-
-<%def name="render_buefy_form()">
+<%def name="render_form()">
 
   <p class="block">
     Please select the "state" of the product, and enter the appropriate
@@ -57,45 +27,22 @@
     if you need to "convert" some already-received amount, into a credit.
   </p>
 
-  ${parent.render_buefy_form()}
+  ${parent.render_form()}
 
 </%def>
 
-<%def name="buefy_form_body()">
+<%def name="form_body()">
 
-  ${render_buefy_field(dform['mode'])}
+  ${form.render_field_complete('mode')}
 
-  ${render_buefy_field(dform['quantity'])}
+  ${form.render_field_complete('quantity')}
 
-  ${render_buefy_field(dform['expiration_date'], bfield_kwargs={'v-show': "field_model_mode == 'expired'"})}
+  ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})}
 
 </%def>
 
-<%def name="render_form()">
-  % if use_buefy:
-
-      ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.buefy_form_body))|n}
-
-  % else:
-
-    <p style="padding: 1em;">
-      Please select the "state" of the product, and enter the appropriate
-      quantity.
-    </p>
-
-    <p style="padding: 1em;">
-      Note that this tool will <strong>add</strong> the corresponding
-      quantities for the row.
-    </p>
-
-    <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>
-
-    ${parent.render_form()}
-
-  % endif
+<%def name="render_form_template()">
+  ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n}
 </%def>
 
 
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index b16aa5b8..710dec4a 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -1,386 +1,153 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/view.mako" />
 
-<%def name="extra_javascript()">
-  ${parent.extra_javascript()}
-  % if not use_buefy and 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();
-        }
-
-        function start_editing_catalog_cost(td) {
-            start_editing(td);
-            editing_catalog_cost = td;
-        }
-
-        function start_editing_invoice_cost(td) {
-            start_editing(td);
-            editing_invoice_cost = td;
-        }
-
-        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;
-            }
-        }
-
-        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>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  % if use_buefy:
-      <style type="text/css">
-        % if allow_edit_catalog_unit_cost:
-            td.catalog_unit_cost {
-                cursor: pointer;
-                background-color: #fcc;
-            }
-            tr.catalog_cost_confirmed td.catalog_unit_cost {
-                background-color: #cfc;
-            }
-        % endif
-        % if allow_edit_invoice_unit_cost:
-            td.invoice_unit_cost {
-                cursor: pointer;
-                background-color: #fcc;
-            }
-            tr.invoice_cost_confirmed td.invoice_unit_cost {
-                background-color: #cfc;
-            }
-        % endif
-      </style>
-  % elif not use_buefy and 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;
+  <style type="text/css">
+    % if allow_edit_catalog_unit_cost:
+        td.c_catalog_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;
+        tr.catalog_cost_confirmed td.c_catalog_unit_cost {
+            background-color: #cfc;
         }
-        .grid td.catalog_unit_cost input,
-        .grid td.invoice_unit_cost input {
-          width: 4rem;
+    % endif
+    % if allow_edit_invoice_unit_cost:
+        td.c_invoice_unit_cost {
+            cursor: pointer;
+            background-color: #fcc;
         }
-      </style>
-  % endif
+        tr.invoice_cost_confirmed td.c_invoice_unit_cost {
+            background-color: #cfc;
+        }
+    % endif
+  </style>
 </%def>
 
 <%def name="render_po_vs_invoice_helper()">
-  % if use_buefy and master.handler.has_purchase_order(batch) and master.handler.has_invoice_file(batch):
-      <div class="object-helper">
-        <h3>PO vs. Invoice</h3>
-        <div class="object-helper-content">
-          ${po_vs_invoice_breakdown_grid}
+  % 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>
-      </div>
+      </nav>
   % endif
 </%def>
 
-<%def name="render_auto_receive_helper()">
-  % if master.has_perm('auto_receive') and master.can_auto_receive(batch):
+<%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%;">
 
-      <div class="object-helper">
-        <h3>Tools</h3>
-        <div class="object-helper-content">
-          % if use_buefy:
-              <b-button type="is-primary"
-                        @click="autoReceiveShowDialog = true"
-                        icon-pack="fas"
-                        icon-left="check">
-                Auto-Receive All Items
-              </b-button>
-          % else:
-              ${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()}
-          % endif
-        </div>
-      </div>
-
-      % if use_buefy:
-          <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)}
+            % if allow_confirm_all_costs:
                 <b-button type="is-primary"
-                          native-type="submit"
-                          :disabled="autoReceiveSubmitting"
+                          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">
-                  {{ autoReceiveSubmitting ? "Working, please wait..." : "Auto-Receive All Items" }}
+                  Auto-Receive All Items
                 </b-button>
-                ${h.end_form()}
-              </footer>
-            </div>
-          </b-modal>
-      % endif
+                <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="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="object_helpers()">
+  ${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>
@@ -390,24 +157,27 @@
           <b-input v-model="inputValue"
                    ref="input"
                    v-show="editing"
+                   size="is-small"
                    @keydown.native="inputKeyDown"
-                   @blur="inputBlur">
+                   @focus="selectAll"
+                   @blur="inputBlur"
+                   style="width: 6rem;">
           </b-input>
         </div>
       </script>
   % endif
 </%def>
 
-<%def name="object_helpers()">
-  ${self.render_status_breakdown()}
-  ${self.render_po_vs_invoice_helper()}
-  ${self.render_execute_helper()}
-  ${self.render_auto_receive_helper()}
-</%def>
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+    % if allow_confirm_all_costs:
+
+        ThisPageData.confirmAllCostsShowDialog = false
+        ThisPageData.confirmAllCostsSubmitting = false
+
+    % endif
 
     ThisPageData.autoReceiveShowDialog = false
     ThisPageData.autoReceiveSubmitting = false
@@ -463,6 +233,7 @@
 
         let ReceivingCostEditor = {
             template: '#receiving-cost-editor-template',
+            mixins: [SimpleRequestMixin],
             props: {
                 row: Object,
                 'field': String,
@@ -476,8 +247,16 @@
             },
             methods: {
 
+                selectAll() {
+                    // nb. must traverse into the <b-input> element
+                    let trueInput = this.$refs.input.$el.firstChild
+                    trueInput.select()
+                },
+
                 startEdit() {
-                    this.inputValue = this.value
+                    // 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()
@@ -512,41 +291,21 @@
                 submitEdit() {
                     let url = '${url('{}.update_row_cost'.format(route_prefix), uuid=batch.uuid)}'
 
-                    // TODO: should get csrf token from parent component?
-                    let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
-                    let headers = {'${csrf_header_name}': csrftoken}
-
                     let params = {
                         row_uuid: this.$props.row.uuid,
                     }
                     params[this.$props.field] = this.inputValue
 
-                    this.$http.post(url, params, {headers: headers}).then(response => {
-                        if (!response.data.error) {
+                    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)
+                        // 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
-
-                        } else {
-                            this.$buefy.toast.open({
-                                message: "Submit failed:  " + response.data.error,
-                                type: 'is-warning',
-                                duration: 4000, // 4 seconds
-                            })
-                        }
-
-                    }, response => {
-                        this.$buefy.toast.open({
-                            message: "Submit failed:  (unknown error)",
-                            type: 'is-warning',
-                            duration: 4000, // 4 seconds
-                        })
+                        // and hide the input box
+                        this.editing = false
                     })
                 },
             },
@@ -558,22 +317,34 @@
 
     % if allow_edit_catalog_unit_cost:
 
-        ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) {
+        ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) {
 
             // start edit for clicked cell
             this.$refs['catalogUnitCost_' + row.uuid].startEdit()
         }
 
-        ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) {
+        ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) {
 
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'catalog_cost_confirmed')
 
-            // start editing next row, unless there are no more
-            let nextRow = index + 1
-            if (this.data.length > nextRow) {
-                nextRow = this.data[nextRow]
-                this.$refs['catalogUnitCost_' + nextRow.uuid].startEdit()
+            // 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()
             }
         }
 
@@ -581,22 +352,35 @@
 
     % if allow_edit_invoice_unit_cost:
 
-        ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) {
+        ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) {
 
             // start edit for clicked cell
             this.$refs['invoiceUnitCost_' + row.uuid].startEdit()
         }
 
-        ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) {
+        ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) {
 
             // update display to indicate cost was confirmed
             this.addRowClass(index, 'invoice_cost_confirmed')
 
-            // start editing next row, unless there are no more
-            let nextRow = index + 1
-            if (this.data.length > nextRow) {
-                nextRow = this.data[nextRow]
-                this.$refs['invoiceUnitCost_' + nextRow.uuid].startEdit()
+            // 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()
+                }
             }
         }
 
@@ -604,17 +388,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
-
-% if not use_buefy and master.handler.allow_truck_dump_receiving() and master.has_perm('edit_row'):
-    ${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()}
-
-    <div id="transform-unit-dialog" style="display: none;">
-      <p>hello world</p>
-    </div>
-% endif
diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako
index dca71c35..086754c6 100644
--- a/tailbone/templates/receiving/view_row.mako
+++ b/tailbone/templates/receiving/view_row.mako
@@ -4,485 +4,489 @@
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   <style type="text/css">
-  % if use_buefy:
 
-        nav.panel {
-            margin: 0.5rem;
-        }
+    nav.panel {
+        margin: 0.5rem;
+    }
 
-        .header-fields {
-            margin-top: 1rem;
-        }
+    .header-fields {
+        margin-top: 1rem;
+    }
 
-        .header-fields .field.is-horizontal {
-            margin-left: 3rem;
-        }
+    .header-fields .field.is-horizontal {
+        margin-left: 3rem;
+    }
 
-        .header-fields .field.is-horizontal .field-label .label {
-            white-space: nowrap;
-        }
+    .header-fields .field.is-horizontal .field-label .label {
+        white-space: nowrap;
+    }
 
-        .quantity-form-fields {
-            margin: 2rem auto;
-            padding-left: 2rem;
-        }
+    .quantity-form-fields {
+        margin: 2rem;
+    }
 
-        .quantity-form-fields .field.is-horizontal .field-label .label {
-            text-align: left;
-            width: 8rem;
-        }
+    .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;
-        }
+    .remove-credit .field.is-horizontal .field-label .label {
+        white-space: nowrap;
+    }
 
-  % endif
   </style>
 </%def>
 
-<%def name="object_helpers()">
-  ${parent.object_helpers()}
-  % if not use_buefy and master.row_editable(row) 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')}
+<%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>
-  % endif
-</%def>
+    </nav>
 
-<%def name="page_content()">
-  % if use_buefy:
-
-      <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;">
-              <div>
-                % if row.product:
-                    ${form.render_field_readonly(product_key_field)}
-                    ${form.render_field_readonly('product')}
-                % else:
-                    ${form.render_field_readonly(product_key_field)}
-                    ${form.render_field_readonly('item_entry')}
-                    % 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 class="is-pulled-right">
-                    ${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
-               :active.sync="accountForProductShowDialog">
-        <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>
+    <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>
 
-            <div class="level">
-              <div class="level-left">
+            <hr />
 
-                <div class="level-item">
-                  <numeric-input v-model="accountForProductQuantity"
-                                 ref="accountForProductQuantityInput">
-                  </numeric-input>
-                </div>
+            <b-field label="Shipped" horizontal>
+              {{ rowData.shipped }}
+            </b-field>
 
-                <div class="level-item">
-                  % if allow_cases:
-                      <b-field>
-                        <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>
-                  % else:
-                      <b-field>
-                        <input type="hidden" v-model="accountForProductUOM" />
-                        Units
-                      </b-field>
-                  % endif
-                </div>
+            <hr />
 
-                % if allow_cases:
-                    <div class="level-item"
-                         v-if="accountForProductUOM == 'cases' && accountForProductQuantity">
-                      = {{ accountForProductTotalUnits }}
-                    </div>
-                % endif
+            <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>
-            </div>
+          % endif
 
-          </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>
+      </div>
+    </nav>
 
-      <b-modal has-modal-card
-               :active.sync="declareCreditShowDialog">
-        <div class="modal-card">
+  </div>
 
-          <header class="modal-card-head">
-            <p class="modal-card-title">Declare Credit</p>
-          </header>
+  <${b}-modal has-modal-card
+              % if request.use_oruga:
+                  v-model:active="accountForProductShowDialog"
+              % else:
+                  :active.sync="accountForProductShowDialog"
+              % endif
+              >
+    <div class="modal-card">
 
-          <section class="modal-card-body">
+      <header class="modal-card-head">
+        <p class="modal-card-title">Account for Product</p>
+      </header>
 
-            <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>
+      <section class="modal-card-body">
 
-            <b-field grouped>
+        <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 label="Received">
+        <b-field grouped>
+
+          % if allow_cases:
+              <b-field label="Case Qty.">
                 <span class="control">
-                  {{ rowData.received }}
+                  {{ rowData.case_quantity }}
                 </span>
               </b-field>
 
               <span class="control">
                 &nbsp;
               </span>
+          % endif
 
-              <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="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="declareCreditType == 'expired'"
-                       :type="declareCreditExpiration ? null : 'is-danger'">
-                <tailbone-datepicker v-model="declareCreditExpiration">
-                </tailbone-datepicker>
-              </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>
+        </b-field>
 
-            <div class="level">
-              <div class="level-left">
+        <div style="display: flex; gap: 0.5rem; align-items: center;">
 
-                <div class="level-item">
-                  <numeric-input v-model="declareCreditQuantity"
-                                 ref="declareCreditQuantityInput">
-                  </numeric-input>
-                </div>
+          <numeric-input v-model="accountForProductQuantity"
+                         ref="accountForProductQuantityInput">
+          </numeric-input>
 
-                <div class="level-item">
-                  % if allow_cases:
-                      <b-field>
-                        <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>
-                  % else:
-                      <b-field>
-                        <input type="hidden" v-model="declareCreditUOM" />
-                        Units
-                      </b-field>
-                  % endif
-                </div>
+          % 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>
 
-                % if allow_cases:
-                    <div class="level-item"
-                         v-if="declareCreditUOM == 'cases' && declareCreditQuantity">
-                      = {{ declareCreditTotalUnits }}
+          % 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>
 
-              </div>
-            </div>
+            % else:
+                <b-field>
+                  <input type="hidden" v-model="declareCreditUOM" />
+                  Units
+                </b-field>
+            % endif
 
-          </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>
+      </section>
 
-      <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>
-                  ${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>
-                  ${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
+      <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>
 
-  % else:
-      ## legacy / not buefy
-      ${parent.page_content()}
-  % endif
+  <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_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
 ##     ThisPage.methods.editUnitCost = function() {
 ##         alert("TODO: not yet implemented")
@@ -538,6 +542,10 @@
 
     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(() => {
@@ -712,6 +720,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
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/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako
index 7f24bfde..0921530c 100644
--- a/tailbone/templates/reports/generated/choose.mako
+++ b/tailbone/templates/reports/generated/choose.mako
@@ -1,47 +1,14 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/create.mako" />
 
-<%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
+        #report-description {
+          margin-left: 2em;
+        }
     % else:
         .report-selection {
           margin-left: 10em;
@@ -56,7 +23,7 @@
   </style>
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <p>Please select the type of report you wish to generate.</p>
     <br />
@@ -69,19 +36,7 @@
 
 <%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
+      ${parent.page_content()}
   % else:
       <div>
         <br />
@@ -98,13 +53,13 @@
   % endif
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n}
+    ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n}
 
-    TailboneForm.methods.reportTypeChanged = function(reportType) {
+    ${form.vue_component}.methods.reportTypeChanged = function(reportType) {
         this.$emit('report-change', this.reportDescriptions[reportType])
     }
 
@@ -116,6 +71,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako
index 0c994ad0..f60a9819 100644
--- a/tailbone/templates/reports/generated/delete.mako
+++ b/tailbone/templates/reports/generated/delete.mako
@@ -1,16 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/delete.mako" />
 
-<%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 params_data is not Undefined:
-        ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n}
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/generate.mako b/tailbone/templates/reports/generated/generate.mako
index 38adfe34..2b8fa66c 100644
--- a/tailbone/templates/reports/generated/generate.mako
+++ b/tailbone/templates/reports/generated/generate.mako
@@ -5,19 +5,24 @@
 
 <%def name="content_title()">New Report:&nbsp; ${report.name}</%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
-    <p style="padding: 1em;">${report.__doc__}</p>
-    <br />
+    <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>
 
-<%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/view.mako b/tailbone/templates/reports/generated/view.mako
index 6260efba..cce6f346 100644
--- a/tailbone/templates/reports/generated/view.mako
+++ b/tailbone/templates/reports/generated/view.mako
@@ -23,16 +23,11 @@
   % 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 params_data is not Undefined:
-        ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n}
+        ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
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
index 026c73dc..5cdf2be5 100644
--- a/tailbone/templates/reports/problems/view.mako
+++ b/tailbone/templates/reports/problems/view.mako
@@ -45,11 +45,10 @@
             <b-button @click="runReportShowDialog = false">
               Cancel
             </b-button>
-            ${h.form(master.get_action_url('execute', instance))}
+            ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})}
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       native-type="submit"
-                      @click="runReportSubmitting = true"
                       :disabled="runReportSubmitting"
                       icon-pack="fas"
                       icon-left="arrow-circle-right">
@@ -62,12 +61,12 @@
   % 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 weekdays_data is not Undefined:
-        ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
+        ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
     % endif
 
     ThisPageData.runReportShowDialog = false
@@ -75,6 +74,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
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 c5a5b78d..f5588695 100644
--- a/tailbone/templates/roles/view.mako
+++ b/tailbone/templates/roles/view.mako
@@ -6,36 +6,16 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="page_content()">
-  ${parent.page_content()}
-  % if not use_buefy:
-  <h2>Users</h2>
-
-  % 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
-  % 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 users_data is not Undefined:
-        ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n}
+        ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n}
     % endif
 
     ThisPage.methods.detachPerson = function(url) {
-        ## TODO: this should require POST, but we will add that once
-        ## we can assume a Buefy theme is present, to avoid having to
-        ## implement the logic in old jquery...
+        ## 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
         }
@@ -43,5 +23,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako
index 13bceb3e..f9c815c2 100644
--- a/tailbone/templates/settings/email/configure.mako
+++ b/tailbone/templates/settings/email/configure.mako
@@ -3,6 +3,24 @@
 
 <%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;">
 
@@ -26,30 +44,72 @@
 
   </div>
 
-  % if request.has_perm('errors.bogus'):
-      <h3 class="block is-size-3">Testing</h3>
-      <div class="block" style="padding-left: 2rem;">
+  <h3 class="block is-size-3">Testing</h3>
+  <div class="block" style="padding-left: 2rem;">
 
-        <b-field grouped>
-          <p class="control">
-            You can raise a "bogus" error to test if/how it generates email:
-          </p>
+    <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">
+                    :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>
-        </b-field>
-
+        </div>
       </div>
-      </h3>
-  % endif
+    </div>
+
+  </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  % if request.has_perm('errors.bogus'):
-      <script type="text/javascript">
+<%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
 
@@ -74,9 +134,6 @@
             })
         }
 
-      </script>
-  % endif
+    % endif
+  </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako
index 11881285..ab8d6fa4 100644
--- a/tailbone/templates/settings/email/index.mako
+++ b/tailbone/templates/settings/email/index.mako
@@ -15,10 +15,10 @@
   ${parent.render_grid_component()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('configure'):
-      <script type="text/javascript">
+      <script>
 
         ThisPageData.showEmails = 'available'
 
@@ -26,9 +26,9 @@
             this.$refs.grid.showEmails = this.showEmails
         }
 
-        ${grid.component_studly}Data.showEmails = 'available'
+        ${grid.vue_component}Data.showEmails = 'available'
 
-        ${grid.component_studly}.computed.visibleData = function() {
+        ${grid.vue_component}.computed.visibleData = function() {
 
             if (this.showEmails == 'available') {
                 return this.data.filter(email => email.hidden == 'No')
@@ -41,23 +41,27 @@
             return this.data
         }
 
-        ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) {
+        ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) {
             return row.hidden == 'Yes' ? "Un-hide" : "Hide"
         }
 
-        ${grid.component_studly}.methods.toggleHidden = function(row) {
+        ${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,
+                hidden: row.hidden == 'No' ? true : false,
             }
             this.submitForm(url, params, response => {
-                row.hidden = params.hidden ? 'Yes' : 'No'
+                // 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>
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako
index be4f5774..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'})}
@@ -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,7 +80,7 @@
             return {
                 previewFormButtonText: "Send Preview Email",
                 previewFormSubmitting: false,
-                userEmailAddress: ${json.dumps(request.user.email_address)|n},
+                userEmailAddress: ${json.dumps(user_email_address)|n},
             }
         },
         methods: {
@@ -135,10 +96,13 @@
         }
     }
 
-    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/trainwreck/transactions/index.mako b/tailbone/templates/tables/index.mako
similarity index 55%
rename from tailbone/templates/trainwreck/transactions/index.mako
rename to tailbone/templates/tables/index.mako
index 31d956fc..b13f0785 100644
--- a/tailbone/templates/trainwreck/transactions/index.mako
+++ b/tailbone/templates/tables/index.mako
@@ -3,8 +3,8 @@
 
 <%def name="context_menu_items()">
   ${parent.context_menu_items()}
-  % if master.has_perm('rollover'):
-      <li>${h.link_to("Yearly Rollover", url('{}.rollover'.format(route_prefix)))}</li>
+  % if master.has_perm('migrations'):
+      <li>${h.link_to("View / Apply Migrations", url('{}.migrations'.format(route_prefix)))}</li>
   % endif
 </%def>
 
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/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/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 fe3ef429..1869043b 100644
--- a/tailbone/templates/themes/falafel/base.mako
+++ b/tailbone/templates/themes/falafel/base.mako
@@ -1,857 +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" />
-<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
-<!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>
-    ${declare_formposter_mixin()}
-
-    ${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__))}
-
-  ## 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__))}
-  ${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()">
-  ${h.javascript_link('https://unpkg.com/vue@{}/dist/vue.js'.format(vue_version))}
-
-  ## vue-resource
-  ## (needed for e.g. this.$http.get() calls, used by grid at least)
-  ## TODO: make this configurable also
-  ${h.javascript_link('https://cdn.jsdelivr.net/npm/vue-resource@1.5.1')}
-</%def>
-
-<%def name="buefy()">
-  ${h.javascript_link('https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(buefy_version))}
-</%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/falafel/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/falafel/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 {
-        min-width: ${filter_fieldname_width};
-        justify-content: left;
-    }
-    .filters .filter-verb {
-        min-width: ${filter_verb_width};
-    }
-  </style>
-</%def>
-
-<%def name="buefy_styles()">
-  % if buefy_css:
-      ## custom Buefy CSS
-      ${h.stylesheet_link(buefy_css)}
-  % else:
-      ## upstream Buefy CSS
-      ${h.stylesheet_link('https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(buefy_version))}
-  % endif
-</%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()}
-              <div id="global-header-title">
-                ${base_meta.global_title()}
-              </div>
-            </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 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 master.show_create_link 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 master.show_create_link 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)}
-                  <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">
-                        % if use_buefy:
-                            <b-input name="entry"
-                                     placeholder="${quickie.placeholder}"
-                                     autocomplete="off">
-                            </b-input>
-                        % else:
-                            ${h.text('entry', placeholder=quickie.placeholder, autocomplete='off')}
-                        % endif
-                      </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">
-                      </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)}
-                  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">
-                  <b-button tag="a" href="${help_url}"
-                            target="_blank"
-                            icon-pack="fas"
-                            icon-left="fas fa-question-circle">
-                    Help
-                  </b-button>
-                </div>
-            % endif
-
-            ## 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="hero is-primary">
-            <div class="level">
-              <div class="level-left">
-                <div class="level-item">
-                  <h1 class="title" v-html="contentTitleHTML"></h1>
-                </div>
-              </div>
-              <div class="level-right">
-                ${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>
-
-  <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 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>
-
-  ${tailbone_autocomplete_template()}
-</%def>
-
-<%def name="render_this_page_component()">
-  <this-page
-    v-on:change-content-title="changeContentTitle">
-  </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.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("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_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'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('edit', instance)}"
-                             icon-left="edit"
-                             text="Edit This">
-                </once-button>
-              </div>
-          % endif
-          % if master.cloneable and master.has_perm('clone'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('clone', instance)}"
-                             icon-left="object-ungroup"
-                             text="Clone This">
-                </once-button>
-              </div>
-          % endif
-          % if master.deletable and instance_deletable and master.has_perm('delete'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('delete', instance)}"
-                             type="is-danger"
-                             icon-left="trash"
-                             text="Delete This">
-                </once-button>
-              </div>
-          % endif
-      % else:
-          ## viewing row
-          % if instance_deletable and master.has_perm('delete_row'):
-              <div class="level-item">
-                <once-button tag="a" href="${action_url('delete', instance)}"
-                             type="is-danger"
-                             icon-left="trash"
-                             text="Delete This">
-                </once-button>
-              </div>
-          % endif
-      % endif
-  % elif master and master.editing:
-      % if master.viewable and master.has_perm('view'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('view', instance)}"
-                         icon-left="eye"
-                         text="View This">
-            </once-button>
-          </div>
-      % endif
-      % if master.deletable and instance_deletable and master.has_perm('delete'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('delete', instance)}"
-                         type="is-danger"
-                         icon-left="trash"
-                         text="Delete This">
-            </once-button>
-          </div>
-      % endif
-  % elif master and master.deleting:
-      % if master.viewable and master.has_perm('view'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('view', instance)}"
-                         icon-left="eye"
-                         text="View This">
-            </once-button>
-          </div>
-      % endif
-      % if master.editable and instance_editable and master.has_perm('edit'):
-          <div class="level-item">
-            <once-button tag="a" href="${action_url('edit', instance)}"
-                         icon-left="edit"
-                         text="Edit This">
-            </once-button>
-          </div>
-      % endif
-  % endif
-</%def>
-
-<%def name="render_prevnext_header_buttons()">
-  % if show_prev_next is not Undefined and show_prev_next:
-      % if prev_url:
-          <div class="level-item">
-            % if use_buefy:
-                <b-button tag="a" href="${prev_url}"
-                          icon-pack="fas"
-                          icon-left="arrow-left">
-                  Older
-                </b-button>
-            % else:
-                ${h.link_to(u"« Older", prev_url, class_='button autodisable')}
-            % endif
-          </div>
-      % else:
-          <div class="level-item">
-            % if use_buefy:
-                <b-button tag="a" href="#"
-                          disabled
-                          icon-pack="fas"
-                          icon-left="arrow-left">
-                  Older
-                </b-button>
-            % else:
-                ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')}
-            % endif
-          </div>
-      % endif
-      % if next_url:
-          <div class="level-item">
-            % if use_buefy:
-                <b-button tag="a" href="${next_url}"
-                          icon-pack="fas"
-                          icon-left="arrow-right">
-                  Newer
-                </b-button>
-            % else:
-                ${h.link_to(u"Newer »", next_url, class_='button autodisable')}
-            % endif
-          </div>
-      % else:
-          <div class="level-item">
-            % if use_buefy:
-                <b-button tag="a" href="#"
-                          disabled
-                          icon-pack="fas"
-                          icon-left="arrow-right">
-                  Newer
-                </b-button>
-            % else:
-                ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')}
-            % endif
-          </div>
-      % endif
-  % endif
-</%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',
-        mixins: [FormPosterMixin],
-        computed: {},
-        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
-
-            toggleNestedMenu(hash) {
-                const key = 'menu_' + hash + '_shown'
-                this[key] = !this[key]
-            },
-        },
-    }
-
-    let WholePageData = {
-        contentTitleHTML: ${json.dumps(capture(self.content_title))|n},
-        feedbackMessage: "",
-    }
-
-    ## 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="modify_whole_page_vars()">
-  <script type="text/javascript">
-
-    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
index 7cf03165..10c57e18 100644
--- a/tailbone/templates/trainwreck/transactions/configure.mako
+++ b/tailbone/templates/trainwreck/transactions/configure.mako
@@ -3,13 +3,57 @@
 
 <%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;">
-    % for key, engine in six.iteritems(trainwreck_engines):
+    <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>
@@ -18,14 +62,9 @@
   </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>
     ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako
index 6d6e0b17..f26515b5 100644
--- a/tailbone/templates/trainwreck/transactions/rollover.mako
+++ b/tailbone/templates/trainwreck/transactions/rollover.mako
@@ -8,7 +8,7 @@
 <%def name="page_content()">
   <br />
 
-  % if six.text_type(next_year) not in trainwreck_engines:
+  % 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.
@@ -20,38 +20,37 @@
   </p>
 
   <b-table :data="engines">
-    <template slot-scope="props">
-      <b-table-column field="key" label="DB Key">
-        {{ props.row.key }}
-      </b-table-column>
-      <b-table-column field="oldest_date" label="Oldest Date">
-        <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">
-        <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>
-    </template>
+    <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_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.engines = ${json.dumps(engines_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako
index 2be51c7d..630950cf 100644
--- a/tailbone/templates/trainwreck/transactions/view.mako
+++ b/tailbone/templates/trainwreck/transactions/view.mako
@@ -1,15 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%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 custorder_xref_markers_data is not Undefined:
-        ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
+        ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
     % endif
-
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako
index 9abcb8ba..2507492e 100644
--- a/tailbone/templates/trainwreck/transactions/view_row.mako
+++ b/tailbone/templates/trainwreck/transactions/view_row.mako
@@ -1,16 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view_row.mako" />
 
-<%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 discounts_data is not Undefined:
-        ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n}
+        ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako
index fb3a3219..4815fc79 100644
--- a/tailbone/templates/units-of-measure/index.mako
+++ b/tailbone/templates/units-of-measure/index.mako
@@ -7,7 +7,7 @@
   % if master.has_perm('collect_wild_uoms'):
   <b-button type="is-primary"
             icon-pack="fas"
-            icon-left="fas fa-shopping-basket"
+            icon-left="shopping-basket"
             @click="showingCollectWildDialog = true">
     Collect from the Wild
   </b-button>
@@ -51,20 +51,17 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('collect_wild_uoms'):
-  <script type="text/javascript">
+      <script>
 
-    TailboneGridData.showingCollectWildDialog = false
+        ${grid.vue_component}Data.showingCollectWildDialog = false
 
-    TailboneGrid.methods.collectFromWild = function() {
-        this.$refs['collect-wild-uoms-form'].submit()
-    }
+        ${grid.vue_component}.methods.collectFromWild = function() {
+            this.$refs['collect-wild-uoms-form'].submit()
+        }
 
-  </script>
+      </script>
   % endif
 </%def>
-
-
-${parent.body()}
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
index cde81b9e..9439f830 100644
--- a/tailbone/templates/upgrades/configure.mako
+++ b/tailbone/templates/upgrades/configure.mako
@@ -7,41 +7,51 @@
   <h3 class="is-size-3">Upgradable Systems</h3>
   <div class="block" style="padding-left: 2rem; display: flex;">
 
-    <b-table :data="upgradeSystems"
+    <${b}-table :data="upgradeSystems"
              sortable>
-      <template slot-scope="props">
-        <b-table-column field="key"
-                        label="Key"
-                        sortable>
-          {{ props.row.key }}
-        </b-table-column>
-        <b-table-column field="label"
-                        label="Label"
-                        sortable>
-          {{ props.row.label }}
-        </b-table-column>
-        <b-table-column field="command"
-                        label="Command"
-                        sortable>
-          {{ props.row.command }}
-        </b-table-column>
-        <b-table-column label="Actions">
-          <a href="#"
-             @click.prevent="upgradeSystemEdit(props.row)">
-            <i class="fas fa-edit"></i>
-            Edit
-          </a>
-          &nbsp;
-          <a href="#"
-             v-if="props.row.key != 'rattail'"
-             class="has-text-danger"
-             @click.prevent="updateSystemDelete(props.row)">
-            <i class="fas fa-trash"></i>
-            Delete
-          </a>
-        </b-table-column>
-      </template>
-    </b-table>
+      <${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"
@@ -64,21 +74,21 @@
                      :type="upgradeSystemKey ? null : 'is-danger'">
               <b-input v-model.trim="upgradeSystemKey"
                        ref="upgradeSystemKey"
-                       :disabled="upgradeSystemKey == 'rattail'">
-              </b-input>
+                       :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'">
-              </b-input>
+                       :disabled="upgradeSystemKey == 'rattail'"
+                       expanded />
             </b-field>
             <b-field label="Command"
                      :type="upgradeSystemCommand ? null : 'is-danger'">
               <b-input v-model.trim="upgradeSystemCommand"
-                       ref="upgradeSystemCommand">
-              </b-input>
+                       ref="upgradeSystemCommand"
+                       expanded />
             </b-field>
           </section>
 
@@ -101,9 +111,9 @@
   </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>
 
     ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n}
     ThisPageData.upgradeSystemShowDialog = false
@@ -151,6 +161,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako
index c6ae11f2..c3fca81d 100644
--- a/tailbone/templates/upgrades/view.mako
+++ b/tailbone/templates/upgrades/view.mako
@@ -1,43 +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 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');
-        }
-    }
-
-    $(function() {
-
-        show_packages('diffs');
-
-        $('.showing .all').click(function() {
-            show_packages('all');
-            return false;
-        });
-
-        $('.showing .diffs').click(function() {
-            show_packages('diffs')
-            return false;
-        });
-
-    });
-
-  </script>
-  % endif
-</%def>
-
 <%def name="extra_styles()">
   ${parent.extra_styles()}
   % if master.has_perm('execute'):
@@ -56,9 +19,15 @@
   ${parent.render_this_page()}
 
   % if expose_websockets and master.has_perm('execute'):
-      <b-modal :active.sync="upgradeExecuting"
-               full-screen
-               :can-cancel="false">
+      <${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">
 
@@ -66,9 +35,13 @@
               <div class="level-item has-text-centered"
                    style="display: flex; flex-direction: column;">
                 <p class="block">
-                  Upgrading (please wait) ...
+                  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"
@@ -76,6 +49,7 @@
     ##                             format="percent"
                             >
                 </b-progress>
+                % endif
               </div>
               <div class="level-right">
                 <div class="level-item">
@@ -102,7 +76,7 @@
 
           </div>
         </div>
-      </b-modal>
+      </${b}-modal>
   % endif
 
   % if master.has_perm('execute'):
@@ -112,16 +86,18 @@
   % endif
 </%def>
 
-<%def name="render_buefy_form()">
+<%def name="render_form()">
   <div class="form">
     <${form.component}
-      % if expose_websockets and master.has_perm('execute'):
+      % 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"
-      @declare-failure-click="declareFailureClick"
-      :declare-failure-submitting="declareFailureSubmitting"
+      % endif
       % endif
       >
     </${form.component}>
@@ -132,7 +108,7 @@
   % if instance_executable and master.has_perm('execute'):
       <div class="buttons">
         % if instance.enabled and not instance.executing:
-            % if use_buefy and expose_websockets:
+            % if expose_websockets:
                 <b-button type="is-primary"
                           icon-pack="fas"
                           icon-left="arrow-circle-right"
@@ -140,7 +116,7 @@
                           @click="$emit('execute-upgrade-click')">
                   {{ upgradeExecuting ? "Working, please wait..." : "Execute this upgrade" }}
                 </b-button>
-            % elif use_buefy:
+            % else:
                 ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), **{'@submit': 'submitForm'})}
                 ${h.csrf_token(request)}
                 <b-button type="is-primary"
@@ -151,11 +127,6 @@
                   {{ formSubmitting ? "Working, please wait..." : "Execute this upgrade" }}
                 </b-button>
                 ${h.end_form()}
-            % else:
-                ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')}
-                ${h.csrf_token(request)}
-                ${h.submit('execute', "Execute this upgrade", class_='button is-primary')}
-                ${h.end_form()}
             % endif
         % elif instance.enabled:
             <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button>
@@ -166,11 +137,11 @@
   % 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'
 
     % if master.has_perm('execute'):
 
@@ -182,7 +153,7 @@
             // execute upgrade
             //////////////////////////////
 
-            TailboneForm.props.upgradeExecuting = {
+            ${form.vue_component}.props.upgradeExecuting = {
                 type: Boolean,
                 default: false,
             }
@@ -202,6 +173,7 @@
 
             ThisPage.methods.showExecuteDialog = function() {
                 this.upgradeExecuting = true
+                document.title = "Upgrading ${system_title} ..."
                 this.$nextTick(() => {
                     this.adjustTextoutHeight()
                 })
@@ -210,8 +182,8 @@
             ThisPage.methods.establishWebsocket = function() {
 
                 ## TODO: should be a cleaner way to get this url?
-                url = '${request.route_url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}'
-                url = url.replace(/^https?:/, 'wss:')
+                let url = '${url('ws.upgrades.execution_progress', _query={'uuid': instance.uuid})}'
+                url = url.replace(/^http(s?):/, 'ws$1:')
 
                 this.ws = new WebSocket(url)
 
@@ -281,9 +253,9 @@
             // execute upgrade
             //////////////////////////////
 
-            TailboneFormData.formSubmitting = false
+            ${form.vue_component}Data.formSubmitting = false
 
-            TailboneForm.methods.submitForm = function() {
+            ${form.vue_component}.methods.submitForm = function() {
                 this.formSubmitting = true
             }
 
@@ -293,12 +265,12 @@
         // declare failure
         //////////////////////////////
 
-        TailboneForm.props.declareFailureSubmitting = {
+        ${form.vue_component}.props.declareFailureSubmitting = {
             type: Boolean,
             default: false,
         }
 
-        TailboneForm.methods.declareFailureClick = function() {
+        ${form.vue_component}.methods.declareFailureClick = function() {
             this.$emit('declare-failure-click')
         }
 
@@ -315,6 +287,3 @@
 
   </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
index a44534dc..ecfdd1c7 100644
--- a/tailbone/templates/users/preferences.mako
+++ b/tailbone/templates/users/preferences.mako
@@ -27,10 +27,10 @@
   <div class="block" style="padding-left: 2rem;">
 
     <b-field label="Theme Style">
-        <b-select name="tailbone.${user.uuid}.buefy_css"
-                  v-model="simpleSettings['tailbone.${user.uuid}.buefy_css']"
+        <b-select name="tailbone.${user.uuid}.user_css"
+                  v-model="simpleSettings['tailbone.${user.uuid}.user_css']"
                   @input="settingsNeedSaved = true">
-          <option v-for="option in buefyCSSOptions"
+          <option v-for="option in themeStyleOptions"
                   :key="option.value"
                   :value="option.value">
             {{ option.label }}
@@ -42,14 +42,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ThisPageData.buefyCSSOptions = ${json.dumps(buefy_css_options)|n}
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n}
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako
index b34902a1..d1afd218 100644
--- a/tailbone/templates/users/view.mako
+++ b/tailbone/templates/users/view.mako
@@ -14,12 +14,123 @@
   % endif
 </%def>
 
-<%def name="context_menu_items()">
-  ${parent.context_menu_items()}
-  % if master.has_perm('preferences'):
-      <li>${h.link_to("Edit User Preferences", action_url('preferences', instance))}</li>
+<%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>
 
-${parent.body()}
+      ${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 2d4653aa..19a1b89d 100644
--- a/tailbone/templates/util.mako
+++ b/tailbone/templates/util.mako
@@ -2,43 +2,27 @@
 
 <%def name="view_profile_button(person)">
   <div class="buttons">
-    % if use_buefy:
-        <b-button type="is-primary"
-                  tag="a" href="${url('people.view_profile', uuid=person.uuid)}"
-                  icon-pack="fas"
-                  icon-left="user">
-          ${person}
-        </b-button>
-    % else:
-        ${h.link_to(person, url('people.view_profile', uuid=person.uuid), class_='button is-primary')}
-    % endif
+    <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'):
-      % if use_buefy:
-          <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>
-          </nav>
-      % else:
-          <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
-            </div>
+      <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>
-      % endif
+        </div>
+      </nav>
   % endif
 </%def>
diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako
index 79dad455..6b135346 100644
--- a/tailbone/templates/vendors/configure.mako
+++ b/tailbone/templates/vendors/configure.mako
@@ -44,14 +44,9 @@
   </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>
     ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
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
index e631c141..432e011d 100644
--- a/tailbone/templates/workorders/view.mako
+++ b/tailbone/templates/workorders/view.mako
@@ -24,7 +24,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="receive()"
                       :disabled="receiveButtonDisabled">
               {{ receiveButtonText }}
@@ -41,7 +41,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="awaitEstimate()"
                       :disabled="awaitEstimateButtonDisabled">
               {{ awaitEstimateButtonText }}
@@ -58,7 +58,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="awaitParts()"
                       :disabled="awaitPartsButtonDisabled">
               {{ awaitPartsButtonText }}
@@ -75,7 +75,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="workOnIt()"
                       :disabled="workOnItButtonDisabled">
               {{ workOnItButtonText }}
@@ -92,7 +92,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="release()"
                       :disabled="releaseButtonDisabled">
               {{ releaseButtonText }}
@@ -109,7 +109,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       icon-pack="fas"
-                      icon-left="fas fa-arrow-right"
+                      icon-left="arrow-right"
                       @click="deliver()"
                       :disabled="deliverButtonDisabled">
               {{ deliverButtonText }}
@@ -132,7 +132,7 @@
             ${h.csrf_token(request)}
             <b-button type="is-warning"
                       icon-pack="fas"
-                      icon-left="fas fa-ban"
+                      icon-left="ban"
                       @click="confirmCancel()"
                       :disabled="cancelButtonDisabled">
               {{ cancelButtonText }}
@@ -145,9 +145,9 @@
   </nav>
 </%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.receiveButtonDisabled = false
     ThisPageData.receiveButtonText = "I've received the order from customer"
@@ -216,6 +216,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
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 5dee997f..71aa35e3 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,16 +24,14 @@
 Utilities
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import datetime
-
-import six
-import pytz
-import humanize
+import importlib
 import logging
+import warnings
+
+import humanize
+import markdown
 
-from rattail.time import timezone, make_utc
 from rattail.files import resource_path
 
 import colander
@@ -41,62 +39,97 @@ 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 get_form_data(request):
     """
-    Returns the effective form data for the given request.  Mostly
-    this is a convenience, to return either POST or JSON depending on
-    the type of request.
+    DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()`
+    instead.
     """
-    # nb. we prefer JSON only if no POST is present
-    # TODO: this seems to work for our use case at least, but perhaps
-    # there is a better way?  see also
-    # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
-    if request.is_xhr and not request.POST:
-        return request.json_body
-    return request.POST
+    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)
 
 
-def should_use_buefy(request):
+def get_global_search_options(request):
     """
-    Returns a flag indicating whether or not the current theme supports (and
-    therefore should use) the Buefy JS library.
+    Returns global search options for current request.  Basically a
+    list of all "index views" minus the ones they aren't allowed to
+    access.
     """
-    # 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
+    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
 
-    # 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_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):
@@ -112,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',
@@ -147,13 +182,13 @@ def raw_datetime(config, value, verbose=False, as_date=False):
     # 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 = {}
@@ -165,7 +200,7 @@ 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)
 
     time_diff = app.render_time_ago(time_ago, fallback=None)
     if time_diff is not None:
@@ -181,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
@@ -226,6 +273,41 @@ 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
@@ -243,15 +325,25 @@ def get_effective_theme(rattail_config, theme=None, session=None):
             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
@@ -291,7 +383,7 @@ def include_configured_views(pyramid_config):
     """
     rattail_config = pyramid_config.registry.settings.get('rattail_config')
     app = rattail_config.get_app()
-    model = rattail_config.get_model()
+    model = app.model
     session = app.make_session()
 
     # fetch all include-related settings at once
diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py
index 6b6ebc19..29c73b61 100644
--- a/tailbone/views/__init__.py
+++ b/tailbone/views/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2022 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,7 +27,7 @@ Pyramid Views
 from __future__ import unicode_literals, absolute_import
 
 from .core import View
-from .master import MasterView
+from .master import MasterView, ViewSupplement
 
 
 def includeme(config):
diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py
index d0c12d9c..33888654 100644
--- a/tailbone/views/asgi/__init__.py
+++ b/tailbone/views/asgi/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -41,12 +41,13 @@ class MockRequest(dict):
         pass
 
 
-class WebsocketView(object):
+class WebsocketView:
 
     def __init__(self, pyramid_config):
         self.pyramid_config = pyramid_config
         self.registry = self.pyramid_config.registry
-        self.model = self.rattail_config.get_model()
+        app = self.get_rattail_app()
+        self.model = app.model
 
     @property
     def rattail_config(self):
@@ -89,7 +90,7 @@ class WebsocketView(object):
                                        session=session) as s:
 
                     # load user proper
-                    return s.query(model.User).get(user_uuid)
+                    return s.get(model.User, user_uuid)
 
     def get_user_session(self, scope):
         settings = self.registry.settings
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index e7922e3d..eceab803 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
@@ -48,28 +44,6 @@ class UserLogin(colander.MappingSchema):
                                    widget=dfwidget.PasswordWidget())
 
 
-@colander.deferred
-def current_password_correct(node, kw):
-    request = kw['request']
-    app = request.rattail_config.get_app()
-    auth = app.get_auth_handler()
-    user = kw['user']
-    def validate(node, value):
-        if not auth.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):
@@ -94,6 +68,7 @@ class AuthenticationView(View):
         """
         The login view, responsible for displaying and handling the login form.
         """
+        app = self.get_rattail_app()
         referrer = self.request.get_referrer(default=self.request.route_url('home'))
 
         # redirect if already logged in
@@ -101,15 +76,12 @@ class AuthenticationView(View):
             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:
@@ -122,20 +94,21 @@ 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'}
 
-        context = {
+        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),
         }
-        if use_buefy:
-            context['index_title'] = self.rattail_config.node_title()
-        return context
 
     def authenticate_user(self, username, password):
         app = self.get_rattail_app()
@@ -173,19 +146,40 @@ class AuthenticationView(View):
         if not self.request.user:
             return self.redirect(self.request.route_url('home'))
 
-        if 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))
+        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())
 
-        use_buefy = self.get_use_buefy()
-        schema = ChangePassword().bind(user=self.request.user, request=self.request)
-        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'])
+        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):
         """
@@ -233,6 +227,9 @@ 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')
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 6dc2436d..c162b579 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,31 +32,25 @@ 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 prettify, simple_error
-from rattail.progress import SocketProgress
+from rattail.util import simple_error
 
 import colander
-import deform
 from deform import widget as dfwidget
-from pyramid.renderers import render_to_response
-from pyramid.response import FileResponse
 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__)
@@ -72,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
@@ -103,10 +97,10 @@ class BatchMasterView(MasterView):
         'description',
         'notes',
         'params',
-        'created',
-        'created_by',
         'rowcount',
         'status_code',
+        'created',
+        'created_by',
         'executed',
         'executed_by',
     ]
@@ -118,7 +112,7 @@ class BatchMasterView(MasterView):
     }
 
     def __init__(self, request):
-        super(BatchMasterView, self).__init__(request)
+        super().__init__(request)
         self.batch_handler = self.get_handler()
         # TODO: deprecate / remove this (?)
         self.handler = self.batch_handler
@@ -170,7 +164,7 @@ class BatchMasterView(MasterView):
         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
@@ -192,33 +186,27 @@ class BatchMasterView(MasterView):
 
         breakdown = self.make_status_breakdown(batch)
 
-        if use_buefy:
-            factory = self.get_grid_factory()
-            g = factory('batch_row_status_breakdown', [],
-                        columns=['title', 'count'])
-            g.set_click_handler('title', "autoFilterStatus(props.row)")
-            kwargs['status_breakdown_data'] = breakdown
-            kwargs['status_breakdown_grid'] = HTML.literal(
-                g.render_buefy_table_element(data_prop='statusBreakdownData',
-                                             empty_labels=True))
-
-        else:
-            kwargs['status_breakdown'] = [
-                (status['title'], status['count'])
-                for status in breakdown]
+        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)
-        use_buefy = self.get_use_buefy()
         form = forms.Form(schema=UploadWorksheet(),
                           request=self.request,
                           action_url=action_url,
-                          use_buefy=use_buefy,
                           component='upload-worksheet-form')
         form.set_type('worksheet_file', 'file')
-        # TODO: must set these to avoid some default Buefy code
+        # TODO: must set these to avoid some default code
         form.auto_disable = False
         form.auto_disable_save = False
         return form
@@ -246,7 +234,7 @@ class BatchMasterView(MasterView):
         Thread target for updating a batch from worksheet.
         """
         session = self.make_isolated_session()
-        batch = session.query(self.model_class).get(batch_uuid)
+        batch = session.get(self.model_class, batch_uuid)
         try:
             self.handler.update_from_worksheet(batch, path, progress=progress)
 
@@ -299,7 +287,8 @@ class BatchMasterView(MasterView):
         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)
@@ -348,7 +337,7 @@ class BatchMasterView(MasterView):
         return batch.id_str
 
     def configure_form(self, f):
-        super(BatchMasterView, self).configure_form(f)
+        super().configure_form(f)
 
         # id
         f.set_readonly('id')
@@ -360,6 +349,7 @@ class BatchMasterView(MasterView):
             f.remove('params')
         else:
             f.set_readonly('params')
+            f.set_renderer('params', self.render_params)
 
         # created
         f.set_readonly('created')
@@ -394,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
@@ -417,23 +407,20 @@ 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:
@@ -443,44 +430,34 @@ class BatchMasterView(MasterView):
             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)
 
@@ -488,7 +465,7 @@ 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)
 
@@ -510,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?
@@ -524,11 +501,21 @@ 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'])
 
+        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)
+
     def get_batch_kwargs(self, batch, **kwargs):
         """
         Return a kwargs dict for use with ``self.handler.make_batch()``, using
@@ -579,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:
@@ -616,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')
@@ -644,7 +631,7 @@ class BatchMasterView(MasterView):
         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
@@ -657,7 +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()
+        return super().create_row()
 
     def save_create_row_form(self, form):
         batch = self.get_instance()
@@ -670,7 +657,7 @@ 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')
@@ -693,16 +680,13 @@ class BatchMasterView(MasterView):
         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.get_use_buefy():
-            return
-        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):
         """
@@ -712,32 +696,30 @@ 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 '')
@@ -774,7 +756,7 @@ class BatchMasterView(MasterView):
         # nb. must make new session, separate from main thread
         session = app.make_session()
         batch = self.get_instance_for_key(key, session)
-        batch_str = six.text_type(batch)
+        batch_str = str(batch)
 
         try:
             # try to delete batch
@@ -850,7 +832,6 @@ class BatchMasterView(MasterView):
         """
         defaults = {}
         route_prefix = self.get_route_prefix()
-        use_buefy = self.get_use_buefy()
 
         schema = None
         if self.has_execution_options(batch):
@@ -871,16 +852,23 @@ class BatchMasterView(MasterView):
                         labels = kwargs.setdefault('labels', {})
                         labels[field.name] = field.title
 
-                    # auto-convert select widgets for buefy theme
-                    if use_buefy and isinstance(field.widget, forms.widgets.PlainSelectWidget):
+                    # 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'] = 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'):
@@ -946,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])
@@ -963,12 +951,15 @@ class BatchMasterView(MasterView):
         # run command in subprocess
         log.debug("launching command in subprocess: %s", cmd)
         try:
-            subprocess.check_output(cmd, stderr=subprocess.PIPE)
+            # 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)
-            log.warning(error.stderr.decode('utf_8'))
-            raise
+            output = error.output.decode('utf_8')
+            log.warning(output)
+            raise Exception(output)
 
     def action_subprocess_thread(self, key, port, username, handler_action, progress, **kwargs):
         """
@@ -981,6 +972,10 @@ class BatchMasterView(MasterView):
         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,
@@ -999,7 +994,7 @@ 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)
@@ -1009,8 +1004,8 @@ class BatchMasterView(MasterView):
                 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)
+                    "{} of '{}' batch failed: {} (see logs for more info)").format(
+                        handler_action, self.handler.batch_key, error)
                 progress.session.save()
 
             return
@@ -1024,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,
@@ -1057,16 +1052,19 @@ 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.exception("population failed for batch %s: %s", batch.uuid, batch)
+            log.warning("population failed for batch %s: %s", batch.uuid, batch,
+                        exc_info=True)
             session.close()
             if progress:
                 progress.session.load()
@@ -1115,9 +1113,11 @@ 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()
@@ -1168,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)
 
@@ -1243,9 +1245,16 @@ class BatchMasterView(MasterView):
             return False
 
         batch = self.get_parent(row)
-        if batch.complete or batch.executed:
+
+        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):
@@ -1265,7 +1274,7 @@ class BatchMasterView(MasterView):
         self.handler.do_remove_row(row)
 
     def delete_row_objects(self, rows):
-        deleted = super(BatchMasterView, self).delete_row_objects(rows)
+        deleted = super().delete_row_objects(rows)
         batch = self.get_instance()
 
         # decrement rowcount for batch
@@ -1286,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
@@ -1308,9 +1317,11 @@ class BatchMasterView(MasterView):
         # 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()
+        app = self.get_rattail_app()
+        model = self.model
+        session = app.make_session()
         batch = self.get_instance_for_key(key, session)
-        user = session.query(model.User).get(user_uuid)
+        user = session.get(model.User, user_uuid)
         try:
             result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs)
 
@@ -1357,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
@@ -1383,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)
 
@@ -1423,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
@@ -1502,6 +1515,12 @@ class BatchMasterView(MasterView):
             config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix),
                                            "Refresh data for {}".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),
@@ -1546,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
diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py
index d4f15ffd..486d8774 100644
--- a/tailbone/views/batch/handheld.py
+++ b/tailbone/views/batch/handheld.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,14 @@
 Views for handheld 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 HandheldBatch, HandheldBatchRow
 
 import colander
+from deform import widget as dfwidget
 from webhelpers2.html import tags
 
-from tailbone import forms
 from tailbone.views.batch import FileBatchMasterView
 
 
@@ -47,14 +46,14 @@ 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 HandheldBatchView(FileBatchMasterView):
     """
     Master view for handheld batches.
     """
-    model_class = model.HandheldBatch
+    model_class = HandheldBatch
     default_handler_spec = 'rattail.batch.handheld:HandheldBatchHandler'
     model_title_plural = "Handheld Batches"
     route_prefix = 'batch.handheld'
@@ -62,7 +61,7 @@ class HandheldBatchView(FileBatchMasterView):
     execution_options_schema = ExecutionOptions
     editable = False
 
-    model_row_class = model.HandheldBatchRow
+    model_row_class = HandheldBatchRow
     rows_creatable = False
     rows_editable = True
 
@@ -117,7 +116,7 @@ class HandheldBatchView(FileBatchMasterView):
     ]
 
     def configure_grid(self, g):
-        super(HandheldBatchView, self).configure_grid(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)
@@ -127,7 +126,7 @@ class HandheldBatchView(FileBatchMasterView):
             return 'notice'
 
     def configure_form(self, f):
-        super(HandheldBatchView, self).configure_form(f)
+        super().configure_form(f)
         batch = f.model_instance
 
         # device_type
@@ -157,13 +156,13 @@ class HandheldBatchView(FileBatchMasterView):
         return tags.link_to(text, url)
 
     def get_batch_kwargs(self, batch):
-        kwargs = super(HandheldBatchView, self).get_batch_kwargs(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(HandheldBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_type('cases', 'quantity')
         g.set_type('units', 'quantity')
         g.set_label('brand_name', "Brand")
@@ -173,7 +172,7 @@ class HandheldBatchView(FileBatchMasterView):
             return 'warning'
 
     def configure_row_form(self, f):
-        super(HandheldBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # readonly fields
         f.set_readonly('upc')
@@ -189,7 +188,7 @@ class HandheldBatchView(FileBatchMasterView):
             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)
+        return super().get_execute_success_url(batch)
 
     def get_execute_results_success_url(self, result, **kwargs):
         if result is True:
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 f8699725..e9f72ceb 100644
--- a/tailbone/views/batch/inventory.py
+++ b/tailbone/views/batch/inventory.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,19 +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.gpc import GPC
-from rattail.util import pretty_quantity, OrderedDict
+from rattail.util import pretty_quantity
 
 import colander
 from deform import widget as dfwidget
@@ -218,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
@@ -231,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', {
@@ -338,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)
 
@@ -357,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
@@ -513,7 +522,7 @@ class InventoryBatchView(BatchMasterView):
 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 79b14a76..7291b05e 100644
--- a/tailbone/views/batch/labels.py
+++ b/tailbone/views/batch/labels.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
@@ -123,7 +119,7 @@ class LabelBatchView(BatchMasterView):
     ]
 
     def configure_form(self, f):
-        super(LabelBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # handheld_batches
         if self.creating:
@@ -142,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:
@@ -159,7 +155,7 @@ class LabelBatchView(BatchMasterView):
         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")
@@ -171,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')
@@ -219,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 03ca638b..bd46ad52 100644
--- a/tailbone/views/batch/newproduct.py
+++ b/tailbone/views/batch/newproduct.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
 
 
@@ -49,11 +49,17 @@ class NewProductBatchView(BatchMasterView):
     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',
@@ -129,7 +135,7 @@ class NewProductBatchView(BatchMasterView):
         ]
 
     def configure_form(self, f):
-        super(NewProductBatchView, self).configure_form(f)
+        super().configure_form(f)
 
         # input_filename
         if self.creating:
@@ -138,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)
 
@@ -165,7 +199,7 @@ class NewProductBatchView(BatchMasterView):
             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')
@@ -177,6 +211,7 @@ 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)
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 6ba28889..5b5d013b 100644
--- a/tailbone/views/batch/pricing.py
+++ b/tailbone/views/batch/pricing.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
 
@@ -155,7 +151,7 @@ class PricingBatchView(BatchMasterView):
         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
 
@@ -192,7 +188,7 @@ class PricingBatchView(BatchMasterView):
                 f.set_required('input_filename', False)
 
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(PricingBatchView, self).get_batch_kwargs(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
@@ -213,7 +209,7 @@ 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)
@@ -241,13 +237,13 @@ class PricingBatchView(BatchMasterView):
         if row.subdepartment_number:
             if row.subdepartment_name:
                 return HTML.tag('span', title=row.subdepartment_name,
-                                c=six.text_type(row.subdepartment_number))
+                                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:
@@ -295,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')
@@ -328,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')
@@ -344,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:
@@ -358,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:
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 ba4d3482..ec8da979 100644
--- a/tailbone/views/batch/vendorcatalog.py
+++ b/tailbone/views/batch/vendorcatalog.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,8 @@
 Views for maintaining vendor catalogs
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import logging
 
-import six
-
 from rattail.db import model
 
 import colander
@@ -196,9 +192,6 @@ class VendorCatalogView(FileBatchMasterView):
             values = [(p.key, p.display) for p in parsers]
             if len(values) == 1:
                 f.set_default('parser_key', parsers[0].key)
-            use_buefy = self.get_use_buefy()
-            if not use_buefy:
-                values.insert(0, ('', "(please choose)"))
             f.set_widget('parser_key', dfwidget.SelectWidget(values=values))
         else:
             f.set_readonly('parser_key')
@@ -209,6 +202,8 @@ class VendorCatalogView(FileBatchMasterView):
         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)
 
             # should we use dropdown or autocomplete?  note that if
             # autocomplete is to be used, we also must make sure we
@@ -232,10 +227,10 @@ class VendorCatalogView(FileBatchMasterView):
                 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'])
+                        vendor = self.Session.get(model.Vendor,
+                                                  self.request.POST['vendor_uuid'])
                         if vendor:
-                            vendor_display = six.text_type(vendor)
+                            vendor_display = str(vendor)
                 f.set_widget('vendor_uuid',
                              forms.widgets.JQueryAutocompleteWidget(
                                  field_display=vendor_display,
@@ -275,35 +270,20 @@ class VendorCatalogView(FileBatchMasterView):
         return parser.display
 
     def template_kwargs_create(self, **kwargs):
-        use_buefy = self.get_use_buefy()
         app = self.get_rattail_app()
         vendor_handler = app.get_vendor_handler()
         parsers = self.get_parsers()
         parsers_data = {}
         for parser in parsers:
-            if use_buefy:
-                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
-            else:
-                if parser.vendor_key:
-                    vendor = vendor_handler.get_vendor(self.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'
+            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
diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py
index 6b8bdef7..4815d1f4 100644
--- a/tailbone/views/batch/vendorinvoice.py
+++ b/tailbone/views/batch/vendorinvoice.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for maintaining vendor invoices
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
 
@@ -89,10 +85,10 @@ class VendorInvoiceView(FileBatchMasterView):
     ]
 
     def get_instance_title(self, batch):
-        return six.text_type(batch.vendor)
+        return str(batch.vendor)
 
     def configure_grid(self, g):
-        super(VendorInvoiceView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # vendor
         g.set_joiner('vendor', lambda q: q.join(model.Vendor))
@@ -118,7 +114,7 @@ class VendorInvoiceView(FileBatchMasterView):
         g.set_link('executed', False)
 
     def configure_form(self, f):
-        super(VendorInvoiceView, self).configure_form(f)
+        super().configure_form(f)
 
         # vendor
         if self.creating:
@@ -167,7 +163,7 @@ class VendorInvoiceView(FileBatchMasterView):
     #         raise formalchemy.ValidationError(unicode(error))
 
     def get_batch_kwargs(self, batch):
-        kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch)
+        kwargs = super().get_batch_kwargs(batch)
         kwargs['parser_key'] = batch.parser_key
         return kwargs
 
@@ -183,7 +179,7 @@ class VendorInvoiceView(FileBatchMasterView):
         return True
 
     def configure_row_grid(self, g):
-        super(VendorInvoiceView, 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")
diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py
index 628ed07c..7afcc567 100644
--- a/tailbone/views/bouncer.py
+++ b/tailbone/views/bouncer.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +24,12 @@
 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
@@ -50,6 +44,7 @@ class EmailBounceView(MasterView):
     url_prefix = '/email-bounces'
     creatable = False
     editable = False
+    downloadable = True
 
     labels = {
         'config_key': "Source",
@@ -66,24 +61,29 @@ class EmailBounceView(MasterView):
     ]
 
     def __init__(self, request):
-        super(EmailBounceView, 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(EmailBounceView, 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 EmailBounceView(MasterView):
         g.set_link('intended_recipient_address')
 
     def configure_form(self, f):
-        super(EmailBounceView, 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,16 @@ class EmailBounceView(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):
         """
@@ -169,20 +174,13 @@ class EmailBounceView(MasterView):
         self.request.session.flash("Email bounce has been marked UN-processed.")
         return self.redirect(self.get_action_url('view', bounce))
 
-    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
-
     @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)
 
@@ -200,15 +198,6 @@ class EmailBounceView(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()
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index f531b48e..f4d98c05 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +24,14 @@
 Various common views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
-import six
+import warnings
+from collections import OrderedDict
 
 from rattail.batch import consume_batch_id
-from rattail.util import OrderedDict, simple_error, import_module_path
+from rattail.util import get_pkg_version, simple_error
 from rattail.files import resource_path
 
-from pyramid import httpexceptions
-from pyramid.response import Response
-
 from tailbone import forms
 from tailbone.forms.common import Feedback
 from tailbone.db import Session
@@ -54,28 +50,62 @@ class CommonView(View):
         """
         Home page view.
         """
+        app = self.get_rattail_app()
+
+        # maybe auto-redirect anons to login
         if not self.request.user:
-            if self.rattail_config.getbool('tailbone', 'login_is_home', default=True):
-                raise self.redirect(self.request.route_url('login'))
+            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.rattail_config.get(
-            'tailbone', 'main_image_url',
-            default=self.request.static_url('tailbone:static/img/home_logo.png'))
+        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')
 
-        use_buefy = self.get_use_buefy()
         context = {
             'image_url': image_url,
-            'use_buefy': use_buefy,
+            'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
-        if use_buefy:
-            context['index_title'] = self.rattail_config.node_title()
 
-        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
@@ -83,16 +113,13 @@ 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 get_project_title(self):
-        return self.rattail_config.app_title()
+        app = self.get_rattail_app()
+        return app.get_title()
 
     def get_project_version(self):
 
@@ -100,9 +127,8 @@ class CommonView(View):
         if hasattr(self, 'project_version'):
             return self.project_version
 
-        pkg = self.rattail_config.app_package()
-        mod = import_module_path(pkg)
-        return mod.__version__
+        app = self.get_rattail_app()
+        return app.get_version()
 
     def exception(self):
         """
@@ -114,26 +140,22 @@ class CommonView(View):
         """
         Generic view to show "about project" info page.
         """
-        use_buefy = self.get_use_buefy()
-        context = {
+        app = self.get_rattail_app()
+        return {
             'project_title': self.get_project_title(),
             'project_version': self.get_project_version(),
             'packages': self.get_packages(),
-            'use_buefy': use_buefy,
+            'index_title': app.get_node_title(),
         }
-        if use_buefy:
-            context['index_title'] = self.rattail_config.node_title()
-        return context
 
     def get_packages(self):
         """
         Should return the full set of packages which should be displayed on the
         'about' page.
         """
-        import rattail, tailbone
         return OrderedDict([
-            ('rattail', rattail.__version__),
-            ('Tailbone', tailbone.__version__),
+            ('rattail', get_pkg_version('rattail')),
+            ('Tailbone', get_pkg_version('Tailbone')),
         ])
 
     def change_theme(self):
@@ -148,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):
         """
@@ -174,15 +195,16 @@ class CommonView(View):
         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
             app.send_email('user_feedback', data=data)
             return {'ok': True}
-        return {'error': "Form did not validate!"}
+        dform = form.make_deform_form()
+        return {'error': str(dform.error)}
 
     def consume_batch_id(self):
         """
@@ -203,9 +225,8 @@ class CommonView(View):
         if not self.request.is_root:
             raise self.forbidden()
 
-        use_buefy = self.get_use_buefy()
         app = self.get_rattail_app()
-        app_title = self.rattail_config.app_title()
+        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)
@@ -253,7 +274,6 @@ class CommonView(View):
             poser_error = simple_error(error)
 
         return {
-            'use_buefy': use_buefy,
             'app_title': app_title,
             'index_title': app_title,
             'poser_dir': poser_dir,
@@ -287,6 +307,13 @@ 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', '/')
diff --git a/tailbone/views/core.py b/tailbone/views/core.py
index c0f03e19..88b2519f 100644
--- a/tailbone/views/core.py
+++ b/tailbone/views/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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 protected_usernames
 
 
-class View(object):
+class View:
     """
     Base class for all class-based views.
     """
@@ -67,8 +58,10 @@ class View(object):
 
         config = self.rattail_config
         if config:
-            self.enum = config.get_enum()
-            self.model = config.get_model()
+            self.config = config
+            self.app = self.config.get_app()
+            self.model = self.app.model
+            self.enum = self.app.enum
 
     @property
     def rattail_config(self):
@@ -94,22 +87,16 @@ 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)
-
     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):
         """
@@ -132,7 +119,8 @@ 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, **kwargs):
         """
@@ -170,13 +158,15 @@ class View(object):
         if attachment:
             if not filename:
                 filename = os.path.basename(path)
-                if six.PY2:
-                    filename = filename.encode('ascii', 'replace')
             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/customers.py b/tailbone/views/customers.py
index a905ea07..7e49ccef 100644
--- a/tailbone/views/customers.py
+++ b/tailbone/views/customers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,10 @@
 Customer Views
 """
 
-from __future__ import unicode_literals, absolute_import
+from collections import OrderedDict
 
-import six
 import sqlalchemy as sa
+from sqlalchemy import orm
 
 import colander
 from pyramid.httpexceptions import HTTPNotFound
@@ -37,14 +37,14 @@ from tailbone import grids
 from tailbone.db import Session
 from tailbone.views import MasterView
 
-from rattail.db import model
+from rattail.db.model import Customer, CustomerShopper, PendingCustomer
 
 
 class CustomerView(MasterView):
     """
     Master view for the Customer class.
     """
-    model_class = model.Customer
+    model_class = Customer
     is_contact = True
     has_versions = True
     results_downloadable = True
@@ -58,6 +58,7 @@ class CustomerView(MasterView):
 
     labels = {
         'id': "ID",
+        'name': "Account Name",
         'default_phone': "Phone Number",
         'default_email': "Email Address",
         'default_address': "Physical Address",
@@ -66,17 +67,16 @@ class CustomerView(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',
@@ -89,6 +89,7 @@ class CustomerView(MasterView):
         'wholesale',
         'active_in_pos',
         'active_in_pos_sticky',
+        'shoppers',
         'people',
         'groups',
         'members',
@@ -106,6 +107,22 @@ class CustomerView(MasterView):
         '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(
@@ -113,56 +130,112 @@ class CustomerView(MasterView):
                 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(CustomerView, 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)
 
+        # 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_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)
+        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'
 
-        g.set_link('id')
-        g.set_link('number')
+        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('name')
         g.set_link('person')
         g.set_link('email')
 
+    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:
@@ -170,13 +243,14 @@ class CustomerView(MasterView):
 
     def get_instance(self):
         try:
-            instance = super(CustomerView, 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
@@ -187,27 +261,35 @@ class CustomerView(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(CustomerView, self).configure_common_form(f)
+    def configure_form(self, f):
+        super().configure_form(f)
         customer = f.model_instance
         permission_prefix = self.get_permission_prefix()
-        use_buefy = self.get_use_buefy()
 
+        # 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)
@@ -234,6 +316,7 @@ class CustomerView(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)"))
@@ -246,14 +329,21 @@ class CustomerView(MasterView):
             f.set_readonly('person')
             f.set_renderer('person', self.form_render_person)
 
-        # people
-        if self.viewing:
-            if use_buefy:
-                f.set_renderer('people', self.render_people_buefy)
-            elif self.people_detachable and self.has_perm('detach_person'):
-                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.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')
 
@@ -264,11 +354,7 @@ class CustomerView(MasterView):
             f.set_renderer('groups', self.render_groups)
             f.set_readonly('groups')
 
-    def configure_form(self, f):
-        super(CustomerView, 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')
@@ -281,13 +367,32 @@ class CustomerView(MasterView):
             f.set_readonly('members')
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(CustomerView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
+        customer = kwargs['instance']
 
-        kwargs['show_profiles_helper'] = self.show_profiles_helper
+        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
 
-        use_buefy = self.get_use_buefy()
-        if use_buefy:
-            customer = kwargs['instance']
+        kwargs['expose_people'] = self.should_expose_people()
+        if kwargs['expose_people']:
             people = []
             for person in customer.people:
                 data = {
@@ -310,9 +415,28 @@ class CustomerView(MasterView):
                 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:
@@ -323,72 +447,61 @@ class CustomerView(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)
-            url = self.request.route_url('people.view', 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 ""
-
-        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))
-
-        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())
-
-    def render_people_buefy(self, customer, field):
+    def render_shoppers(self, customer, field):
         route_prefix = self.get_route_prefix()
         permission_prefix = self.get_permission_prefix()
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.people'.format(route_prefix),
+            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 #"},
+        )
+
+        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',
@@ -400,16 +513,16 @@ class CustomerView(MasterView):
         )
 
         if self.request.has_perm('people.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('people.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
         if self.people_detachable and self.has_perm('detach_person'):
-            g.main_actions.append(self.make_action('detach', icon='minus-circle',
-                                                   link_class='has-text-warning',
-                                                   click_handler="$emit('detach-person', props.row._action_url_detach)"))
+            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_buefy_table_element(data_prop='peopleData'))
+            g.render_table_element(data_prop='peopleData'))
 
     def render_groups(self, customer, field):
         groups = customer.groups
@@ -428,24 +541,28 @@ class CustomerView(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()
 
@@ -492,9 +609,26 @@ class CustomerView(MasterView):
         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',
@@ -522,9 +656,7 @@ class CustomerView(MasterView):
             config.add_tailbone_permission(permission_prefix,
                                            '{}.detach_person'.format(permission_prefix),
                                            "Detach a Person from a {}".format(model_title))
-            # TODO: this should require POST, but we'll add that once
-            # we can assume a Buefy theme is present, to avoid having
-            # to implement the logic in old jquery...
+            # TODO: this should require POST!
             config.add_route('{}.detach_person'.format(route_prefix),
                              '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix),
                              # request_method='POST',
@@ -534,11 +666,92 @@ class CustomerView(MasterView):
                             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 = model.PendingCustomer
+    model_class = PendingCustomer
     route_prefix = 'pending_customers'
     url_prefix = '/customers/pending'
 
@@ -579,19 +792,19 @@ class PendingCustomerView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(PendingCustomerView, self).configure_grid(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 = six.text_type(self.enum.PENDING_CUSTOMER_STATUS_RESOLVED)
+        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(PendingCustomerView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_enum('status_code', self.enum.PENDING_CUSTOMER_STATUS)
 
@@ -619,7 +832,7 @@ class PendingCustomerView(MasterView):
         redirect = self.redirect(self.get_action_url('view', pending))
 
         uuid = self.request.POST['person_uuid']
-        person = self.Session.query(model.Person).get(uuid)
+        person = self.Session.get(model.Person, uuid)
         if not person:
             self.request.session.flash("Person not found!", 'error')
             return redirect
@@ -667,7 +880,7 @@ class PendingCustomerView(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")
 
@@ -676,8 +889,10 @@ 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 {
@@ -700,6 +915,10 @@ def defaults(config, **kwargs):
                               base['CustomerView'])
     CustomerView.defaults(config)
 
+    CustomerShopperView = kwargs.get('CustomerShopperView',
+                                     base['CustomerShopperView'])
+    CustomerShopperView.defaults(config)
+
     PendingCustomerView = kwargs.get('PendingCustomerView',
                                      base['PendingCustomerView'])
     PendingCustomerView.defaults(config)
diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py
index 26ac5cde..fa0df901 100644
--- a/tailbone/views/custorders/batch.py
+++ b/tailbone/views/custorders/batch.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,11 +24,7 @@
 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
@@ -42,8 +38,8 @@ 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 = [
@@ -126,7 +122,7 @@ class CustomerOrderBatchView(BatchMasterView):
     ]
 
     def configure_grid(self, g):
-        super(CustomerOrderBatchView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.set_type('total_price', 'currency')
 
@@ -135,9 +131,9 @@ class CustomerOrderBatchView(BatchMasterView):
         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')
@@ -152,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))
@@ -172,12 +168,12 @@ 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))
@@ -194,7 +190,7 @@ class CustomerOrderBatchView(BatchMasterView):
         pending = batch.pending_customer
         if not pending:
             return
-        text = six.text_type(pending)
+        text = str(pending)
         url = self.request.route_url('pending_customers.view', uuid=pending.uuid)
         return tags.link_to(text, url)
 
@@ -205,7 +201,7 @@ class CustomerOrderBatchView(BatchMasterView):
             return 'notice'
 
     def configure_row_grid(self, g):
-        super(CustomerOrderBatchView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_type('case_quantity', 'quantity')
         g.set_type('cases_ordered', 'quantity')
@@ -219,7 +215,7 @@ class CustomerOrderBatchView(BatchMasterView):
         g.set_link('product_description')
 
     def configure_row_form(self, f):
-        super(CustomerOrderBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         f.set_renderer('product', self.render_product)
         f.set_renderer('pending_product', self.render_pending_product)
diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py
index c780756a..e7edf3aa 100644
--- a/tailbone/views/custorders/items.py
+++ b/tailbone/views/custorders/items.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,27 +24,23 @@
 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 HTML, tags
 
 from tailbone.views import MasterView
-from tailbone.util import raw_datetime
+from tailbone.util import raw_datetime, csrf_token
 
 
 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
@@ -61,94 +57,125 @@ class CustomerOrderItemView(MasterView):
     grid_columns = [
         'order_id',
         'person',
+        '_product_key_',
         'product_brand',
         'product_description',
         'product_size',
+        'department_name',
+        'case_quantity',
         'order_quantity',
         'order_uom',
-        'case_quantity',
+        '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',
-        'sequence',
+        'customer',
         'person',
+        'sequence',
+        '_product_key_',
         'product',
         'pending_product',
         'product_brand',
         'product_description',
         'product_size',
+        'case_quantity',
         'order_quantity',
         'order_uom',
-        'case_quantity',
         'unit_price',
         'total_price',
+        'special_order',
         'price_needs_confirmation',
         'paid_amount',
+        'payment_transaction_number',
         'status_code',
-        'notes',
+        '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(CustomerOrderItemView, 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')
 
-        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_sort_defaults('order_created', 'desc')
-
-        g.set_type('case_quantity', 'quantity')
-        g.set_type('cases_ordered', 'quantity')
-        g.set_type('units_ordered', 'quantity')
-        g.set_type('total_price', 'currency')
-        g.set_type('order_quantity', 'quantity')
-
-        g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE)
-
-        g.set_renderer('person', self.render_person_text)
-        g.set_renderer('order_created', self.render_order_created)
-
-        g.set_renderer('status_code', self.render_status_code_column)
-
+        # 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.set_sorter('person', model.Person.display_name)
+        g.set_filter('person', model.Person.display_name,
+                     default_active=True, default_verb='contains')
+
+        # 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")
 
-        g.set_link('order_id')
-        g.set_link('person')
-        g.set_link('product_brand')
-        g.set_link('product_description')
+        # "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')
+
+        # 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')
+
+        # status_code
+        g.set_renderer('status_code', self.render_status_code_column)
+
+        # 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
@@ -156,28 +183,38 @@ class CustomerOrderItemView(MasterView):
     def render_person_text(self, item, field):
         person = item.order.person
         if person:
-            text = six.text_type(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,
-                                                   six.text_type(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(CustomerOrderItemView, self).configure_form(f)
-        use_buefy = self.get_use_buefy()
+        super().configure_form(f)
         item = f.model_instance
 
         # order
         f.set_renderer('order', self.render_order)
 
+        # contact
+        if self.batch_handler.new_order_requires_customer():
+            f.remove('person')
+        else:
+            f.remove('customer')
+
+        # 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)
@@ -187,7 +224,9 @@ class CustomerOrderItemView(MasterView):
             elif item.pending_product and not item.product:
                 f.remove('product')
 
-        # product uom
+        # 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
@@ -196,13 +235,6 @@ class CustomerOrderItemView(MasterView):
         f.set_renderer('product_size', self.highlight_pending_field)
         f.set_renderer('case_quantity', self.highlight_pending_field_quantity)
 
-        'unit_price',
-        'total_price',
-        'price_needs_confirmation',
-        'paid_amount',
-        'status_code',
-        'notes',
-
         # quantity fields
         f.set_type('cases_ordered', 'quantity')
         f.set_type('units_ordered', 'quantity')
@@ -221,11 +253,52 @@ class CustomerOrderItemView(MasterView):
         # status_code
         f.set_renderer('status_code', self.render_status_code)
 
-        # notes
-        if use_buefy:
-            f.set_renderer('notes', self.render_notes)
-        else:
-            f.remove('notes')
+        # 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:
@@ -271,13 +344,22 @@ class CustomerOrderItemView(MasterView):
         return outer
 
     def render_status_code(self, item, field):
-        use_buefy = self.get_use_buefy()
         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 use_buefy and self.has_perm('change_status'):
+        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',
@@ -288,25 +370,39 @@ class CustomerOrderItemView(MasterView):
         outer = HTML.tag('div', class_='level', c=[left])
         return outer
 
-    def render_notes(self, item, field):
+    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(
-            key='{}.notes'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.events',
             data=[],
             columns=[
-                'text',
-                'created_by',
-                'created',
+                'occurred',
+                'type_code',
+                'user',
+                'note',
             ],
             labels={
-                'text': "Note",
+                'occurred': "When",
+                'type_code': "What",
+                'user': "Who",
             },
         )
 
         table = HTML.literal(
-            g.render_buefy_table_element(data_prop='notesData'))
+            g.render_table_element(data_prop='eventsData'))
         elements = [table]
 
         if self.has_perm('add_note'):
@@ -323,12 +419,13 @@ class CustomerOrderItemView(MasterView):
                         c=elements)
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(CustomerOrderItemView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
+        model = self.model
         app = self.get_rattail_app()
         item = kwargs['instance']
 
-        # fetch notes for current item
-        kwargs['notes_data'] = self.get_context_notes(item)
+        # fetch events for current item
+        kwargs['events_data'] = self.get_context_events(item)
 
         # fetch "other" order items, siblings of current one
         order = item.order
@@ -337,16 +434,19 @@ class CustomerOrderItemView(MasterView):
                                   .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 = localtime(self.rattail_config, order.created, from_utc=True).date()
+                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],
@@ -356,21 +456,24 @@ class CustomerOrderItemView(MasterView):
                 '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_notes(self, item):
-        notes = []
-        for note in reversed(item.notes):
-            created = localtime(self.rattail_config, note.created, from_utc=True)
-            notes.append({
-                'created': raw_datetime(self.rattail_config, created),
-                'created_by': note.created_by.display_name,
-                'text': note.text,
+    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 notes
+        return events
 
     def confirm_price(self):
         """
@@ -398,10 +501,33 @@ class CustomerOrderItemView(MasterView):
         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))
 
@@ -416,7 +542,7 @@ class CustomerOrderItemView(MasterView):
         uuids = self.request.POST['uuids']
         if uuids:
             for uuid in uuids.split(','):
-                item = self.Session.query(model.CustomerOrderItem).get(uuid)
+                item = self.Session.get(model.CustomerOrderItem, uuid)
                 if item:
                     order_items.append(item)
 
@@ -455,62 +581,31 @@ class CustomerOrderItemView(MasterView):
         View for adding a new note to current order item, optinally
         also adding it to all other items under the parent order.
         """
-        order_item = self.get_instance()
+        item = self.get_instance()
         data = self.request.json_body
-        new_note = data['note']
-        apply_all = data['apply_all'] == True
-        user = self.request.user
 
-        if apply_all:
-            order_items = order_item.order.items
-        else:
-            order_items = [order_item]
-
-        for item in order_items:
-            item.notes.append(model.CustomerOrderItemNote(
-                created_by=user, text=new_note))
-
-            # # attach event
-            # item.events.append(model.CustomerOrderItemEvent(
-            #     type_code=self.enum.CUSTORDER_ITEM_EVENT_ADDED_NOTE,
-            #     user=user, note=new_note))
+        self.custorder_handler.add_note(item, data['note'], self.request.user,
+                                        apply_all=data['apply_all'] == True)
 
         self.Session.flush()
-        self.Session.refresh(order_item)
-        return {'success': True,
-                'notes': self.get_context_notes(order_item)}
+        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 render_person(self, item, field):
         person = item.order.person
         if person:
-            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 get_row_data(self, item):
-        return self.Session.query(model.CustomerOrderItemEvent)\
-                           .filter(model.CustomerOrderItemEvent.item == item)\
-                           .order_by(model.CustomerOrderItemEvent.occurred.desc(),
-                                     model.CustomerOrderItemEvent.type_code)
-
-    def configure_row_grid(self, g):
-        super(CustomerOrderItemView, self).configure_row_grid(g)
-
-        g.set_enum('type_code', self.enum.CUSTORDER_ITEM_EVENT)
-
-        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)
@@ -519,6 +614,7 @@ class CustomerOrderItemView(MasterView):
     @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()
@@ -538,6 +634,14 @@ class CustomerOrderItemView(MasterView):
                         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),
@@ -549,6 +653,14 @@ class CustomerOrderItemView(MasterView):
                         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),
diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py
index e8ce8fd3..b1a9831a 100644
--- a/tailbone/views/custorders/orders.py
+++ b/tailbone/views/custorders/orders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,21 +24,17 @@
 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.util import pretty_quantity, simple_error
+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, HTML
 
-from tailbone.db import Session
 from tailbone.views import MasterView
 
 
@@ -49,13 +45,13 @@ class CustomerOrderView(MasterView):
     """
     Master view for customer orders
     """
-    model_class = model.CustomerOrder
+    model_class = CustomerOrder
     route_prefix = 'custorders'
     editable = False
     configurable = True
 
     labels = {
-        'id': "ID",
+        'id': "Order ID",
         'status_code': "Status",
     }
 
@@ -63,8 +59,9 @@ class CustomerOrderView(MasterView):
         'id',
         'customer',
         'person',
-        'created',
         'status_code',
+        'created',
+        'created_by',
     ]
 
     form_fields = [
@@ -82,7 +79,7 @@ class CustomerOrderView(MasterView):
     ]
 
     has_rows = True
-    model_row_class = model.CustomerOrderItem
+    model_row_class = CustomerOrderItem
     rows_viewable = False
 
     row_labels = {
@@ -91,30 +88,56 @@ class CustomerOrderView(MasterView):
 
     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(CustomerOrderView, self).__init__(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(CustomerOrderView, self).configure_grid(g)
+        super().configure_grid(g)
+        model = self.app.model
+
+        # id
+        g.set_link('id')
+        g.filters['id'].default_active = True
+        g.filters['id'].default_verb = 'equal'
+
+        # 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,
@@ -123,6 +146,7 @@ class CustomerOrderView(MasterView):
                                                   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,
@@ -130,16 +154,17 @@ class CustomerOrderView(MasterView):
                                                 default_active=True,
                                                 default_verb='contains')
 
+        # status_code
         g.set_enum('status_code', self.enum.CUSTORDER_STATUS)
 
+        # created
         g.set_sort_defaults('created', 'desc')
 
-        g.set_link('id')
-        g.set_link('customer')
-        g.set_link('person')
+    def get_instance_title(self, order):
+        return f"#{order.id} for {order.customer or order.person}"
 
     def configure_form(self, f):
-        super(CustomerOrderView, self).configure_form(f)
+        super().configure_form(f)
         order = f.model_instance
 
         f.set_readonly('id')
@@ -195,7 +220,7 @@ class CustomerOrderView(MasterView):
         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)
 
@@ -203,12 +228,13 @@ class CustomerOrderView(MasterView):
         pending = batch.pending_customer
         if not pending:
             return
-        text = six.text_type(pending)
+        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)
 
@@ -216,11 +242,13 @@ class CustomerOrderView(MasterView):
         return item.order
 
     def make_row_grid_kwargs(self, **kwargs):
-        kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs)
+        kwargs = super().make_row_grid_kwargs(**kwargs)
 
-        assert not kwargs['main_actions']
-        kwargs['main_actions'].append(
-            self.make_action('view', icon='eye', url=self.row_view_action_url))
+        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
 
@@ -229,12 +257,16 @@ class CustomerOrderView(MasterView):
             return self.request.route_url('custorders.items.view', uuid=item.uuid)
 
     def configure_row_grid(self, g):
-        super(CustomerOrderView, self).configure_row_grid(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')
@@ -275,7 +307,7 @@ class CustomerOrderView(MasterView):
 
     def render_row_status_code(self, item, field):
         text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code,
-                                                   six.text_type(item.status_code))
+                                                   str(item.status_code))
         if item.status_text:
             return HTML.tag('span', title=item.status_text, c=[text])
         return text
@@ -293,6 +325,7 @@ class CustomerOrderView(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()
@@ -343,12 +376,20 @@ class CustomerOrderView(MasterView):
             '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': self.rattail_config.product_key_title(),
-            'allow_unknown_product': self.batch_handler.allow_unknown_product(),
+            '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(),
         })
 
@@ -370,11 +411,23 @@ class CustomerOrderView(MasterView):
                             '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)\
@@ -440,7 +493,8 @@ class CustomerOrderView(MasterView):
         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"}
 
@@ -460,6 +514,7 @@ class CustomerOrderView(MasterView):
         return info
 
     def assign_contact(self, batch, data):
+        model = self.app.model
         kwargs = {}
 
         # this will either be a Person or Customer UUID
@@ -467,14 +522,14 @@ class CustomerOrderView(MasterView):
 
         if self.batch_handler.new_order_requires_customer():
 
-            customer = self.Session.query(model.Customer).get(uuid)
+            customer = self.Session.get(model.Customer, uuid)
             if not customer:
                 return {'error': "Customer not found"}
             kwargs['customer'] = customer
 
         else:
 
-            person = self.Session.query(model.Person).get(uuid)
+            person = self.Session.get(model.Person, uuid)
             if not person:
                 return {'error': "Person not found"}
             kwargs['person'] = person
@@ -483,7 +538,7 @@ class CustomerOrderView(MasterView):
         try:
             self.batch_handler.assign_contact(batch, **kwargs)
         except ValueError as error:
-            return {'error': six.text_type(error)}
+            return {'error': str(error)}
 
         self.Session.flush()
         context = self.get_context_contact(batch)
@@ -585,7 +640,7 @@ class CustomerOrderView(MasterView):
             self.batch_handler.update_pending_customer(batch, self.request.user,
                                                        data)
         except Exception as error:
-            return {'error': six.text_type(error)}
+            return {'error': str(error)}
 
         self.Session.flush()
         context = self.get_context_contact(batch)
@@ -614,7 +669,8 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a product UUID"}
 
-        product = self.Session.query(model.Product).get(uuid)
+        model = self.app.model
+        product = self.Session.get(model.Product, uuid)
         if not product:
             return {'error': "Product not found"}
 
@@ -630,12 +686,14 @@ class CustomerOrderView(MasterView):
         try:
             info = self.batch_handler.get_product_info(batch, product)
         except Exception as error:
-            return {'error': six.text_type(error)}
+            return {'error': str(error)}
         else:
             info['url'] = self.request.route_url('products.view', uuid=info['uuid'])
-            return info
+            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 = []
 
@@ -646,6 +704,7 @@ class CustomerOrderView(MasterView):
                 # 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}
@@ -653,7 +712,7 @@ class CustomerOrderView(MasterView):
     def normalize_batch(self, batch):
         return {
             'uuid': batch.uuid,
-            'total_price': six.text_type(batch.total_price or 0),
+            '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,
@@ -674,15 +733,14 @@ class CustomerOrderView(MasterView):
             return app.render_currency(obj.unit_price)
 
     def normalize_row(self, row):
-        app = self.get_rattail_app()
-        products_handler = app.get_products_handler()
+        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': six.text_type(row.product_upc or ''),
+            '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,
@@ -691,20 +749,20 @@ class CustomerOrderView(MasterView):
             'product_size': row.product_size,
             'product_weighed': row.product_weighed,
 
-            'case_quantity': pretty_quantity(row.case_quantity),
-            'cases_ordered': pretty_quantity(row.cases_ordered),
-            'units_ordered': pretty_quantity(row.units_ordered),
-            'order_quantity': pretty_quantity(row.order_quantity),
+            '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': pretty_quantity(row.discount_percent),
+            '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': app.render_currency(row.total_price),
+            'total_price_display': self.app.render_currency(row.total_price),
 
             'status_code': row.status_code,
             'status_text': row.status_text,
@@ -712,15 +770,15 @@ class CustomerOrderView(MasterView):
 
         if row.unit_regular_price:
             data['unit_regular_price'] = float(row.unit_regular_price)
-            data['unit_regular_price_display'] = app.render_currency(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'] = app.render_currency(row.unit_sale_price)
+            data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price)
         if row.sale_ends:
-            sale_ends = app.localtime(row.sale_ends, from_utc=True).date()
-            data['sale_ends'] = six.text_type(sale_ends)
-            data['sale_ends_display'] = app.render_date(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
@@ -740,7 +798,7 @@ class CustomerOrderView(MasterView):
             pending = row.pending_product
             data['pending_product'] = {
                 'uuid': pending.uuid,
-                'upc': six.text_type(pending.upc) if pending.upc is not None else None,
+                '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,
@@ -757,12 +815,12 @@ class CustomerOrderView(MasterView):
 
         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'] = app.render_currency(case_price)
+        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.rattail_config.product_key()
+        key = self.app.get_product_key_field()
         if key == 'upc':
             data['product_key'] = data['product_upc_pretty']
         elif key == 'item_id':
@@ -786,7 +844,7 @@ class CustomerOrderView(MasterView):
                 case_qty = unit_qty = '??'
             else:
                 case_qty = data['case_quantity']
-                unit_qty = pretty_quantity(row.order_quantity * row.case_quantity)
+                unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity)
             data.update({
                 'order_quantity_display': "{} {} (&times; {} {} = {} {})".format(
                     data['order_quantity'],
@@ -799,14 +857,14 @@ class CustomerOrderView(MasterView):
         else:
             data.update({
                 'order_quantity_display': "{} {}".format(
-                    pretty_quantity(row.order_quantity),
+                    self.app.render_quantity(row.order_quantity),
                     self.enum.UNIT_OF_MEASURE[unit_uom]),
             })
 
         return data
 
     def add_item(self, batch, data):
-        app = self.get_rattail_app()
+        model = self.app.model
 
         order_quantity = decimal.Decimal(data.get('order_quantity') or '0')
         order_uom = data.get('order_uom')
@@ -818,7 +876,7 @@ class CustomerOrderView(MasterView):
             if not uuid:
                 return {'error': "Must specify a product UUID"}
 
-            product = self.Session.query(model.Product).get(uuid)
+            product = self.Session.get(model.Product, uuid)
             if not product:
                 return {'error': "Product not found"}
 
@@ -837,11 +895,14 @@ class CustomerOrderView(MasterView):
             pending_info = dict(data['pending_product'])
 
             if 'upc' in pending_info:
-                pending_info['upc'] = app.make_gpc(pending_info['upc'])
+                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:
-                    pending_info[field] = decimal.Decimal(pending_info[field])
+                    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
 
@@ -863,7 +924,8 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a row UUID"}
 
-        row = self.Session.query(model.CustomerOrderBatchRow).get(uuid)
+        model = self.app.model
+        row = self.Session.get(model.CustomerOrderBatchRow, uuid)
         if not row:
             return {'error': "Row not found"}
 
@@ -880,7 +942,7 @@ class CustomerOrderView(MasterView):
             if not uuid:
                 return {'error': "Must specify a product UUID"}
 
-            product = self.Session.query(model.Product).get(uuid)
+            product = self.Session.get(model.Product, uuid)
             if not product:
                 return {'error': "Product not found"}
 
@@ -921,7 +983,8 @@ class CustomerOrderView(MasterView):
         if not uuid:
             return {'error': "Must specify a row UUID"}
 
-        row = self.Session.query(model.CustomerOrderBatchRow).get(uuid)
+        model = self.app.model
+        row = self.Session.get(model.CustomerOrderBatchRow, uuid)
         if not row:
             return {'error': "Row not found"}
 
@@ -933,6 +996,11 @@ class CustomerOrderView(MasterView):
                 'batch': self.normalize_batch(batch)}
 
     def submit_new_order(self, batch, data):
+
+        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:
@@ -957,8 +1025,63 @@ class CustomerOrderView(MasterView):
     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):
-        return [
+        settings = [
 
             # customer handling
             {'section': 'rattail.custorders',
@@ -981,17 +1104,45 @@ class CustomerOrderView(MasterView):
             {'section': 'rattail.custorders',
              'option': 'product_price_may_be_questionable',
              'type': bool},
-            {'section': 'rattail.custorders',
-             'option': 'allow_unknown_product',
-             '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)
@@ -1001,6 +1152,21 @@ class CustomerOrderView(MasterView):
     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),
@@ -1029,6 +1195,14 @@ class CustomerOrderView(MasterView):
                         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
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index e6c31721..2b955b5f 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,19 +24,18 @@
 DataSync Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import json
 import subprocess
 import logging
 
-import six
 import sqlalchemy as sa
 
-from rattail.db import model
+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
@@ -74,10 +73,22 @@ class DataSyncThreadView(MasterView):
     ]
 
     def __init__(self, request, context=None):
-        super(DataSyncThreadView, self).__init__(request, context=context)
+        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.
@@ -109,7 +120,7 @@ class DataSyncThreadView(MasterView):
         from datasync_change
         group by source, consumer
         """
-        result = self.Session.execute(sql)
+        result = self.Session.execute(sa.text(sql))
         all_changes = {}
         for row in result:
             all_changes[(row.source, row.consumer)] = row.changes
@@ -117,7 +128,7 @@ class DataSyncThreadView(MasterView):
         watcher_data = []
         consumer_data = []
         now = app.localtime()
-        for key, profile in six.iteritems(profiles):
+        for key, profile in profiles.items():
             watcher = profile.watcher
 
             lastrun = self.datasync_handler.get_watcher_lastrun(
@@ -191,10 +202,36 @@ class DataSyncThreadView(MasterView):
         return self.redirect(self.request.get_referrer(
             default=self.request.route_url('datasyncchanges')))
 
-    def configure_get_context(self):
+    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):
@@ -232,25 +269,15 @@ class DataSyncThreadView(MasterView):
             data['consumers_data'] = consumers
             profiles_data.append(data)
 
-        return {
-            'profiles': profiles,
-            'profiles_data': profiles_data,
-            'use_profile_settings': self.datasync_handler.should_use_profile_settings(),
-            'supervisor_process_name': self.rattail_config.get(
-                'rattail.datasync', 'supervisor_process_name'),
-            'restart_command': self.rattail_config.get(
-                'tailbone', 'datasync.restart'),
-        }
+        context['profiles_data'] = profiles_data
+        return context
 
-    def configure_gather_settings(self, data):
-        settings = []
-        watch = []
+    def configure_gather_settings(self, data, **kwargs):
+        """ """
+        settings = super().configure_gather_settings(data, **kwargs)
 
-        use_profile_settings = data.get('use_profile_settings') == 'true'
-        settings.append({'name': 'rattail.datasync.use_profile_settings',
-                         'value': 'true' if use_profile_settings else 'false'})
-
-        if use_profile_settings:
+        if data.get('rattail.datasync.use_profile_settings') == 'true':
+            watch = []
 
             for profile in json.loads(data['profiles']):
                 pkey = profile['key']
@@ -258,7 +285,7 @@ class DataSyncThreadView(MasterView):
                     watch.append(pkey)
 
                 settings.extend([
-                    {'name': 'rattail.datasync.{}.watcher'.format(pkey),
+                    {'name': 'rattail.datasync.{}.watcher.spec'.format(pkey),
                      'value': profile['watcher_spec']},
                     {'name': 'rattail.datasync.{}.watcher.db'.format(pkey),
                      'value': profile['watcher_dbkey']},
@@ -289,7 +316,7 @@ class DataSyncThreadView(MasterView):
                         if consumer['enabled']:
                             consumers.append(ckey)
                         settings.extend([
-                            {'name': 'rattail.datasync.{}.consumer.{}'.format(pkey, ckey),
+                            {'name': f'rattail.datasync.{pkey}.consumer.{ckey}.spec',
                              'value': consumer['consumer_spec']},
                             {'name': 'rattail.datasync.{}.consumer.{}.db'.format(pkey, ckey),
                              'value': consumer['consumer_dbkey']},
@@ -304,7 +331,7 @@ class DataSyncThreadView(MasterView):
                         ])
 
                 settings.extend([
-                    {'name': 'rattail.datasync.{}.consumers'.format(pkey),
+                    {'name': 'rattail.datasync.{}.consumers.list'.format(pkey),
                      'value': ', '.join(consumers)},
                 ])
 
@@ -312,17 +339,12 @@ class DataSyncThreadView(MasterView):
                 settings.append({'name': 'rattail.datasync.watch',
                                  'value': ', '.join(watch)})
 
-        if data['supervisor_process_name']:
-            settings.append({'name': 'rattail.datasync.supervisor_process_name',
-                             'value': data['supervisor_process_name']})
-
-        if data['restart_command']:
-            settings.append({'name': 'tailbone.datasync.restart',
-                             'value': data['restart_command']})
-
         return settings
 
-    def configure_remove_settings(self):
+    def configure_remove_settings(self, **kwargs):
+        """ """
+        super().configure_remove_settings(**kwargs)
+
         purge_datasync_settings(self.rattail_config, self.Session())
 
     @classmethod
@@ -371,7 +393,7 @@ class DataSyncChangeView(MasterView):
     """
     Master view for the DataSyncChange model.
     """
-    model_class = model.DataSyncChange
+    model_class = DataSyncChange
     url_prefix = '/datasync/changes'
     permission_prefix = 'datasync_changes'
     creatable = False
@@ -392,8 +414,19 @@ class DataSyncChangeView(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(DataSyncChangeView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # batch_sequence
         g.set_label('batch_sequence', "Batch Seq.")
@@ -407,7 +440,7 @@ class DataSyncChangeView(MasterView):
         return kwargs
 
     def configure_form(self, f):
-        super(DataSyncChangeView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_readonly('obtained')
 
diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py
index 96dcfb61..47de8dca 100644
--- a/tailbone/views/departments.py
+++ b/tailbone/views/departments.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,10 @@
 Department Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
-from rattail.db import model
+from rattail.db.model import Department, Product
 
 from webhelpers2.html import HTML
 
-from tailbone import grids
 from tailbone.views import MasterView
 
 
@@ -40,7 +35,7 @@ class DepartmentView(MasterView):
     """
     Master view for the Department class.
     """
-    model_class = model.Department
+    model_class = Department
     touchable = True
     has_versions = True
     results_downloadable = True
@@ -51,6 +46,8 @@ class DepartmentView(MasterView):
         'name',
         'product',
         'personnel',
+        'tax',
+        'food_stampable',
         'exempt_from_gross_sales',
     ]
 
@@ -59,13 +56,17 @@ class DepartmentView(MasterView):
         'name',
         'product',
         'personnel',
+        'tax',
+        'food_stampable',
         'exempt_from_gross_sales',
+        'default_custorder_discount',
         'allow_product_deletions',
         'employees',
     ]
 
     has_rows = True
-    model_row_class = model.Product
+    model_row_class = Product
+    rows_title = "Products"
 
     row_labels = {
         'upc': "UPC",
@@ -82,22 +83,26 @@ class DepartmentView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(DepartmentView, 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(DepartmentView, self).configure_form(f)
-        use_buefy = self.get_use_buefy()
+        super().configure_form(f)
 
         f.remove_field('subdepartments')
 
-        if not use_buefy or self.creating or self.editing:
+        if self.creating or self.editing:
             f.remove('employees')
         else:
             f.set_renderer('employees', self.render_employees)
@@ -105,13 +110,26 @@ class DepartmentView(MasterView):
         f.set_type('product', 'boolean')
         f.set_type('personnel', 'boolean')
 
+        # tax
+        if self.creating:
+            # TODO: make this editable instead
+            f.remove('tax')
+        else:
+            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(
-            key='{}.employees'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.employees',
             data=[],
             columns=[
                 'first_name',
@@ -122,42 +140,29 @@ class DepartmentView(MasterView):
         )
 
         if self.request.has_perm('employees.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('employees.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='employeesData'))
+            g.render_table_element(data_prop='employeesData'))
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(DepartmentView, self).template_kwargs_view(**kwargs)
-        use_buefy = self.get_use_buefy()
+        kwargs = super().template_kwargs_view(**kwargs)
         department = kwargs['instance']
-        department_employees = sorted(department.employees, key=six.text_type)
+        department_employees = sorted(department.employees, key=str)
 
-        if use_buefy:
-            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
-
-        else: # not buefy
-            if department.employees:
-                actions = [
-                    grids.GridAction('view', icon='zoomin',
-                                     url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid))
-                ]
-                kwargs['employees'] = grids.Grid(None, department_employees, ['display_name'], request=self.request,
-                                                 model_class=model.Employee, main_actions=actions)
-            else:
-                kwargs['employees'] = None
+        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
 
@@ -166,6 +171,7 @@ class DepartmentView(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()
@@ -175,6 +181,7 @@ class DepartmentView(MasterView):
             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)
 
@@ -182,7 +189,7 @@ class DepartmentView(MasterView):
         return product.department
 
     def configure_row_grid(self, g):
-        super(DepartmentView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         app = self.get_rattail_app()
         self.handler = app.get_products_handler()
@@ -204,6 +211,7 @@ class DepartmentView(MasterView):
         """
         View list of departments by vendor
         """
+        model = self.model
         data = self.Session.query(model.Department)\
                            .outerjoin(model.Product)\
                            .join(model.ProductCost)\
@@ -212,20 +220,14 @@ class DepartmentView(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):
diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index 536bf6ed..98bd4295 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,27 +24,26 @@
 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 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
 
 
+log = logging.getLogger(__name__)
+
+
 class EmailSettingView(MasterView):
     """
     Master view for email admin (settings/preview).
@@ -85,7 +84,7 @@ class EmailSettingView(MasterView):
     ]
 
     def __init__(self, request):
-        super(EmailSettingView, self).__init__(request)
+        super().__init__(request)
         self.email_handler = self.get_handler()
 
     @property
@@ -105,17 +104,24 @@ class EmailSettingView(MasterView):
             emails = self.email_handler.get_all_emails()
         else:
             emails = self.email_handler.get_available_emails()
-        for key, Email in six.iteritems(emails):
+        for key, Email in emails.items():
             email = Email(self.rattail_config, key)
-            data.append(self.normalize(email))
+            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')
@@ -125,18 +131,16 @@ class EmailSettingView(MasterView):
 
         # 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.sorters['hidden'] = g.make_simple_sorter('hidden')
             g.set_type('hidden', 'boolean')
         else:
             g.remove('hidden')
 
         # toggle hidden
         if self.has_perm('configure'):
-            g.main_actions.append(
+            g.actions.append(
                 self.make_action('toggle_hidden', url='#', icon='ban',
                                  click_handler='toggleHidden(props.row)',
                                  factory=ToggleHidden))
@@ -198,7 +202,7 @@ class EmailSettingView(MasterView):
         return True
 
     def configure_form(self, f):
-        super(EmailSettingView, self).configure_form(f)
+        super().configure_form(f)
         profile = f.model_instance['_email']
 
         # key
@@ -266,20 +270,32 @@ class EmailSettingView(MasterView):
         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), six.text_type(data['enabled']).lower())
+        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), six.text_type(data['hidden']).lower())
+            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.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',
@@ -289,6 +305,19 @@ class EmailSettingView(MasterView):
              '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
@@ -297,6 +326,22 @@ class EmailSettingView(MasterView):
                          '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)
@@ -318,6 +363,16 @@ class EmailSettingView(MasterView):
                         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
 
@@ -380,7 +435,7 @@ class EmailPreview(View):
     """
 
     def __init__(self, request):
-        super(EmailPreview, self).__init__(request)
+        super().__init__(request)
 
         if hasattr(self, 'get_handler'):
             warnings.warn("defining a get_handler() method is deprecated; "
@@ -463,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
@@ -496,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')
@@ -526,13 +581,12 @@ class EmailAttemptView(MasterView):
             if len(recips) > 2:
                 recips = recips[:2]
                 recips.append('...')
-            recips = [HTML.escape(r) for r in recips]
             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)
diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py
index b45e78e7..debd8fcb 100644
--- a/tailbone/views/employees.py
+++ b/tailbone/views/employees.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
@@ -48,6 +45,7 @@ class EmployeeView(MasterView):
     touchable = True
     supports_autocomplete = True
     results_downloadable = True
+    configurable = True
 
     labels = {
         'id': "ID",
@@ -81,10 +79,25 @@ class EmployeeView(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(EmployeeView, self).configure_grid(g)
+        super().configure_grid(g)
         route_prefix = self.get_route_prefix()
-        use_buefy = self.get_use_buefy()
 
         # phone
         g.set_joiner('phone', lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_(
@@ -102,9 +115,20 @@ class EmployeeView(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'):
@@ -127,37 +151,52 @@ class EmployeeView(MasterView):
             g.set_enum('status', self.enum.EMPLOYEE_STATUS)
             g.filters['status'].default_active = True
             g.filters['status'].default_verb = 'equal'
-            if use_buefy:
-                g.filters['status'].default_value = six.text_type(self.enum.EMPLOYEE_STATUS_CURRENT)
-            else:
-                g.filters['status'].default_value = self.enum.EMPLOYEE_STATUS_CURRENT
+            g.filters['status'].default_value = str(self.enum.EMPLOYEE_STATUS_CURRENT)
         else:
             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)
+        query = super().query(session)
+        query = query.join(model.Person)
         if not self.has_perm('view_all'):
-            q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
-        return q
+            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
@@ -186,7 +225,7 @@ class EmployeeView(MasterView):
         return not self.is_employee_protected(employee)
 
     def configure_form(self, f):
-        super(EmployeeView, self).configure_form(f)
+        super().configure_form(f)
         employee = f.model_instance
 
         f.set_renderer('person', self.render_person)
@@ -201,7 +240,7 @@ class EmployeeView(MasterView):
         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),
@@ -213,7 +252,7 @@ class EmployeeView(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),
@@ -240,7 +279,7 @@ class EmployeeView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        employee = super(EmployeeView, self).objectify(form, data)
+        employee = super().objectify(form, data)
         self.update_stores(employee, data)
         self.update_departments(employee, data)
         return employee
@@ -255,7 +294,7 @@ class EmployeeView(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):
@@ -268,7 +307,7 @@ class EmployeeView(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):
@@ -283,7 +322,7 @@ class EmployeeView(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)
 
@@ -292,8 +331,8 @@ class EmployeeView(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):
@@ -301,8 +340,8 @@ class EmployeeView(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):
@@ -319,6 +358,15 @@ class EmployeeView(MasterView):
             (model.EmployeeDepartment, 'employee_uuid'),
         ]
 
+    def configure_get_simple_settings(self):
+        return [
+
+            # General
+            {'section': 'rattail',
+             'option': 'employees.straight_to_profile',
+             'type': bool},
+        ]
+
     @classmethod
     def defaults(cls, config):
         cls._defaults(config)
@@ -327,6 +375,7 @@ class EmployeeView(MasterView):
     @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
@@ -334,6 +383,11 @@ class EmployeeView(MasterView):
                                        '{}.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()
diff --git a/tailbone/views/essentials.py b/tailbone/views/essentials.py
index b38749d1..08d2e0c4 100644
--- a/tailbone/views/essentials.py
+++ b/tailbone/views/essentials.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +24,36 @@
 Essential views for convenient includes
 """
 
-from __future__ import unicode_literals, absolute_import
+
+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):
-    config.include('tailbone.views.auth')
-    config.include('tailbone.views.common')
-    config.include('tailbone.views.email')
-    config.include('tailbone.views.menus')
-    config.include('tailbone.views.people')
-    config.include('tailbone.views.progress')
-    config.include('tailbone.views.roles')
-    config.include('tailbone.views.settings')
-    config.include('tailbone.views.tables')
-    config.include('tailbone.views.upgrades')
-    config.include('tailbone.views.users')
+    defaults(config)
diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py
index 3f6d417c..44df359f 100644
--- a/tailbone/views/exports.py
+++ b/tailbone/views/exports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,7 +145,7 @@ 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
@@ -152,7 +154,7 @@ class ExportMasterView(MasterView):
         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)
@@ -169,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):
@@ -189,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/features.py b/tailbone/views/features.py
index d55be524..d9417452 100644
--- a/tailbone/views/features.py
+++ b/tailbone/views/features.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,6 @@
 Feature views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 import colander
 import markdown
 
@@ -49,27 +46,23 @@ class GenerateFeatureView(View):
         return handler
 
     def __call__(self):
-        use_buefy = self.get_use_buefy()
-
         schema = self.handler.make_schema()
-        app_form = forms.Form(schema=schema, request=self.request,
-                              use_buefy=use_buefy)
-        for key, value in six.iteritems(self.handler.get_defaults()):
+        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,
-                              use_buefy=use_buefy)
-            for key, value in six.iteritems(feature.get_defaults()):
+            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(newstyle=True):
+            if app_form.validate():
 
                 feature_type = self.request.POST['feature_type']
                 feature = self.handler.get_feature(feature_type)
@@ -77,7 +70,7 @@ class GenerateFeatureView(View):
                     raise ValueError("Unknown feature type: {}".format(feature_type))
 
                 feature_form = feature_forms[feature.feature_key]
-                if feature_form.validate(newstyle=True):
+                if feature_form.validate():
                     context = dict(app_form.validated)
                     context.update(feature_form.validated)
                     result = self.handler.do_generate(feature, **context)
@@ -86,7 +79,6 @@ class GenerateFeatureView(View):
         context = {
             'index_title': "Generate Feature",
             'handler': self.handler,
-            'use_buefy': use_buefy,
             'app_form': app_form,
             'feature_type': feature_type,
             'feature_forms': feature_forms,
diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py
index 4d702c92..34211c30 100644
--- a/tailbone/views/handheld.py
+++ b/tailbone/views/handheld.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 (DEPRECATED) Views for handheld batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import warnings
 
 # nb. this is imported only for sake of legacy callers
@@ -35,5 +33,5 @@ from tailbone.views.batch.handheld import HandheldBatchView
 def includeme(config):
     warnings.warn("tailbone.views.handheld is a deprecated module; "
                   "please use tailbone.views.batch.handheld instead",
-                  DeprecationWarning)
+                  DeprecationWarning, stacklevel=2)
     config.include('tailbone.views.batch.handheld')
diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py
index 003d7ac4..48b32cc2 100644
--- a/tailbone/views/importing.py
+++ b/tailbone/views/importing.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,20 +24,16 @@
 View for running arbitrary import/export jobs
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import getpass
-import socket
-import sys
+import json
 import logging
+import socket
 import subprocess
+import sys
 import time
 
-import json
-import six
 import sqlalchemy as sa
 
-from rattail.exceptions import ConfigurationError
 from rattail.threads import Thread
 
 import colander
@@ -155,10 +151,15 @@ class ImportingView(MasterView):
         return data
 
     def configure_grid(self, g):
-        super(ImportingView, self).configure_grid(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):
         """
@@ -180,7 +181,7 @@ class ImportingView(MasterView):
         return ImportHandlerSchema()
 
     def make_form_kwargs(self, **kwargs):
-        kwargs = super(ImportingView, self).make_form_kwargs(**kwargs)
+        kwargs = super().make_form_kwargs(**kwargs)
 
         # nb. this is set as sort of a hack, to prevent SA model
         # inspection logic
@@ -189,7 +190,7 @@ class ImportingView(MasterView):
         return kwargs
 
     def configure_form(self, f):
-        super(ImportingView, self).configure_form(f)
+        super().configure_form(f)
 
         f.set_renderer('models', self.render_models)
 
@@ -201,7 +202,7 @@ class ImportingView(MasterView):
         return HTML.tag('ul', c=items)
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(ImportingView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         handler_info = kwargs['instance']
         kwargs['handler'] = handler_info['_handler']
         return kwargs
@@ -222,7 +223,7 @@ class ImportingView(MasterView):
                 try:
                     return self.do_runjob(handler_info, form)
                 except Exception as error:
-                    self.request.session.flash(six.text_type(error), 'error')
+                    self.request.session.flash(str(error), 'error')
                     return self.redirect(self.request.current_route_url())
 
         return self.render_to_response('runjob', {
@@ -274,7 +275,6 @@ class ImportingView(MasterView):
         handler = handler_info['_handler']
         defaults = {
             'request': self.request,
-            'use_buefy': self.get_use_buefy(),
             'model_instance': handler,
             'cancel_url': self.request.route_url('{}.view'.format(route_prefix),
                                                  key=handler.get_key()),
@@ -299,15 +299,42 @@ class ImportingView(MasterView):
         f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys],
                                                      multiple=True,
                                                      size=len(keys)))
-        # f.set_default('models', keys)
 
-        f.set_default('create', True)
-        f.set_default('update', True)
-        f.set_default('delete', False)
+        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']
@@ -411,8 +438,7 @@ And here is the output:
             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()
@@ -431,22 +457,7 @@ And here is the output:
         return HTML.tag('div', class_='tailbone-markdown', c=[notes])
 
     def get_cmd_for_handler(self, handler, ignore_errors=False):
-        handler_key = handler.get_key()
-
-        cmd = self.rattail_config.getlist('rattail.importing',
-                                          '{}.cmd'.format(handler_key))
-        if not cmd or len(cmd) != 2:
-            cmd = self.rattail_config.getlist('rattail.importing',
-                                              '{}.default_cmd'.format(handler_key))
-
-            if not cmd or len(cmd) != 2:
-                msg = ("Missing or invalid config; please set '{}.default_cmd' in the "
-                       "[rattail.importing] section of your config file".format(handler_key))
-                if ignore_errors:
-                    return
-                raise ConfigurationError(msg)
-
-        return cmd
+        return handler.get_cmd(ignore_errors=ignore_errors)
 
     def get_runas_for_handler(self, handler):
         handler_key = handler.get_key()
@@ -627,11 +638,14 @@ class ImportHandlerSchema(colander.MappingSchema):
 
 class RunJobSchema(colander.MappingSchema):
 
-    handler_spec = colander.SchemaNode(colander.String())
+    handler_spec = colander.SchemaNode(colander.String(),
+                                       missing=colander.null)
     
-    host_title = colander.SchemaNode(colander.String())
+    host_title = colander.SchemaNode(colander.String(),
+                                       missing=colander.null)
 
-    local_title = colander.SchemaNode(colander.String())
+    local_title = colander.SchemaNode(colander.String(),
+                                       missing=colander.null)
 
     models = colander.SchemaNode(colander.List())
 
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 c392e510..fa878448 100644
--- a/tailbone/views/labels/profiles.py
+++ b/tailbone/views/labels/profiles.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
@@ -80,6 +78,13 @@ class LabelProfileView(MasterView):
         # 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)
 
@@ -93,18 +98,16 @@ class LabelProfileView(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?
diff --git a/tailbone/views/luigi.py b/tailbone/views/luigi.py
index aaa7e2be..568183ad 100644
--- a/tailbone/views/luigi.py
+++ b/tailbone/views/luigi.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,12 @@
 Views for Luigi
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import json
 import logging
 import os
 import re
 import shlex
 
-import six
 import sqlalchemy as sa
 
 from rattail.util import simple_error
@@ -62,13 +59,25 @@ class LuigiTaskView(MasterView):
     def __init__(self, request, context=None):
         super(LuigiTaskView, self).__init__(request, context=context)
         app = self.get_rattail_app()
-        self.luigi_handler = app.get_luigi_handler()
+
+        # 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', {
-            'use_buefy': self.get_use_buefy(),
             'index_url': None,
             'luigi_url': luigi_url,
             'luigi_history_url': history_url,
@@ -108,7 +117,10 @@ class LuigiTaskView(MasterView):
         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)
+            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)
@@ -147,19 +159,25 @@ class LuigiTaskView(MasterView):
         return context
 
     def get_overnight_tasks(self):
-        tasks = self.luigi_handler.get_all_overnight_tasks()
+        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'] = six.text_type(task['last_date'])
+                task['last_date'] = str(task['last_date'])
         return tasks
 
     def get_backfill_tasks(self):
-        tasks = self.luigi_handler.get_all_backfill_tasks()
+        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'] = six.text_type(task['last_date'])
+                task['last_date'] = str(task['last_date'])
             if task['target_date']:
-                task['target_date'] = six.text_type(task['target_date'])
+                task['target_date'] = str(task['target_date'])
         return tasks
 
     def configure_gather_settings(self, data):
@@ -202,7 +220,7 @@ class LuigiTaskView(MasterView):
                 {'name': 'rattail.luigi.backfill.task.{}.notes'.format(key),
                  'value': task['notes']},
                 {'name': 'rattail.luigi.backfill.task.{}.target_date'.format(key),
-                 'value': six.text_type(task['target_date'])},
+                 'value': str(task['target_date'])},
             ])
         if keys:
             settings.append({'name': 'rattail.luigi.backfill.tasks',
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index af776d97..21a5e58f 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,38 +24,34 @@
 Model Master View
 """
 
-from __future__ import unicode_literals, absolute_import
-
+import io
 import os
 import csv
 import datetime
 import getpass
 import shutil
-import tempfile
 import logging
+from collections import OrderedDict
 
 import json
-import six
 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
 
@@ -107,6 +103,7 @@ class MasterView(View):
     set_deletable = False
     supports_autocomplete = False
     supports_set_enabled_toggle = False
+    supports_grid_totals = False
     populatable = False
     mergeable = False
     merge_handler = None
@@ -116,9 +113,11 @@ class MasterView(View):
     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
@@ -139,6 +138,7 @@ class MasterView(View):
     deleting = False
     executing = False
     cloning = False
+    configuring = False
     has_pk_fields = False
     has_image = False
     has_thumbnail = False
@@ -162,6 +162,8 @@ class MasterView(View):
 
     labels = {'uuid': "UUID"}
 
+    customer_key_fields = {}
+    member_key_fields = {}
     product_key_fields = {}
 
     # ROW-RELATED ATTRS FOLLOW:
@@ -217,7 +219,8 @@ class MasterView(View):
         to the current thread (one per request), this method should instead
         return e.g. a new independent ``rattail.db.Session`` instance.
         """
-        return RattailSession()
+        app = self.get_rattail_app()
+        return app.make_session()
 
     @classmethod
     def get_grid_factory(cls):
@@ -251,7 +254,7 @@ class MasterView(View):
 
     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):
@@ -259,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'):
@@ -266,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):
@@ -328,31 +323,36 @@ 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,
         }
 
@@ -383,7 +383,7 @@ class MasterView(View):
         grid contents etc.
         """
 
-    def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
+    def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs):
         """
         Creates a new grid instance
         """
@@ -392,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
@@ -411,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):
         """
@@ -444,21 +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.
@@ -471,17 +503,19 @@ class MasterView(View):
                 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))
@@ -493,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.
         """
@@ -510,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
@@ -531,42 +589,45 @@ class MasterView(View):
             'filterable': self.rows_filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.rows_sortable,
-            'pageable': self.rows_pageable,
+            '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.rows_default_pagesize:
-            defaults['default_pagesize'] = self.rows_default_pagesize
+            defaults['pagesize'] = self.rows_default_pagesize
 
-        if self.has_rows and 'main_actions' not in defaults:
+        if self.has_rows and 'actions' not in defaults:
             actions = []
-            use_buefy = self.get_use_buefy()
 
             # view action
             if self.rows_viewable:
-                icon = 'eye' if use_buefy else 'zoomin'
-                actions.append(self.make_action('view', icon=icon, url=self.row_view_action_url))
+                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):
@@ -592,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
@@ -615,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
@@ -666,7 +725,7 @@ class MasterView(View):
         return self.render_to_response(template, context)
 
     def make_create_form(self):
-        return self.make_form(self.get_model_class())
+        return self.make_form()
 
     def save_create_form(self, form):
         uploads = self.normalize_uploads(form)
@@ -680,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):
@@ -710,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']
@@ -746,7 +819,7 @@ class MasterView(View):
         value = getattr(obj, field)
         if value is None:
             return ""
-        value = six.text_type(value)
+        value = str(value)
         if len(value) > 100:
             value = value[:100] + '...'
         return value
@@ -812,11 +885,34 @@ class MasterView(View):
             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)
 
@@ -824,7 +920,7 @@ class MasterView(View):
         pending = getattr(obj, field)
         if not pending:
             return
-        text = six.text_type(pending)
+        text = str(pending)
         url = self.request.route_url('pending_products.view', uuid=pending.uuid)
         return tags.link_to(text, url,
                             class_='has-background-warning')
@@ -837,10 +933,25 @@ class MasterView(View):
         if short:
             text = "({}) {}".format(short, vendor.name)
         else:
-            text = six.text_type(vendor)
+            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:
@@ -893,7 +1004,7 @@ 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)
 
@@ -901,7 +1012,7 @@ 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_profile', uuid=person.uuid)
         return tags.link_to(text, url)
 
@@ -909,7 +1020,7 @@ class MasterView(View):
         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)
 
@@ -925,14 +1036,30 @@ 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)
@@ -947,6 +1074,24 @@ class MasterView(View):
 
         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
 
@@ -990,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:
@@ -1029,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)
@@ -1042,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,
@@ -1069,12 +1212,14 @@ class MasterView(View):
             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)
 
@@ -1181,18 +1326,11 @@ class MasterView(View):
         instance = self.get_instance()
         instance_title = self.get_instance_title(instance)
         grid = self.make_version_grid(instance=instance)
-        use_buefy = self.get_use_buefy()
 
         # 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,
@@ -1211,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.
         """
@@ -1219,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 = []
@@ -1238,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()
@@ -1269,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,
@@ -1290,6 +1557,15 @@ class MasterView(View):
         })
 
     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()
 
@@ -1397,7 +1673,7 @@ class MasterView(View):
         pass
 
     def validate_quick_row_form(self, form):
-        return form.validate(newstyle=True)
+        return form.validate()
 
     def make_default_row_grid_tools(self, obj):
         if self.rows_creatable:
@@ -1431,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):
@@ -1514,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):
@@ -1559,6 +1824,26 @@ class MasterView(View):
         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.
@@ -1608,7 +1893,7 @@ class MasterView(View):
         View for deleting an existing model record.
         """
         if not self.deletable:
-            raise httpexceptions.HTTPForbidden()
+            raise self.forbidden()
 
         self.deleting = True
         instance = self.get_instance()
@@ -1620,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':
@@ -1662,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()
 
@@ -1712,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)
@@ -1762,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)
@@ -1803,7 +2104,10 @@ class MasterView(View):
 
         # caller must explicitly request websocket behavior; otherwise
         # we will assume traditional behavior for progress
-        ws = self.request.is_xhr and self.request.json_body.get('ws')
+        ws = False
+        if ((self.request.is_xhr or self.request.content_type == 'application/json')
+            and self.request.json_body.get('ws')):
+            ws = True
 
         # make our progress tracker
         progress = self.make_execute_progress(obj, ws=ws)
@@ -1825,6 +2129,7 @@ class MasterView(View):
         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)
@@ -1840,16 +2145,18 @@ class MasterView(View):
         model_key = self.get_model_key(as_tuple=True)
         if len(model_key) == 1 and model_key[0] == 'uuid':
             uuid = key[0]
-            return session.query(self.model_class).get(uuid)
+            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()
+        app = self.get_rattail_app()
+        model = self.app.model
+        session = app.make_session()
         obj = self.get_instance_for_key(key, session)
-        user = session.query(model.User).get(user_uuid)
+        user = session.get(model.User, user_uuid)
         try:
             success_msg = self.execute_instance(obj, user,
                                                 progress=progress,
@@ -1940,8 +2247,7 @@ class MasterView(View):
 
                 # strip suffix, interpret data as JSON
                 data = data[:-len(suffix)]
-                if six.PY3:
-                    data = data.decode('utf_8')
+                data = data.decode('utf_8')
                 data = json.loads(data)
 
                 if data.get('everything_complete'):
@@ -1998,40 +2304,62 @@ class MasterView(View):
 
         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):
         """
@@ -2048,7 +2376,8 @@ class MasterView(View):
         if self.merge_handler:
             return self.merge_handler.get_merge_preview_data(obj)
 
-        raise NotImplementedError("please implement `{}.get_merge_data()`".format(self.__class__.__name__))
+        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)
@@ -2112,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())
@@ -2199,9 +2548,12 @@ class MasterView(View):
         """
         Returns the master view's index URL.
         """
-        route = self.get_route_prefix()
-        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):
         """
@@ -2240,6 +2592,17 @@ 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
 
@@ -2248,6 +2611,96 @@ class MasterView(View):
 
         return global_help_url(self.rattail_config)
 
+    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.
@@ -2258,7 +2711,6 @@ class MasterView(View):
         """
         context = {
             'master': self,
-            'use_buefy': self.get_use_buefy(),
             'model_title': self.get_model_title(),
             'model_title_plural': self.get_model_title_plural(),
             'route_prefix': self.get_route_prefix(),
@@ -2269,13 +2721,21 @@ class MasterView(View):
             '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,
         }
 
-        key = self.rattail_config.product_key()
-        context['product_key_field'] = self.product_key_fields.get(key, key)
+        context['customer_key_field'] = self.get_customer_key_field()
+        context['customer_key_label'] = self.get_customer_key_label()
 
-        if self.expose_quickie_search:
+        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:
@@ -2290,8 +2750,15 @@ class MasterView(View):
 
         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))
+
+        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.
         mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template)
@@ -2389,15 +2856,28 @@ class MasterView(View):
                     # 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_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):
@@ -2472,6 +2952,81 @@ class MasterView(View):
 
         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.
@@ -2494,8 +3049,148 @@ class MasterView(View):
         """
         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.
@@ -2550,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, []
@@ -2591,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())
@@ -2617,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()),
@@ -2648,33 +3359,74 @@ class MasterView(View):
 
     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))
         if not factory:
             factory = grids.GridAction
-        return factory(key, url=url, **kwargs)
+        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:
             try:
-                if isinstance(self.model_key, six.string_types):
-                    return {self.model_key: row[self.model_key]}
-                return dict([(key, row[key])
+                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(row, self.model_key)}
+                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):
@@ -2707,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
@@ -2878,12 +3634,8 @@ class MasterView(View):
         """
         if fmt == 'csv':
 
-            if six.PY2:
-                csv_file = open(path, 'wb')
-                writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8')
-            else: # PY3
-                csv_file = open(path, 'wt', encoding='utf_8')
-                writer = csv.DictWriter(csv_file, fields)
+            csv_file = open(path, 'wt', encoding='utf_8')
+            writer = csv.DictWriter(csv_file, fields)
             writer.writeheader()
 
             def write(obj, i):
@@ -2947,14 +3699,14 @@ class MasterView(View):
         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 = localtime(self.rattail_config, value,
-                                  from_utc=not self.has_local_times)
+                value = app.localtime(value, from_utc=not self.has_local_times)
 
             data[field] = value
 
@@ -2973,7 +3725,7 @@ class MasterView(View):
             if value is None:
                 value = ''
             else:
-                value = six.text_type(value)
+                value = str(value)
 
             csvrow[field] = value
 
@@ -2984,13 +3736,14 @@ class MasterView(View):
         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 = localtime(self.rattail_config, value, tzinfo=False)
+                value = app.localtime(value, tzinfo=False)
 
             data[key] = value
 
@@ -3041,13 +3794,8 @@ class MasterView(View):
             results = results.with_session(session).all()
             fields = self.get_csv_fields()
 
-            if six.PY2:
-                csv_file = open(path, 'wb')
-                writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8')
-            else: # PY3
-                csv_file = open(path, 'wt', encoding='utf_8')
-                writer = csv.DictWriter(csv_file, fields)
-
+            csv_file = open(path, 'wt', encoding='utf_8')
+            writer = csv.DictWriter(csv_file, fields)
             writer.writeheader()
 
             def write(obj, i):
@@ -3407,12 +4155,8 @@ class MasterView(View):
 
         if fmt == 'csv':
 
-            if six.PY2:
-                csv_file = open(path, 'wb')
-                writer = UnicodeDictWriter(csv_file, fields, encoding='utf_8')
-            else: # PY3
-                csv_file = open(path, 'wt', encoding='utf_8')
-                writer = csv.DictWriter(csv_file, fields)
+            csv_file = open(path, 'wt', encoding='utf_8')
+            writer = csv.DictWriter(csv_file, fields)
             writer.writeheader()
 
             def write(obj, i):
@@ -3455,14 +4199,14 @@ class MasterView(View):
         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 = localtime(self.rattail_config, value,
-                                  from_utc=not self.has_local_times)
+                value = app.localtime(value, from_utc=not self.has_local_times)
 
             data[field] = value
 
@@ -3481,7 +4225,7 @@ class MasterView(View):
             if value is None:
                 value = ''
             else:
-                value = six.text_type(value)
+                value = str(value)
 
             csvrow[field] = value
 
@@ -3492,6 +4236,7 @@ class MasterView(View):
         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]
@@ -3502,7 +4247,7 @@ class MasterView(View):
 
             # make timestamps local, "zone-naive"
             elif isinstance(value, datetime.datetime):
-                value = localtime(self.rattail_config, value, tzinfo=False)
+                value = app.localtime(value, tzinfo=False)
 
             data[key] = value
 
@@ -3512,10 +4257,11 @@ class MasterView(View):
         """
         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()
 
@@ -3553,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
@@ -3581,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
@@ -3619,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
 
     ##############################
@@ -3704,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):
@@ -3810,32 +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
@@ -3861,6 +4635,9 @@ 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):
@@ -3928,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
 
@@ -4217,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
@@ -4261,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())
@@ -4290,31 +5071,87 @@ 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_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:
-            key = self.rattail_config.product_key()
-            field = self.product_key_fields.get(key, key)
+            field = self.get_product_key_field()
             g.replace('_product_key_', field)
-            g.set_label(field, self.rattail_config.product_key_title(key))
+            g.set_label(field, self.get_product_key_label())
             g.set_link(field)
-            if key == 'upc':
+            if field == 'upc':
                 g.set_renderer(field, self.render_upc)
 
     def configure_field_product_key(self, f):
         if '_product_key_' in f:
-            key = self.rattail_config.product_key()
-            field = self.product_key_fields.get(key, key)
+            field = self.get_product_key_field()
             f.replace('_product_key_', field)
-            f.set_label(field, self.rattail_config.product_key_title(key))
-            if key == 'upc':
+            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):
@@ -4338,6 +5175,56 @@ 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)
+
     ##############################
     # Configuration Views
     ##############################
@@ -4346,6 +5233,8 @@ class MasterView(View):
         """
         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()
@@ -4358,9 +5247,9 @@ class MasterView(View):
 
                 # collect any uploaded files
                 uploads = {}
-                for key, value in six.iteritems(data):
+                for key, value in data.items():
                     if isinstance(value, cgi_FieldStorage):
-                        tempdir = tempfile.mkdtemp()
+                        tempdir = app.make_temp_dir()
                         filename = os.path.basename(value.filename)
                         filepath = os.path.join(tempdir, filename)
                         with open(filepath, 'wb') as f:
@@ -4426,6 +5315,39 @@ class MasterView(View):
                     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
@@ -4470,7 +5392,8 @@ class MasterView(View):
                               simple['option'])
 
     def configure_get_context(self, simple_settings=None,
-                              input_file_templates=True):
+                              input_file_templates=True,
+                              output_file_templates=True):
         """
         Returns the full context dict, for rendering the configure
         page template.
@@ -4502,7 +5425,7 @@ class MasterView(View):
                 elif simple.get('type') is bool:
                     value = config.getbool(simple['section'],
                                            simple['option'],
-                                           default=False)
+                                           default=simple.get('default', False))
                 else:
                     value = config.get(simple['section'],
                                        simple['option'])
@@ -4519,7 +5442,7 @@ class MasterView(View):
             for template in self.normalize_input_file_templates(
                     include_file_options=True):
                 settings[template['setting_mode']] = template['mode']
-                settings[template['setting_file']] = template['file']
+                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']
@@ -4527,10 +5450,27 @@ class MasterView(View):
             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):
+                                  input_file_templates=True,
+                                  output_file_templates=True):
         settings = []
 
         # maybe collect "simple" settings
@@ -4543,11 +5483,13 @@ class MasterView(View):
                 value = data.get(name)
 
                 if simple.get('type') is bool:
-                    value = six.text_type(bool(value)).lower()
+                    value = str(bool(value)).lower()
                 elif simple.get('type') is int:
-                    value = six.text_type(int(value or '0'))
+                    value = str(int(value or '0'))
+                elif value is None:
+                    value = ''
                 else:
-                    value = six.text_type(value)
+                    value = str(value)
 
                 # only want to save this setting if we received a
                 # value, or if empty values are okay to save
@@ -4574,12 +5516,32 @@ class MasterView(View):
                 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):
+                                  input_file_templates=True,
+                                  output_file_templates=True):
         app = self.get_rattail_app()
-        model = self.model
+        model = self.app.model
         names = []
 
         if simple_settings is None:
@@ -4596,6 +5558,14 @@ class MasterView(View):
                     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
@@ -4674,8 +5644,27 @@ class MasterView(View):
                                                '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))
@@ -4683,8 +5672,6 @@ class MasterView(View):
             config.add_view(cls, attr='index', route_name=route_prefix,
                             permission='{}.list'.format(permission_prefix),
                             **kwargs)
-            config.add_tailbone_index_page(route_prefix, model_title_plural,
-                                           '{}.list'.format(permission_prefix))
 
             # download results
             # this is the "new" more flexible approach, but we only want to
@@ -4729,6 +5716,15 @@ class MasterView(View):
                 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,
@@ -4832,39 +5828,18 @@ class MasterView(View):
                             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)
-            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))
+            cls._defaults_view(config)
 
         # image
         if cls.has_image:
@@ -5009,3 +5984,228 @@ class MasterView(View):
                                  '{}/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))
+
+    @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 a0157649..46ed7e4b 100644
--- a/tailbone/views/members.py
+++ b/tailbone/views/members.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,21 +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):
     defaults(config)
diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py
index 37c2536c..b606e4e7 100644
--- a/tailbone/views/menus.py
+++ b/tailbone/views/menus.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,12 @@
 Base class for Config Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import json
 
 import sqlalchemy as sa
 
 from tailbone.views import View
 from tailbone.db import Session
-from tailbone.menus import make_menu_key
 
 
 class MenuConfigView(View):
@@ -62,9 +59,8 @@ class MenuConfigView(View):
 
         context = {
             'config_title': "Menus",
-            'use_buefy': True,
-            'index_title': "App Settings",
-            'index_url': self.request.route_url('appsettings'),
+            'index_title': "App Details",
+            'index_url': self.request.route_url('appinfo'),
         }
 
         possible_index_options = sorted(
@@ -82,12 +78,16 @@ class MenuConfigView(View):
         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 = make_menu_key(self.rattail_config, topitem['title'])
+            key = menus._make_menu_key(self.rattail_config, topitem['title'])
             main_keys.append(key)
 
             settings.extend([
@@ -102,7 +102,7 @@ class MenuConfigView(View):
                     if item.get('route'):
                         item_key = item['route']
                     else:
-                        item_key = make_menu_key(self.rattail_config, item['title'])
+                        item_key = menus._make_menu_key(self.rattail_config, item['title'])
                     item_keys.append(item_key)
 
                     settings.extend([
@@ -173,9 +173,7 @@ class MenuConfigView(View):
                          '/configure-menus')
         config.add_view(cls, attr='configure',
                         route_name='configure_menus',
-                        # nb. must be root to configure menus!  b/c
-                        # otherwise some route options may be hidden
-                        permission='admin',
+                        permission='appinfo.configure',
                         renderer='/configure-menus.mako')
         config.add_tailbone_config_page('configure_menus', "Menus", 'admin')
 
diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py
index f483d03b..9199c025 100644
--- a/tailbone/views/messages.py
+++ b/tailbone/views/messages.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,19 +24,13 @@
 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
@@ -52,6 +46,7 @@ class MessageView(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 MessageView(MasterView):
 
     def index(self):
         if not self.request.user:
-            raise httpexceptions.HTTPForbidden
-        return super(MessageView, self).index()
+            raise self.forbidden()
+        return super().index()
 
     def get_instance(self):
         if not self.request.user:
-            raise httpexceptions.HTTPForbidden
-        message = super(MessageView, 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 MessageView(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 MessageView(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 MessageView(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,23 +181,17 @@ class MessageView(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)
@@ -212,16 +206,10 @@ class MessageView(MasterView):
     #     return form
 
     def configure_form(self, f):
-        super(MessageView, 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 MessageView(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 MessageView(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,17 +263,11 @@ class MessageView(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)
-                    if use_buefy:
-                        f.set_default('set_recipients', value)
-                    else:
-                        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):
-                    if use_buefy:
-                        f.set_default('set_recipients', [old_message.sender.uuid])
-                    else:
-                        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.
@@ -299,14 +279,14 @@ class MessageView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        message = super(MessageView, 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)
 
@@ -346,11 +326,9 @@ class MessageView(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
@@ -358,9 +336,8 @@ class MessageView(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):
@@ -416,7 +393,7 @@ class MessageView(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'):
@@ -491,7 +468,7 @@ class InboxView(MessageView):
         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)
 
 
@@ -507,7 +484,7 @@ class ArchiveView(MessageView):
         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)
 
 
@@ -528,7 +505,7 @@ class SentView(MessageView):
                       .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)\
@@ -537,30 +514,16 @@ class SentView(MessageView):
                                                 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
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 6d517e3a..405b1ca3 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,26 +24,25 @@
 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.db.util import maxlen
-from rattail.time import localtime
-from rattail.util import OrderedDict, simple_error
+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.db import TrainwreckSession
 from tailbone.views import MasterView
+from tailbone.util import raw_datetime
 
 
 log = logging.getLogger(__name__)
@@ -53,15 +52,16 @@ 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
     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",
@@ -94,7 +94,7 @@ class PersonView(MasterView):
     mergeable = True
 
     def __init__(self, request):
-        super(PersonView, self).__init__(request)
+        super().__init__(request)
         app = self.get_rattail_app()
 
         # always get a reference to the People Handler
@@ -104,7 +104,7 @@ class PersonView(MasterView):
         self.handler = self.people_handler
 
     def make_grid_kwargs(self, **kwargs):
-        kwargs = super(PersonView, self).make_grid_kwargs(**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'):
@@ -113,21 +113,42 @@ class PersonView(MasterView):
         return kwargs
 
     def configure_grid(self, g):
-        super(PersonView, 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'
@@ -139,23 +160,39 @@ class PersonView(MasterView):
         g.set_filter('employee_status', model.Employee.status,
                      value_enum=self.enum.EMPLOYEE_STATUS)
 
-        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_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)\
@@ -165,25 +202,23 @@ class PersonView(MasterView):
                                     .filter(model.MergePeopleRequest.merged == None)\
                                     .first()
         if merge_request:
-            use_buefy = self.get_use_buefy()
-            if use_buefy:
-                return HTML.tag('span',
-                                class_='has-text-danger has-text-weight-bold',
-                                title="A merge has been requested for this person.",
-                                c="MR")
-            return "MR"
+            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:
@@ -201,17 +236,27 @@ class PersonView(MasterView):
             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(PersonView, self).objectify(form, data)
+        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:
@@ -247,7 +292,7 @@ class PersonView(MasterView):
             customer._people.reorder()
 
         # continue with normal logic
-        super(PersonView, self).delete_instance(person)
+        super().delete_instance(person)
 
     def touch_instance(self, person):
         """
@@ -256,8 +301,10 @@ class PersonView(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(PersonView, self).touch_instance(person)
+        super().touch_instance(person)
 
         def touch(obj):
             change = model.Change()
@@ -279,7 +326,7 @@ class PersonView(MasterView):
             touch(address)
 
     def configure_common_form(self, f):
-        super(PersonView, self).configure_common_form(f)
+        super().configure_common_form(f)
         person = f.model_instance
 
         f.set_label('display_name', "Full Name")
@@ -335,24 +382,28 @@ class PersonView(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)
             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):
@@ -361,7 +412,7 @@ class PersonView(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:
@@ -371,7 +422,6 @@ class PersonView(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:
@@ -381,15 +431,13 @@ class PersonView(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'),
@@ -399,36 +447,205 @@ class PersonView(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),
             'phone_type_options': self.get_phone_type_options(),
             'email_type_options': self.get_email_type_options(),
             'max_lengths': self.get_max_lengths(),
-            'customers_data': self.get_context_customers(person),
-            'members_data': self.get_context_members(person),
-            'employee': employee,
-            'employee_data': self.get_context_employee(employee) if employee else {},
-            '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),
-            'dynamic_content_title': self.get_context_content_title(person),
+            '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):
         """
@@ -436,26 +653,22 @@ class PersonView(MasterView):
         """
         return kwargs
 
-    def template_kwargs_view_profile_buefy(self, **kwargs):
-        """
-        Note that any subclass should not need to define this method.
-        It by default invokes :meth:`template_kwargs_view_profile()`
-        and returns that result.
-        """
-        return self.template_kwargs_view_profile(**kwargs)
-
     def get_max_lengths(self):
+        app = self.get_rattail_app()
         model = self.model
-        return {
-            'person_first_name': maxlen(model.Person.first_name),
-            'person_middle_name': maxlen(model.Person.middle_name),
-            'person_last_name': maxlen(model.Person.last_name),
-            'address_street': maxlen(model.PersonMailingAddress.street),
-            'address_street2': maxlen(model.PersonMailingAddress.street2),
-            'address_city': maxlen(model.PersonMailingAddress.city),
-            'address_state': maxlen(model.PersonMailingAddress.state),
-            'address_zipcode': maxlen(model.PersonMailingAddress.zipcode),
+        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):
         """
@@ -500,13 +713,53 @@ class PersonView(MasterView):
             '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 six.text_type(person)
+        return str(person)
 
     def get_context_address(self, address):
         context = {
@@ -516,7 +769,7 @@ class PersonView(MasterView):
             'city': address.city,
             'state': address.state,
             'zipcode': address.zipcode,
-            'display': six.text_type(address),
+            'display': str(address),
         }
 
         model = self.model
@@ -527,59 +780,120 @@ class PersonView(MasterView):
         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.
@@ -587,6 +901,12 @@ class PersonView(MasterView):
         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
 
@@ -596,11 +916,52 @@ class PersonView(MasterView):
             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
@@ -611,6 +972,19 @@ class PersonView(MasterView):
         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.
@@ -618,17 +992,19 @@ class PersonView(MasterView):
         person = self.get_instance()
         data = dict(self.request.json_body)
 
-        self.handler.update_names(person,
-                                  first=data['first_name'],
-                                  middle=data['middle_name'],
-                                  last=data['last_name'])
+        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 {
-            'success': True,
-            'person': self.get_context_person(person),
-            'dynamic_content_title': self.get_context_content_title(person),
-        }
+        return self.profile_changed_response(person)
 
     def get_context_phones(self, person):
         data = []
@@ -658,19 +1034,17 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        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.query(model.PersonPhoneNumber).get(data['phone_uuid'])
+        phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid'])
         if not phone:
             return {'error': "Phone not found."}
 
@@ -688,20 +1062,18 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        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.query(model.PersonPhoneNumber).get(data['phone_uuid'])
+        phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid'])
         if not phone:
             return {'error': "Phone not found."}
         if phone not in person.phones:
@@ -711,20 +1083,18 @@ class PersonView(MasterView):
         person.remove_phone(phone)
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        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.query(model.PersonPhoneNumber).get(data['phone_uuid'])
+        phone = self.Session.get(model.PersonPhoneNumber, data['phone_uuid'])
         if not phone:
             return {'error': "Phone not found."}
         if phone not in person.phones:
@@ -734,10 +1104,7 @@ class PersonView(MasterView):
         person.set_primary_phone(phone)
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def get_context_emails(self, person):
         data = []
@@ -773,19 +1140,17 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        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.query(model.PersonEmailAddress).get(data['email_uuid'])
+        email = self.Session.get(model.PersonEmailAddress, data['email_uuid'])
         if not email:
             return {'error': "Email not found."}
 
@@ -799,20 +1164,18 @@ class PersonView(MasterView):
             return {'error': simple_error(error)}
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        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.query(model.PersonEmailAddress).get(data['email_uuid'])
+        email = self.Session.get(model.PersonEmailAddress, data['email_uuid'])
         if not email:
             return {'error': "Email not found."}
         if email not in person.emails:
@@ -822,21 +1185,18 @@ class PersonView(MasterView):
         person.remove_email(email)
 
         self.Session.flush()
-
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        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.query(model.PersonEmailAddress).get(data['email_uuid'])
+        email = self.Session.get(model.PersonEmailAddress, data['email_uuid'])
         if not email:
             return {'error': "Email not found."}
         if email not in person.emails:
@@ -846,10 +1206,7 @@ class PersonView(MasterView):
         person.set_primary_email(email)
 
         self.Session.flush()
-        return {
-            'success': True,
-            'person': self.get_context_person(person),
-        }
+        return self.profile_changed_response(person)
 
     def profile_edit_address(self):
         """
@@ -863,9 +1220,66 @@ class PersonView(MasterView):
         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 {
-            'success': True,
-            'person': self.get_context_person(person),
+            '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):
@@ -885,16 +1299,7 @@ class PersonView(MasterView):
         employee = handler.begin_employment(person, start_date,
                                             employee_id=data['id'])
         self.Session.flush()
-        return self.profile_start_employee_result(employee, start_date)
-
-    def profile_start_employee_result(self, employee, start_date):
-        return {
-            'success': True,
-            'employee': self.get_context_employee(employee),
-            'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid),
-            'start_date': six.text_type(start_date),
-            'employee_history_data': self.get_context_employee_history(employee),
-        }
+        return self.profile_changed_response(person)
 
     def profile_end_employee(self):
         """
@@ -914,26 +1319,18 @@ class PersonView(MasterView):
         handler.end_employment(employee, end_date,
                                revoke_access=data.get('revoke_access'))
         self.Session.flush()
-        return self.profile_end_employee_result(employee, end_date)
-
-    def profile_end_employee_result(self, employee, end_date):
-        return {
-            'success': True,
-            'employee': self.get_context_employee(employee),
-            'employee_view_url': self.request.route_url('employees.view', uuid=employee.uuid),
-            'end_date': six.text_type(end_date),
-            'employee_history_data': self.get_context_employee_history(employee),
-        }
+        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.query(model.EmployeeHistory).get(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."}
 
@@ -949,14 +1346,7 @@ class PersonView(MasterView):
             history.end_date = end_date
 
         self.Session.flush()
-        current_history = employee.get_current_history()
-        return {
-            'success': True,
-            'employee': self.get_context_employee(employee),
-            'start_date': six.text_type(current_history.start_date),
-            'end_date': six.text_type(current_history.end_date or ''),
-            'employee_history_data': self.get_context_employee_history(employee),
-        }
+        return self.profile_changed_response(person)
 
     def profile_update_employee_id(self):
         """
@@ -970,32 +1360,269 @@ class PersonView(MasterView):
 
         data = self.request.json_body
         employee.id = data['employee_id']
-        self.Session.flush()
 
+        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 {
-            'success': True,
-            'employee': self.get_context_employee(employee),
+            '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']
@@ -1004,57 +1631,42 @@ class PersonView(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:
@@ -1078,6 +1690,29 @@ class PersonView(MasterView):
                                    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)
@@ -1109,6 +1744,14 @@ class PersonView(MasterView):
         config.add_view(cls, attr='view_profile', route_name='{}.view_profile'.format(route_prefix),
                         permission='{}.view_profile'.format(permission_prefix))
 
+        # 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')
+
         # profile - edit personal details
         config.add_tailbone_permission('people_profile',
                                        'people_profile.edit_person',
@@ -1204,6 +1847,38 @@ class PersonView(MasterView):
                         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')
@@ -1231,32 +1906,87 @@ class PersonView(MasterView):
                         renderer='json',
                         permission='employees.edit')
 
-        # manage notes from profile view
-        if cls.manage_notes_from_profile_view:
+        # 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')
 
-            # 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 - 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')
 
-            # 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 - 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')
 
-            # 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 - 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),
@@ -1278,7 +2008,7 @@ 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
@@ -1304,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,
@@ -1325,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')
@@ -1344,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:
@@ -1369,7 +2100,7 @@ class MergePeopleRequestView(MasterView):
     """
     Master view for the MergePeopleRequest class.
     """
-    model_class = model.MergePeopleRequest
+    model_class = MergePeopleRequest
     route_prefix = 'people_merge_requests'
     url_prefix = '/people/merge-requests'
     creatable = False
@@ -1399,7 +2130,7 @@ class MergePeopleRequestView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(MergePeopleRequestView, self).configure_grid(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)
@@ -1413,31 +2144,33 @@ class MergePeopleRequestView(MasterView):
         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.query(self.model.Person).get(uuid)
+        person = self.Session.get(model.Person, uuid)
         if person:
-            return six.text_type(person)
+            return str(person)
         return "(person not found)"
 
     def get_instance_title(self, merge_request):
         model = self.model
-        removing = self.Session.query(model.Person).get(merge_request.removing_uuid)
-        keeping = self.Session.query(model.Person).get(merge_request.keeping_uuid)
+        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(MergePeopleRequestView, self).configure_form(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.query(self.model.Person).get(uuid)
+        person = self.Session.get(model.Person, uuid)
         if person:
-            text = six.text_type(person)
+            text = str(person)
             url = self.request.route_url('people.view', uuid=person.uuid)
             return tags.link_to(text, url)
         return "(person not found)"
@@ -1457,4 +2190,8 @@ def defaults(config, **kwargs):
 
 
 def includeme(config):
-    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.people')
+    else:
+        defaults(config)
diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py
index 43ba211d..ded80b18 100644
--- a/tailbone/views/poser/reports.py
+++ b/tailbone/views/poser/reports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,8 @@
 Poser Report Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import os
 
-import six
-
 from rattail.util import simple_error
 
 import colander
@@ -95,7 +91,7 @@ class PoserReportView(PoserMasterView):
         return self.poser_handler.get_all_reports(ignore_errors=False)
 
     def configure_grid(self, g):
-        super(PoserReportView, self).configure_grid(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)
@@ -114,7 +110,7 @@ class PoserReportView(PoserMasterView):
         g.set_searchable('description')
 
         if self.request.has_perm('report_output.create'):
-            g.more_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'generate', icon='arrow-circle-right',
                 url=self.get_generate_url))
 
@@ -157,7 +153,7 @@ class PoserReportView(PoserMasterView):
         return report
 
     def configure_form(self, f):
-        super(PoserReportView, self).configure_form(f)
+        super().configure_form(f)
         report = f.model_instance
 
         # report_key
@@ -179,7 +175,7 @@ class PoserReportView(PoserMasterView):
             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 six.iteritems(flavors)]
+                      for key, flavor in flavors.items()]
             f.set_widget('flavor', dfwidget.SelectWidget(values=values))
             f.set_validator('flavor', colander.OneOf(flavors))
             if flavors:
@@ -231,7 +227,7 @@ class PoserReportView(PoserMasterView):
                     return report
 
     def configure_row_grid(self, g):
-        super(PoserReportView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
 
         g.set_renderer('id', self.render_id_str)
 
diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py
index 14c97a61..27efd549 100644
--- a/tailbone/views/poser/views.py
+++ b/tailbone/views/poser/views.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Poser Views for Views...
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 import colander
 
 from .master import PoserMasterView
@@ -68,7 +64,7 @@ class PoserViewView(PoserMasterView):
         return self.make_form({})
 
     def configure_form(self, f):
-        super(PoserViewView, self).configure_form(f)
+        super().configure_form(f)
         view = f.model_instance
 
         # key
@@ -224,28 +220,28 @@ class PoserViewView(PoserMasterView):
             },
         }}
 
-        for key, views in six.iteritems(everything['rattail']):
-            for vkey, view in six.iteritems(views):
+        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 six.itervalues(providers):
+        for provider in providers.values():
 
             # loop thru provider top-level groups
-            for topkey, groups in six.iteritems(provider.get_provided_views()):
+            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 six.iteritems(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 six.iteritems(views):
+                        for vkey, view in views.items():
                             view['options'] = [vkey]
 
                     else: # otherwise must "update" existing group
@@ -254,7 +250,7 @@ class PoserViewView(PoserMasterView):
                         stdgroup = topgroup[key]
 
                         # loop thru views within provider group
-                        for vkey, view in six.iteritems(views):
+                        for vkey, view in views.items():
 
                             # add view to group if it's new
                             if vkey not in stdgroup:
@@ -270,8 +266,8 @@ class PoserViewView(PoserMasterView):
         settings = []
 
         view_settings = self.collect_available_view_settings()
-        for topgroup in six.itervalues(view_settings):
-            for view_section, section_settings in six.iteritems(topgroup):
+        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})
@@ -282,25 +278,25 @@ class PoserViewView(PoserMasterView):
                               input_file_templates=True):
 
         # first get normal context
-        context = super(PoserViewView, self).configure_get_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 six.itervalues(view_settings):
-            for key, views in six.iteritems(topgroup):
-                for vkey, view in six.iteritems(views):
+        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 six.iteritems(view_settings):
+        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 six.iteritems(settings)]
+                            for key, setting in settings.items()]
                 settings.sort(key=lambda itm: itm[1])
                 topgroup[key] = settings
         context['view_settings'] = view_settings
@@ -308,7 +304,7 @@ class PoserViewView(PoserMasterView):
         return context
 
     def configure_flash_settings_saved(self):
-        super(PoserViewView, self).configure_flash_settings_saved()
+        super().configure_flash_settings_saved()
         self.request.session.flash("Please restart the web app!", 'warning')
 
 
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index 0012adc8..3986f8b0 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,14 +24,11 @@
 "Principal" master view
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import copy
+from collections import OrderedDict
 
 from rattail.core import Object
-from rattail.util import OrderedDict
 
-import wtforms
 from webhelpers2.html import HTML
 
 from tailbone.db import Session
@@ -46,7 +43,7 @@ class PrincipalMasterView(MasterView):
     def get_fallback_templates(self, template, **kwargs):
         return [
             '/principal/{}.mako'.format(template),
-        ] + super(PrincipalMasterView, self).get_fallback_templates(template, **kwargs)
+        ] + super().get_fallback_templates(template, **kwargs)
 
     def perm_sortkey(self, item):
         key, value = item
@@ -56,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:
 
@@ -113,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)
@@ -126,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))
@@ -154,7 +194,7 @@ class PermissionsRenderer(Object):
             rendered = False
             for key in sorted(perms, key=lambda p: perms[p]['label'].lower()):
                 checked = auth.has_permission(Session(), principal, key,
-                                              include_guest=self.include_guest,
+                                              include_anonymous=self.include_guest,
                                               include_authenticated=self.include_authenticated)
                 if checked:
                     label = perms[key]['label']
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index ab9f55c6..8461ae03 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,28 +24,24 @@
 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, simple_error
-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
@@ -79,16 +75,20 @@ class ProductView(MasterView):
     """
     Master view for the Product class.
     """
-    model_class = model.Product
+    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",
@@ -161,21 +161,8 @@ class ProductView(MasterView):
         'inventory_on_order',
     ]
 
-    # 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)
-    SalePrice = orm.aliased(model.ProductPrice)
-    TPRPrice = orm.aliased(model.ProductPrice)
-
     def __init__(self, request):
-        super(ProductView, self).__init__(request)
+        super().__init__(request)
         self.expose_label_printing = self.rattail_config.getbool(
             'tailbone', 'products.print_labels', default=False)
 
@@ -187,29 +174,12 @@ class ProductView(MasterView):
         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):
@@ -225,23 +195,9 @@ class ProductView(MasterView):
                            .all()
 
     def configure_grid(self, g):
-        super(ProductView, self).configure_grid(g)
+        super().configure_grid(g)
         app = self.get_rattail_app()
         model = self.model
-        use_buefy = self.get_use_buefy()
-
-        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)
 
         ProductCostCode = orm.aliased(model.ProductCost)
         ProductCostCodeAny = orm.aliased(model.ProductCost)
@@ -257,15 +213,17 @@ class ProductView(MasterView):
                                ProductCostCodeAny.product_uuid == model.Product.uuid)
 
         # product key
-        key = self.rattail_config.product_key()
-        field = self.product_key_fields.get(key, 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)
 
         # brand
-        g.joiners['brand'] = lambda q: q.outerjoin(model.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))
@@ -273,23 +231,43 @@ class ProductView(MasterView):
         departments = self.get_departments()
         department_choices = OrderedDict([('', "(any)")]
                                          + [(d.uuid, d.name) for d in departments])
-        if not use_buefy:
-            department_choices = [tags.Option(name, uuid)
-                                  for uuid, name in six.iteritems(department_choices)]
         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')
 
-        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
+        # 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.sorters['brand'] = g.make_sorter(model.Brand.name)
-        g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name)
-        g.sorters['vendor'] = g.make_sorter(model.Vendor.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)
@@ -307,22 +285,19 @@ class ProductView(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['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['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.joiners['vendor_code_any'] = join_vendor_code_any
         # g.filters['vendor_code_any'] = g.make_filter('vendor_code_any', ProductCostCodeAny.code)
@@ -353,28 +328,34 @@ class ProductView(MasterView):
         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(
-            self.TPRPrice, self.TPRPrice.uuid == model.Product.tpr_price_uuid))
-        g.set_filter('tpr_price', self.TPRPrice.price)
+            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(
-            self.SalePrice, self.SalePrice.uuid == model.Product.sale_price_uuid))
-        g.set_filter('sale_price', self.SalePrice.price)
+            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)
@@ -389,17 +370,23 @@ class ProductView(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)
 
         if self.expose_label_printing and self.has_perm('print_labels'):
-            if use_buefy:
-                g.more_actions.append(self.make_action(
-                    'print_label', icon='print', url='#',
-                    click_handler='quickLabelPrint(props.row)'))
-            else:
-                g.more_actions.append(grids.GridAction('print_label', icon='print'))
+            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('on_hand', self.render_on_hand)
@@ -408,12 +395,315 @@ class ProductView(MasterView):
         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', "Vendor (preferred)")
+    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(ProductView, 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
 
         # unit_size
@@ -421,6 +711,7 @@ class ProductView(MasterView):
 
         # 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
@@ -544,311 +835,6 @@ class ProductView(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, field):
-        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 = localtime(self.rattail_config, price.starts, from_utc=True)
-                starts = app.render_date(starts.date())
-            else:
-                starts = "??"
-
-            if price.ends:
-                ends = localtime(self.rattail_config, 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
-
-        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 not text:
-            return
-
-        if 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 ""
-        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 ""
-        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):
-        kwargs = super(ProductView, self).template_kwargs_index(**kwargs)
-        model = self.model
-
-        if self.expose_label_printing:
-
-            kwargs['label_profiles'] = self.Session.query(model.LabelProfile)\
-                                                   .filter(model.LabelProfile.visible == True)\
-                                                   .order_by(model.LabelProfile.ordinal)\
-                                                   .all()
-
-            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(ProductView, 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')
-        fields.append('true_margin')
-
-        return fields
-
-    def get_xlsx_row(self, product, fields):
-        row = super(ProductView, 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
-
-        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(ProductView, self).download_results_normalize(
-            product, fields, **kwargs)
-
-        if 'upc' in data:
-            if isinstance(data['upc'], GPC):
-                data['upc'] = six.text_type(data['upc'])
-
-        return data
-
-    def get_instance(self):
-        key = self.request.matchdict['uuid']
-        product = self.Session.query(model.Product).get(key)
-        if product:
-            return product
-        price = self.Session.query(model.ProductPrice).get(key)
-        if price:
-            return price.product
-        raise httpexceptions.HTTPNotFound()
-
-    def configure_form(self, f):
-        super(ProductView, self).configure_form(f)
-        product = f.model_instance
-
         # department
         if self.creating or self.editing:
             if 'department' in f.fields:
@@ -970,7 +956,7 @@ class ProductView(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')
@@ -985,11 +971,11 @@ class ProductView(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))
@@ -1015,7 +1001,7 @@ class ProductView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        product = super(ProductView, 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:
@@ -1023,6 +1009,14 @@ class ProductView(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:
@@ -1123,7 +1117,8 @@ class ProductView(MasterView):
         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:
@@ -1131,7 +1126,8 @@ class ProductView(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):
         """
@@ -1154,12 +1150,12 @@ class ProductView(MasterView):
             if price is not None:
                 history['price'] = float(price)
                 history['price_display'] = app.render_currency(price)
-            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 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
 
@@ -1167,6 +1163,7 @@ class ProductView(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)
 
@@ -1180,18 +1177,18 @@ class ProductView(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()
 
         kwargs['image_url'] = self.products_handler.get_image_url(product)
 
@@ -1199,12 +1196,10 @@ class ProductView(MasterView):
         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',
@@ -1216,12 +1211,10 @@ class ProductView(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',
@@ -1237,12 +1230,10 @@ class ProductView(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',
@@ -1254,12 +1245,10 @@ class ProductView(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',
@@ -1279,29 +1268,81 @@ class ProductView(MasterView):
         kwargs['costs_label_code'] = "Order Code"
         kwargs['costs_label_case_size'] = "Case Size"
 
-        if use_buefy:
-            kwargs['vendor_sources'] = self.get_context_vendor_sources(product)
-            kwargs['lookup_codes'] = self.get_context_lookup_codes(product)
+        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(
-            key='{}.vendor_sources'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.vendor_sources',
             data=[],
-            columns=[
-                'preferred',
-                'vendor',
-                'vendor_item_code',
-                'case_size',
-                'case_cost',
-                'unit_cost',
-                'status',
-            ],
+            columns=columns,
             labels={
                 'preferred': "Pref.",
                 'vendor_item_code': "Order Code",
@@ -1316,13 +1357,15 @@ class ProductView(MasterView):
                 'uuid': cost.uuid,
                 'preferred': "X" if cost.preference == 1 else None,
                 'vendor_item_code': cost.code,
-                'case_size': app.render_quantity(cost.case_size),
-                'case_cost': app.render_currency(cost.case_cost),
                 'unit_cost': app.render_currency(cost.unit_cost, scale=4),
                 'status': "discontinued" if cost.discontinued else "available",
             }
 
-            text = six.text_type(cost.vendor)
+            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)
@@ -1338,7 +1381,8 @@ class ProductView(MasterView):
 
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.lookup_codes'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.lookup_codes',
             data=[],
             columns=[
                 'sequence',
@@ -1365,10 +1409,12 @@ class ProductView(MasterView):
         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
@@ -1434,10 +1480,12 @@ class ProductView(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
@@ -1576,10 +1624,12 @@ class ProductView(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
@@ -1645,10 +1695,12 @@ class ProductView(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
@@ -1712,6 +1764,7 @@ class ProductView(MasterView):
                                                 'form': form})
 
     def get_version_child_classes(self):
+        model = self.model
         return [
             (model.ProductCode, 'product_uuid'),
             (model.ProductCost, 'product_uuid'),
@@ -1724,10 +1777,11 @@ class ProductView(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
 
@@ -1737,12 +1791,12 @@ class ProductView(MasterView):
         model = self.model
 
         profile = self.request.params.get('profile')
-        profile = self.Session.query(model.LabelProfile).get(profile) if profile else None
+        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.query(model.Product).get(product) if product else None
+        product = self.Session.get(model.Product, product) if product else None
         if not product:
             return {'error': "Product not found"}
 
@@ -1759,14 +1813,14 @@ class ProductView(MasterView):
             printer.print_labels([({'product': product}, quantity)])
         except Exception as error:
             log.warning("error occurred while printing labels", exc_info=True)
-            return {'error': six.text_type(error)}
+            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 Buefy
-        ``<b-table>`` component.
+        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
@@ -1803,7 +1857,8 @@ class ProductView(MasterView):
             lookup_fields.append('alt_code')
         if lookup_fields:
             product = self.products_handler.locate_product_for_entry(
-                session, term, lookup_fields=lookup_fields)
+                session, term, lookup_fields=lookup_fields,
+                first_if_multiple=True)
             if product:
                 final_results.append(self.search_normalize_result(product))
 
@@ -1858,9 +1913,11 @@ class ProductView(MasterView):
             '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.
@@ -1868,6 +1925,7 @@ class ProductView(MasterView):
         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)
@@ -1880,14 +1938,15 @@ class ProductView(MasterView):
             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 = self.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)
@@ -1922,10 +1981,11 @@ class ProductView(MasterView):
         """
         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())
@@ -1957,7 +2017,7 @@ class ProductView(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
 
@@ -1970,7 +2030,7 @@ class ProductView(MasterView):
                 # collect batch-type-specific params
                 pform = params_forms.get(batch_key)
                 if pform:
-                    if pform.validate(newstyle=True):
+                    if pform.validate():
                         pdata = pform.validated
                         for field in pform.schema:
                             param_name = pform.schema[field.name].param_name
@@ -2053,8 +2113,9 @@ class ProductView(MasterView):
         """
         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
         try:
@@ -2105,11 +2166,16 @@ class ProductView(MasterView):
             {'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',
@@ -2188,7 +2254,7 @@ class PendingProductView(MasterView):
     """
     Master view for the Pending Product class.
     """
-    model_class = model.PendingProduct
+    model_class = PendingProduct
     route_prefix = 'pending_products'
     url_prefix = '/products/pending'
     bulk_deletable = True
@@ -2212,6 +2278,7 @@ class PendingProductView(MasterView):
 
     form_fields = [
         '_product_key_',
+        'product',
         'brand_name',
         'brand',
         'description',
@@ -2220,33 +2287,76 @@ class PendingProductView(MasterView):
         '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(PendingProductView, self).configure_grid(g)
-
-        g.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
-        g.filters['status_code'].default_active = True
-        g.filters['status_code'].default_verb = 'not_equal'
-        g.filters['status_code'].default_value = six.text_type(self.enum.PENDING_PRODUCT_STATUS_RESOLVED)
-
-        g.set_sort_defaults('created', 'desc')
+        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(PendingProductView, self).configure_form(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:
@@ -2269,11 +2379,11 @@ class PendingProductView(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(pending.brand or '')
+                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))
@@ -2296,9 +2406,9 @@ class PendingProductView(MasterView):
                 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'])
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid'])
                         if vendor:
-                            vendor_display = six.text_type(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')))
@@ -2325,6 +2435,25 @@ class PendingProductView(MasterView):
         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')
@@ -2332,14 +2461,53 @@ class PendingProductView(MasterView):
             f.set_readonly('user')
             f.set_renderer('user', self.render_user)
 
-        # status_code
+        # resolved*
         if self.creating:
-            f.remove('status_code')
+            f.remove('resolved', 'resolved_by')
+        elif pending.resolved:
+            f.set_renderer('resolved_by', self.render_user)
         else:
-            # f.set_readonly('status_code')
-            f.set_enum('status_code', self.enum.PENDING_PRODUCT_STATUS)
+            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
@@ -2348,7 +2516,7 @@ class PendingProductView(MasterView):
         if data is None:
             data = form.validated
 
-        pending = super(PendingProductView, self).objectify(form, data)
+        pending = super().objectify(form, data)
 
         if not pending.user:
             pending.user = self.request.user
@@ -2395,16 +2563,77 @@ class PendingProductView(MasterView):
         redirect = self.redirect(self.get_action_url('view', pending))
 
         uuid = self.request.POST['product_uuid']
-        product = self.Session.query(model.Product).get(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()
-        products_handler.resolve_product(pending, product, self.request.user)
+        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)
@@ -2428,6 +2657,89 @@ class PendingProductView(MasterView):
                         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')
+
+
+class ProductCostView(MasterView):
+    """
+    Master view for Product Costs
+    """
+    model_class = ProductCost
+    route_prefix = 'product_costs'
+    url_prefix = '/products/costs'
+    has_versions = True
+
+    grid_columns = [
+        '_product_key_',
+        'vendor',
+        'preference',
+        'code',
+        'case_size',
+        'case_cost',
+        'pack_size',
+        'pack_cost',
+        'unit_cost',
+    ]
+
+    def query(self, session):
+        """ """
+        query = super().query(session)
+        model = self.app.model
+
+        # always join on Product
+        return query.join(model.Product)
+
+    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()
@@ -2438,6 +2750,9 @@ def defaults(config, **kwargs):
     PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
     PendingProductView.defaults(config)
 
+    ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
+    ProductCostView.defaults(config)
+
 
 def includeme(config):
     defaults(config)
diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py
index 169f324e..3f47ba3e 100644
--- a/tailbone/views/progress.py
+++ b/tailbone/views/progress.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Progress Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from tailbone.progress import get_progress_session
 
 
@@ -44,7 +40,7 @@ def progress(request):
 
         bits = session.get('extra_session_bits')
         if bits:
-            for key, value in six.iteritems(bits):
+            for key, value in bits.items():
                 request.session[key] = value
 
     elif session.get('error'):
diff --git a/tailbone/views/projects.py b/tailbone/views/projects.py
index 9a6633f4..bcc4cb5d 100644
--- a/tailbone/views/projects.py
+++ b/tailbone/views/projects.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,210 +24,428 @@
 Project views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import os
-import zipfile
-# from collections import OrderedDict
+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 View
+from tailbone.views import MasterView
 
 
-class GenerateProject(colander.MappingSchema):
-    """
-    Base schema for the "generate project" form
-    """
-    name = colander.SchemaNode(colander.String())
-
-    slug = colander.SchemaNode(colander.String())
-
-    organization = colander.SchemaNode(colander.String())
-
-    python_project_name = colander.SchemaNode(colander.String())
-
-    python_name = colander.SchemaNode(colander.String())
-
-    has_db = colander.SchemaNode(colander.Boolean())
-
-    extends_db = colander.SchemaNode(colander.Boolean())
-
-    has_batch_schema = colander.SchemaNode(colander.Boolean())
-
-    has_web = colander.SchemaNode(colander.Boolean())
-
-    has_web_api = colander.SchemaNode(colander.Boolean())
-
-    has_datasync = colander.SchemaNode(colander.Boolean())
-
-    # has_filemon = colander.SchemaNode(colander.Boolean())
-
-    # has_tempmon = colander.SchemaNode(colander.Boolean())
-
-    # has_bouncer = colander.SchemaNode(colander.Boolean())
-
-    integrates_catapult = colander.SchemaNode(colander.Boolean())
-
-    integrates_corepos = colander.SchemaNode(colander.Boolean())
-
-    # integrates_instacart = colander.SchemaNode(colander.Boolean())
-
-    integrates_locsms = colander.SchemaNode(colander.Boolean())
-
-    # integrates_mailchimp = colander.SchemaNode(colander.Boolean())
-
-    uses_fabric = colander.SchemaNode(colander.Boolean())
-
-
-class GenerateRattailIntegrationProject(colander.MappingSchema):
-    """
-    Schema to generate new rattail-integration project
-    """
-    integration_name = colander.SchemaNode(colander.String())
-
-    integration_url = colander.SchemaNode(colander.String())
-
-    slug = colander.SchemaNode(colander.String())
-
-    python_project_name = colander.SchemaNode(colander.String())
-
-    python_name = colander.SchemaNode(colander.String())
-
-    extends_config = colander.SchemaNode(colander.Boolean())
-
-    extends_db = colander.SchemaNode(colander.Boolean())
-
-
-class GenerateTailboneIntegrationProject(colander.MappingSchema):
-    """
-    Schema to generate new tailbone-integration project
-    """
-    integration_name = colander.SchemaNode(colander.String())
-
-    integration_url = colander.SchemaNode(colander.String())
-
-    slug = colander.SchemaNode(colander.String())
-
-    python_project_name = colander.SchemaNode(colander.String())
-
-    python_name = colander.SchemaNode(colander.String())
-
-
-class GenerateByjoveProject(colander.MappingSchema):
-    """
-    Schema for generating a new 'byjove' project
-    """
-    name = colander.SchemaNode(colander.String())
-
-    slug = colander.SchemaNode(colander.String())
-
-
-class GenerateFabricProject(colander.MappingSchema):
-    """
-    Schema for generating a new 'fabric' project
-    """
-    name = colander.SchemaNode(colander.String())
-
-    slug = colander.SchemaNode(colander.String())
-
-    organization = colander.SchemaNode(colander.String())
-
-    python_project_name = colander.SchemaNode(colander.String())
-
-    python_name = colander.SchemaNode(colander.String())
-
-    integrates_with = colander.SchemaNode(colander.String(),
-                                          missing=colander.null)
-
-
-class GenerateProjectView(View):
+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(GenerateProjectView, self).__init__(request)
-        self.project_handler = self.get_handler()
-        # TODO: deprecate / remove this
-        self.handler = self.project_handler
+        super(GeneratedProjectView, self).__init__(request)
+        self.project_handler = self.get_project_handler()
 
-    def get_handler(self):
-        from rattail.projects.handler import RattailProjectHandler
-        return RattailProjectHandler(self.rattail_config)
+    def get_project_handler(self):
+        app = self.get_rattail_app()
+        return app.get_project_handler()
 
-    def __call__(self):
-        use_buefy = self.get_use_buefy()
+    def create(self):
+        supported = self.project_handler.get_supported_project_generators()
+        supported_keys = list(supported)
 
-        # choices = OrderedDict([
-        #     ('has_db', {'prompt': "Does project need its own Rattail DB?",
-        #                 'type': 'bool'}),
-        # ])
+        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)
 
-        project_type = 'rattail'
-        if self.request.method == 'POST':
-            project_type = self.request.POST.get('project_type', 'rattail')
-        if project_type not in self.project_handler.get_supported_project_types():
-            raise ValueError("Unknown project type: {}".format(project_type))
+        else: # no project_type
 
-        if project_type == 'byjove':
-            schema = GenerateByjoveProject
-        elif project_type == 'fabric':
-            schema = GenerateFabricProject
-        elif project_type == 'rattail_integration':
-            schema = GenerateRattailIntegrationProject
-        elif project_type == 'tailbone_integration':
-            schema = GenerateTailboneIntegrationProject
-        else:
-            schema = GenerateProject
-        form = forms.Form(schema=schema(), request=self.request,
-                          use_buefy=use_buefy)
-        if form.validate(newstyle=True):
-            zipped = self.generate_project(project_type, form)
-            return self.file_response(zipped)
-            # self.request.session.flash("New project was generated: {}".format(form.validated['name']))
-            # return self.redirect(self.request.current_route_url())
+            # 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"
 
-        return {
+            # 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",
-            'handler': self.handler,
-            # 'choices': choices,
-            'use_buefy': use_buefy,
-        }
+            'project_type': project_type,
+            'form': form,
+        })
 
     def generate_project(self, project_type, form):
-        options = form.validated
-        slug = options['slug']
-        path = self.handler.generate_project(project_type, slug, options)
+        context = dict(form.validated)
+        output = self.project_handler.generate_project(project_type,
+                                                       context=context)
+        return self.project_handler.zip_output(output)
 
-        zipped = '{}.zip'.format(path)
-        with zipfile.ZipFile(zipped, 'w', zipfile.ZIP_DEFLATED) as z:
-            self.zipdir(z, path, slug)
-        return zipped
+    def make_project_form(self, project_type):
 
-    def zipdir(self, zipf, path, slug):
-        for root, dirs, files in os.walk(path):
-            relative_root = os.path.join(slug, root[len(path)+1:])
-            for fname in files:
-                zipf.write(os.path.join(root, fname),
-                           arcname=os.path.join(relative_root, fname))
+        # 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):
-        config.add_tailbone_permission('common', 'common.generate_project',
-                                       "Generate new project source code")
-        config.add_route('generate_project', '/generate-project')
-        config.add_view(cls, route_name='generate_project',
-                        permission='common.generate_project',
-                        renderer='/generate_project.mako')
+        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()
 
-    GenerateProjectView = kwargs.get('GenerateProjectView', base['GenerateProjectView'])
-    GenerateProjectView.defaults(config)
+    GeneratedProjectView = kwargs.get('GeneratedProjectView', base['GeneratedProjectView'])
+    GeneratedProjectView.defaults(config)
 
 
 def includeme(config):
diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py
index 77b02501..e7bebdff 100644
--- a/tailbone/views/purchases/core.py
+++ b/tailbone/views/purchases/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
@@ -143,28 +139,35 @@ 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)
@@ -198,7 +201,7 @@ class PurchaseView(MasterView):
         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)
@@ -322,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')
 
@@ -353,7 +356,7 @@ class PurchaseView(MasterView):
             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')
diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py
index 71902426..7da096eb 100644
--- a/tailbone/views/purchases/credits.py
+++ b/tailbone/views/purchases/credits.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,7 +173,9 @@ 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
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index ee460192..5e00704e 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,18 +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.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
 
 
@@ -44,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
@@ -72,6 +69,8 @@ class PurchasingBatchView(BatchMasterView):
         'store',
         'buyer',
         'vendor',
+        'description',
+        'workflow',
         'department',
         'purchase',
         'vendor_email',
@@ -163,21 +162,194 @@ class PurchasingBatchView(BatchMasterView):
     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.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person))
         g.set_filter('buyer', model.Person.display_name)
@@ -212,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:
@@ -225,22 +397,41 @@ class PurchasingBatchView(BatchMasterView):
             f.set_type('po_total_calculated', 'currency')
 
     def configure_form(self, f):
-        super(PurchasingBatchView, self).configure_form(f)
-        model = self.model
+        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
-        app = self.get_rattail_app()
-        today = app.localtime().date()
-        use_buefy = self.get_use_buefy()
+        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')
@@ -264,7 +455,6 @@ class PurchasingBatchView(BatchMasterView):
         if self.creating:
             f.replace('vendor', 'vendor_uuid')
             f.set_label('vendor_uuid', "Vendor")
-            vendor_handler = app.get_vendor_handler()
             use_dropdown = vendor_handler.choice_uses_dropdown()
             if use_dropdown:
                 vendors = self.Session.query(model.Vendor)\
@@ -276,12 +466,13 @@ class PurchasingBatchView(BatchMasterView):
                 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'])
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor_uuid'])
                         if vendor:
-                            vendor_display = six.text_type(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')
 
@@ -309,21 +500,45 @@ 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)
@@ -336,21 +551,17 @@ class PurchasingBatchView(BatchMasterView):
             kwargs = {}
 
             if 'vendor_uuid' in self.request.matchdict:
-                vendor = self.Session.query(model.Vendor).get(
-                    self.request.matchdict['vendor_uuid'])
+                vendor = self.Session.get(model.Vendor,
+                                          self.request.matchdict['vendor_uuid'])
                 if vendor:
                     kwargs['vendor'] = vendor
 
-            parsers = self.handler.get_supported_invoice_parsers(**kwargs)
+            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)
 
-            if use_buefy:
-                f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values))
-            else:
-                parser_values.insert(0, ('', "(please choose)"))
-                f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values))
+            f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values))
         else:
             f.remove_field('invoice_parser_key')
 
@@ -404,11 +615,34 @@ class PurchasingBatchView(BatchMasterView):
                             'vendor_contact',
                             'status_code')
 
-    def valid_vendor_uuid(self, node, value):
-        model = self.model
-        vendor = self.Session.query(model.Vendor).get(value)
-        if not vendor:
-            raise colander.Invalid(node, "Invalid vendor selection")
+        # 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
@@ -419,12 +653,30 @@ class PurchasingBatchView(BatchMasterView):
         return tags.link_to(text, url)
 
     def render_purchase(self, batch, field):
-        purchase = batch.purchase
+        model = self.model
+
+        # 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 ""
-        text = six.text_type(purchase)
-        url = self.request.route_url('purchases.view', uuid=purchase.uuid)
-        return tags.link_to(text, url)
+            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:
@@ -435,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)
@@ -455,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)
 
@@ -477,6 +731,7 @@ class PurchasingBatchView(BatchMasterView):
                 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)\
@@ -484,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]
 
@@ -501,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."}
-
-        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, **kwargs):
-        kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, **kwargs)
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
+        model = self.app.model
+
         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:
@@ -553,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:
@@ -579,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
 
@@ -611,7 +849,7 @@ class PurchasingBatchView(BatchMasterView):
 #         return query.options(orm.joinedload(model.PurchaseBatchRow.credits))
 
     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')
@@ -698,7 +936,7 @@ class PurchasingBatchView(BatchMasterView):
             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()
@@ -802,13 +1040,13 @@ class PurchasingBatchView(BatchMasterView):
             return app.render_cases_units(cases, units)
 
     def make_row_credits_grid(self, row):
-        use_buefy = self.get_use_buefy()
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
 
         g = factory(
-            key='{}.row_credits'.format(route_prefix),
-            data=[] if use_buefy else row.credits,
+            self.request,
+            key=f'{route_prefix}.row_credits',
+            data=[],
             columns=[
                 'credit_type',
                 'shorted',
@@ -837,36 +1075,9 @@ class PurchasingBatchView(BatchMasterView):
         return g
 
     def render_row_credits(self, row, field):
-        use_buefy = self.get_use_buefy()
-        if not use_buefy and not row.credits:
-            return
-
         g = self.make_row_credits_grid(row)
-
-        if use_buefy:
-            return HTML.literal(
-                g.render_buefy_table_element(data_prop='rowData.credits'))
-        else:
-            return HTML.literal(g.render_grid())
-
-#     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")
+        return HTML.literal(
+            g.render_table_element(data_prop='rowData.credits'))
 
 #     def before_create_row(self, form):
 #         row = form.fieldset.model
@@ -935,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)
@@ -959,27 +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()
 
-        # 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))
-
-
-    @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
index 2d62d6e1..ec4e3ee3 100644
--- a/tailbone/views/purchasing/costing.py
+++ b/tailbone/views/purchasing/costing.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Views for 'costing' (purchasing) batches
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 import colander
 from deform import widget as dfwidget
 
@@ -47,8 +43,6 @@ class CostingBatchView(PurchasingBatchView):
     downloadable = True
     bulk_deletable = True
 
-    purchase_order_fieldname = 'purchase'
-
     labels = {
         'invoice_parser_key': "Invoice Parser",
     }
@@ -188,14 +182,12 @@ class CostingBatchView(PurchasingBatchView):
 
         # okay, at this point we need the user to select a vendor and workflow
         self.creating = True
-        use_buefy = self.get_use_buefy()
         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,
-                          use_buefy=use_buefy)
+        form = forms.Form(schema=schema, request=self.request)
         if len(valid_workflows) == 1:
             form.set_default('workflow', valid_workflows[0])
 
@@ -208,17 +200,14 @@ class CostingBatchView(PurchasingBatchView):
                                   .order_by(model.Vendor.id)
             vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
                              for vendor in vendors]
-            if use_buefy:
-                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-            else:
-                form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values))
+            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.query(model.Vendor).get(self.request.POST['vendor'])
+                    vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
                     if vendor:
-                        vendor_display = six.text_type(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))
@@ -226,19 +215,15 @@ class CostingBatchView(PurchasingBatchView):
         # configure workflow field
         values = [(workflow['workflow_key'], workflow['display'])
                   for workflow in workflows]
-        if use_buefy:
-            form.set_widget('workflow',
-                            dfwidget.SelectWidget(values=values))
-        else:
-            form.set_widget('workflow',
-                            forms.widgets.JQuerySelectWidget(values=values))
+        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(newstyle=True):
+        if form.validate():
             workflow_key = form.validated['workflow']
             vendor_uuid = form.validated['vendor']
             url = self.request.route_url('{}.create_workflow'.format(route_prefix),
@@ -254,7 +239,6 @@ class CostingBatchView(PurchasingBatchView):
     def configure_form(self, f):
         super(CostingBatchView, self).configure_form(f)
         route_prefix = self.get_route_prefix()
-        use_buefy = self.get_use_buefy()
         model = self.model
         workflow = self.request.matchdict.get('workflow_key')
 
@@ -272,8 +256,8 @@ class CostingBatchView(PurchasingBatchView):
         if self.creating and workflow:
 
             # display vendor but do not allow changing
-            vendor = self.Session.query(model.Vendor).get(
-                self.request.matchdict['vendor_uuid'])
+            vendor = self.Session.get(model.Vendor,
+                                      self.request.matchdict['vendor_uuid'])
             assert vendor
 
             f.set_hidden('vendor_uuid')
@@ -304,17 +288,17 @@ class CostingBatchView(PurchasingBatchView):
             f.remove_field('batch_type')
 
         # purchase
+        field = self.batch_handler.get_purchase_order_fieldname()
         if (self.creating and workflow == 'invoice_with_po'
-            and self.purchase_order_fieldname == 'purchase'):
-            if use_buefy:
-                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')
+            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']
@@ -332,7 +316,6 @@ class CostingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._costing_defaults(config)
-        cls._purchasing_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index f4820783..c7cc7bfc 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
 
 
@@ -54,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView):
     rows_editable = 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",
@@ -62,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',
@@ -135,15 +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
         if self.creating or not batch.executed or not batch.purchase:
             f.remove_field('purchase')
 
+        # 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(OrderingBatchView, self).get_batch_kwargs(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
@@ -158,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:
@@ -244,7 +255,7 @@ class OrderingBatchView(PurchasingBatchView):
         assert not (batch.executed or batch.complete)
 
         uuid = data.get('row_uuid')
-        row = self.Session.query(self.model_row_class).get(uuid) if uuid else None
+        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:
@@ -311,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,
@@ -329,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
@@ -376,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:
@@ -406,7 +414,7 @@ class OrderingBatchView(PurchasingBatchView):
             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"}
 
@@ -433,7 +441,7 @@ class OrderingBatchView(PurchasingBatchView):
                 self.handler.update_row_quantity(row, cases_ordered=cases_ordered,
                                                  units_ordered=units_ordered)
             except Exception as error:
-                return {'error': six.text_type(error)}
+                return {'error': str(error)}
 
         else: # empty order quantities
 
@@ -465,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])
 
@@ -484,14 +493,75 @@ 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(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs)
+        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._purchasing_defaults(config)
+        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 7b668dc5..01858c98 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,29 +24,23 @@
 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.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 tailbone.util import get_form_data
+from wuttaweb.util import get_form_data
+
+from tailbone import forms
 from tailbone.views.purchasing import PurchasingBatchView
 
 
@@ -85,12 +79,9 @@ class ReceivingBatchView(PurchasingBatchView):
 
     rows_editable = False
     rows_editable_but_not_directly = True
-    rows_deletable = True
 
     default_uom_is_case = True
 
-    purchase_order_fieldname = 'purchase'
-
     labels = {
         'truck_dump_batch': "Truck Dump Parent",
         'invoice_parser_key': "Invoice Parser",
@@ -117,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView):
         'store',
         'vendor',
         'description',
-        'receiving_workflow',
+        'workflow',
         'truck_dump',
         'truck_dump_children_first',
         'truck_dump_children',
@@ -168,7 +159,6 @@ class ReceivingBatchView(PurchasingBatchView):
         'cases_received',
         'units_received',
         'catalog_unit_cost',
-        'po_unit_cost',
         'invoice_unit_cost',
         'invoice_total_calculated',
         'credits',
@@ -212,6 +202,7 @@ class ReceivingBatchView(PurchasingBatchView):
         'po_unit_cost',
         'po_case_size',
         'po_total',
+        'invoice_number',
         'invoice_line_number',
         'invoice_unit_cost',
         'invoice_cost_confirmed',
@@ -239,180 +230,36 @@ class ReceivingBatchView(PurchasingBatchView):
         return self.enum.PURCHASE_BATCH_MODE_RECEIVING
 
     def configure_grid(self, g):
-        super(ReceivingBatchView, self).configure_grid(g)
+        super().configure_grid(g)
 
         if not self.handler.allow_truck_dump_receiving():
             g.remove('truck_dump')
 
-    def create(self, form=None, **kwargs):
-        """
-        Custom view for creating a new receiving batch.  We split the process
-        into two steps, 1) choose and 2) create.  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.costing:CostingBatchView.create()`
-        which uses similar logic.
-        """
-        route_prefix = self.get_route_prefix()
-        workflows = self.handler.supported_receiving_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('{}.create'.format(route_prefix)))
-
-            # 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 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.query(model.Vendor).get(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(ReceivingBatchView, self).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(ReceivingBatchView, self).create(form=form, **kwargs)
-
-        # okay, at this point we need the user to select a vendor and workflow
-        self.creating = True
-        use_buefy = self.get_use_buefy()
-        context = {}
-
-        # form to accept user choice of vendor/workflow
-        schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
-        form = forms.Form(schema=schema, request=self.request,
-                          use_buefy=use_buefy)
-
-        # configure vendor field
-        app = self.get_rattail_app()
-        vendor_handler = app.get_vendor_handler()
-        if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'):
-            # only show vendors for which we have dedicated invoice parsers
-            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)
-            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
-                             for vendor in vendors]
-            if use_buefy:
-                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-            else:
-                form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values))
-        else:
-            # 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, "({}) {}".format(vendor.id, vendor.name))
-                                 for vendor in vendors]
-                if use_buefy:
-                    form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-                else:
-                    form.set_widget('vendor', forms.widgets.JQuerySelectWidget(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.query(model.Vendor).get(self.request.POST['vendor'])
-                        if vendor:
-                            vendor_display = six.text_type(vendor)
-                vendors_url = self.request.route_url('vendors.autocomplete')
-                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=vendor_display, service_url=vendors_url))
-        form.set_validator('vendor', self.valid_vendor_uuid)
-
-        # configure workflow field
-        values = [(workflow['workflow_key'], workflow['display'])
-                  for workflow in workflows]
-        if use_buefy:
-            form.set_widget('workflow',
-                            dfwidget.SelectWidget(values=values))
-        else:
-            form.set_widget('workflow',
-                            forms.widgets.JQuerySelectWidget(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(newstyle=True):
-            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 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):
 
         # first run it through the normal logic, if that doesn't like
         # it then we won't either
-        if not super(ReceivingBatchView, self).row_deletable(row):
+        if not super().row_deletable(row):
             return False
 
-        batch = row.batch
-
-        # can always delete rows from truck dump parent
-        if batch.is_truck_dump_parent():
-            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():
@@ -420,34 +267,27 @@ 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()
-        use_buefy = self.get_use_buefy()
 
         # 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.query(model.Vendor).get(
-                self.request.matchdict['vendor_uuid'])
+            vendor = self.Session.get(model.Vendor,
+                                      self.request.matchdict['vendor_uuid'])
             assert vendor
             f.set_readonly('vendor_uuid')
-            f.set_default('vendor_uuid', six.text_type(vendor))
+            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))
 
-        # receiving_workflow
-        if self.creating and workflow:
-            f.set_readonly('receiving_workflow')
-            f.set_renderer('receiving_workflow', self.render_receiving_workflow)
-        else:
-            f.remove('receiving_workflow')
-
+        # TODO: remove this
         # batch_type
         if self.creating:
             f.set_widget('batch_type', dfwidget.HiddenWidget())
@@ -529,19 +369,28 @@ class ReceivingBatchView(PurchasingBatchView):
             f.set_widget('store_uuid', dfwidget.HiddenWidget())
 
         # purchase
-        if (self.creating and workflow in ('from_po', 'from_po_with_invoice')
-            and self.purchase_order_fieldname == 'purchase'):
-            if use_buefy:
-                f.replace('purchase', 'purchase_uuid')
-                purchases = self.batch_handler.get_eligible_purchases(
-                    vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
-                values = [(p.uuid, self.batch_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')
-        elif self.creating or not batch.purchase:
+        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:
@@ -551,6 +400,16 @@ 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.)")
@@ -584,6 +443,17 @@ class ReceivingBatchView(PurchasingBatchView):
                          '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',
@@ -620,14 +490,28 @@ class ReceivingBatchView(PurchasingBatchView):
                          'invoice_date',
                          'invoice_number')
 
-    def render_receiving_workflow(self, batch, field):
-        key = self.request.matchdict['workflow_key']
-        info = self.handler.receiving_workflow_info(key)
-        if info:
-            return info['display']
+    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)
+        kwargs = super().template_kwargs_create(**kwargs)
+        model = self.model
         if self.handler.allow_truck_dump_receiving():
             vmap = {}
             batches = self.Session.query(model.PurchaseBatch)\
@@ -640,41 +524,41 @@ class ReceivingBatchView(PurchasingBatchView):
         return kwargs
 
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, **kwargs)
-        batch_type = self.request.POST['batch_type']
+        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']
 
-        # TODO: ugh should just have workflow and no batch_type
-        kwargs['receiving_workflow'] = batch_type
-        if batch_type == 'from_scratch':
+        workflow = kwargs['workflow']
+        if workflow == 'from_scratch':
             kwargs.pop('truck_dump_batch', None)
             kwargs.pop('truck_dump_batch_uuid', None)
-        elif batch_type == 'from_invoice':
+        elif workflow == 'from_invoice':
             pass
-        elif batch_type == 'from_po':
+        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 batch_type == 'from_po_with_invoice':
+        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 batch_type == 'truck_dump_children_first':
+        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 batch_type == 'truck_dump_children_last':
+        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 batch_type.startswith('truck_dump_child'):
+        elif workflow.startswith('truck_dump_child'):
             truck_dump = self.get_instance()
             kwargs['store'] = truck_dump.store
             kwargs['vendor'] = truck_dump.vendor
@@ -720,42 +604,65 @@ class ReceivingBatchView(PurchasingBatchView):
         return breakdown
 
     def allow_edit_catalog_unit_cost(self, batch):
-        return (not batch.executed
-                and self.has_perm('edit_row')
-                and self.batch_handler.allow_receiving_edit_catalog_unit_cost())
+
+        # 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):
-        return (not batch.executed
-                and self.has_perm('edit_row')
-                and self.batch_handler.allow_receiving_edit_invoice_unit_cost())
+
+        # 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(ReceivingBatchView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         batch = kwargs['instance']
-        use_buefy = self.get_use_buefy()
 
         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()
-            if use_buefy:
 
-                g = factory('batch_po_vs_invoice_breakdown', [],
-                    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_buefy_table_element(data_prop='poVsInvoiceBreakdownData',
-                                                 empty_labels=True))
-
-            else:
-                kwargs['po_vs_invoice_breakdown_grid'] = factory(
-                    'batch_po_vs_invoice_breakdown',
-                    data=breakdown,
-                    columns=['title', 'count'])
+            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):
@@ -765,7 +672,7 @@ class ReceivingBatchView(PurchasingBatchView):
             credits_data.append({
                 'uuid': credit.uuid,
                 'credit_type': credit.credit_type,
-                'expiration_date': six.text_type(credit.expiration_date) if credit.expiration_date else None,
+                '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,
@@ -779,8 +686,7 @@ class ReceivingBatchView(PurchasingBatchView):
         return credits_data
 
     def template_kwargs_view_row(self, **kwargs):
-        kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs)
-        use_buefy = self.get_use_buefy()
+        kwargs = super().template_kwargs_view_row(**kwargs)
         app = self.get_rattail_app()
         products_handler = app.get_products_handler()
         row = kwargs['instance']
@@ -792,18 +698,17 @@ class ReceivingBatchView(PurchasingBatchView):
         elif row.upc:
             kwargs['image_url'] = products_handler.get_image_url(upc=row.upc)
 
-        if use_buefy:
-            kwargs['row_context'] = self.get_context_row(row)
+        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
+        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
 
@@ -818,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)
 
@@ -920,28 +825,30 @@ 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)
 
-    @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)
@@ -949,8 +856,8 @@ class ReceivingBatchView(PurchasingBatchView):
             batch.department_uuid = department.uuid
 
     def configure_row_grid(self, g):
-        super(ReceivingBatchView, self).configure_row_grid(g)
-        use_buefy = self.get_use_buefy()
+        super().configure_row_grid(g)
+        model = self.model
         batch = self.get_instance()
 
         # vendor_code
@@ -958,37 +865,18 @@ class ReceivingBatchView(PurchasingBatchView):
         g.filters['vendor_code'].default_verb = 'contains'
 
         # catalog_unit_cost
-        if (self.handler.has_purchase_order(batch)
-            or self.handler.has_invoice_file(batch)):
-            g.remove('catalog_unit_cost')
-        elif use_buefy and self.allow_edit_catalog_unit_cost(batch):
+        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',
-                                'catalogUnitCostClicked(props.row)')
+                                'this.catalogUnitCostClicked')
 
         # invoice_unit_cost
-        if use_buefy and self.allow_edit_invoice_unit_cost(batch):
+        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',
-                                'invoiceUnitCostClicked(props.row)')
-
-        # nb. only show PO *or* invoice cost; prefer the latter unless
-        # we have a PO and no invoice
-        if (self.batch_handler.has_purchase_order(batch)
-            and not self.batch_handler.has_invoice_file(batch)):
-            g.remove('invoice_unit_cost')
-        else:
-            g.remove('po_unit_cost')
-
-        # 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)())
+                                'this.invoiceUnitCostClicked')
 
         show_ordered = self.rattail_config.getbool(
             'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid',
@@ -1014,14 +902,16 @@ class ReceivingBatchView(PurchasingBatchView):
         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():
@@ -1029,6 +919,19 @@ class ReceivingBatchView(PurchasingBatchView):
         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',
@@ -1048,7 +951,7 @@ class ReceivingBatchView(PurchasingBatchView):
         })
 
     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 '')
@@ -1060,10 +963,10 @@ class ReceivingBatchView(PurchasingBatchView):
 
     def get_row_instance_title(self, row):
         if row.product:
-            return six.text_type(row.product)
+            return str(row.product)
         if row.upc:
             return row.upc.pretty()
-        return super(ReceivingBatchView, self).get_row_instance_title(row)
+        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
@@ -1075,14 +978,13 @@ class ReceivingBatchView(PurchasingBatchView):
     def make_row_credits_grid(self, row):
 
         # first make grid like normal
-        g = super(ReceivingBatchView, self).make_row_credits_grid(row)
+        g = super().make_row_credits_grid(row)
 
-        if (self.get_use_buefy()
-            and self.has_perm('edit_row')
+        if (self.has_perm('edit_row')
             and self.row_editable(row)):
 
             # add the Un-Declare action
-            g.main_actions.append(self.make_action(
+            g.actions.append(self.make_action(
                 'remove', label="Un-Declare",
                 url='#', icon='trash',
                 link_class='has-text-danger',
@@ -1106,61 +1008,58 @@ class ReceivingBatchView(PurchasingBatchView):
         """
         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.viewing = True
-        use_buefy = self.get_use_buefy()
         row = self.get_row_instance()
 
-        # things are a bit different now w/ buefy support..
-        if use_buefy:
+        # 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))
 
-            # 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()
 
-            # 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)
 
-            # 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']
 
-                # 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)
 
-                # 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)})
 
-                except Exception as error:
-                    return self.json_response({'error': six.text_type(error)})
-
-                self.Session.flush()
-                self.Session.refresh(row)
-                return self.json_response({
-                    'ok': True,
-                    'row': self.get_context_row(row)})
+            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()
@@ -1184,15 +1083,12 @@ class ReceivingBatchView(PurchasingBatchView):
         }
 
         schema = ReceiveRowForm().bind(session=self.Session())
-        form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy)
+        form = forms.Form(schema=schema, request=self.request)
         form.cancel_url = self.get_row_action_url('view', row)
 
         # mode
         mode_values = [(mode, mode) for mode in possible_modes]
-        if use_buefy:
-            mode_widget = dfwidget.SelectWidget(values=mode_values)
-        else:
-            mode_widget = forms.widgets.JQuerySelectWidget(values=mode_values)
+        mode_widget = dfwidget.SelectWidget(values=mode_values)
         form.set_widget('mode', mode_widget)
 
         # quantity
@@ -1206,7 +1102,7 @@ class ReceivingBatchView(PurchasingBatchView):
         # 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)
@@ -1246,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:
@@ -1256,7 +1152,7 @@ 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'])
 
@@ -1312,59 +1208,55 @@ class ReceivingBatchView(PurchasingBatchView):
         View for declaring a credit, i.e. converting some "received" or similar
         quantity, to a credit of some sort.
         """
-        use_buefy = self.get_use_buefy()
         row = self.get_row_instance()
 
-        # things are a bit different now w/ buefy support..
-        if use_buefy:
+        # 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))
 
-            # 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()
 
-            # 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)
 
-            # 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'])
 
-                # 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)
 
-                try:
-                    result = self.handler.can_declare_credit(row, **kwargs)
+            except Exception as error:
+                return self.json_response({'error': str(error)})
 
-                except Exception as error:
-                    return self.json_response({'error': six.text_type(error)})
+            else:
+                if result:
+                    self.handler.declare_credit(row, **kwargs)
 
                 else:
-                    if result:
-                        self.handler.declare_credit(row, **kwargs)
+                    return self.json_response({
+                        'error': "Handler says you can't declare that credit; "
+                        "not sure why"})
 
-                    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)})
+            self.Session.flush()
+            self.Session.refresh(row)
+            return self.json_response({
+                'ok': True,
+                'row': self.get_context_row(row)})
 
         batch = row.batch
         context = {
@@ -1380,16 +1272,12 @@ class ReceivingBatchView(PurchasingBatchView):
         }
 
         schema = DeclareCreditForm()
-        form = forms.Form(schema=schema, request=self.request,
-                          use_buefy=use_buefy)
+        form = forms.Form(schema=schema, request=self.request)
         form.cancel_url = self.get_row_action_url('view', row)
 
         # credit_type
         values = [(m, m) for m in POSSIBLE_CREDIT_TYPES]
-        if use_buefy:
-            widget = dfwidget.SelectWidget(values=values)
-        else:
-            widget = forms.widgets.JQuerySelectWidget(values=values)
+        widget = dfwidget.SelectWidget(values=values)
         form.set_widget('credit_type', widget)
 
         # quantity
@@ -1400,7 +1288,7 @@ class ReceivingBatchView(PurchasingBatchView):
         # 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)
@@ -1441,7 +1329,7 @@ class ReceivingBatchView(PurchasingBatchView):
         credit = None
         uuid = data.get('uuid')
         if uuid:
-            credit = self.Session.query(model.PurchaseBatchCredit).get(uuid)
+            credit = self.Session.get(model.PurchaseBatchCredit, uuid)
         if not credit:
             return {'error': "Credit not found"}
 
@@ -1478,10 +1366,11 @@ class ReceivingBatchView(PurchasingBatchView):
         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:
@@ -1522,7 +1411,8 @@ 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
@@ -1595,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
@@ -1699,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)
 
@@ -1838,12 +1729,14 @@ class ReceivingBatchView(PurchasingBatchView):
         """
         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(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"}
 
@@ -1854,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:
@@ -1863,16 +1756,18 @@ 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),
             },
         }
 
@@ -1891,45 +1786,39 @@ class ReceivingBatchView(PurchasingBatchView):
 
     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)
+    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)
@@ -1952,6 +1841,9 @@ class ReceivingBatchView(PurchasingBatchView):
             {'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},
@@ -1963,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView):
              '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},
@@ -1979,6 +1877,9 @@ class ReceivingBatchView(PurchasingBatchView):
             {'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},
@@ -1991,6 +1892,9 @@ class ReceivingBatchView(PurchasingBatchView):
             {'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',
@@ -2007,7 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._receiving_defaults(config)
-        cls._purchasing_defaults(config)
+        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
@@ -2015,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView):
     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()
 
-        # new receiving 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))
-
         # row-level receiving
         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),
@@ -2060,6 +1958,14 @@ class ReceivingBatchView(PurchasingBatchView):
         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
         config.add_tailbone_permission(permission_prefix,
                                        '{}.auto_receive'.format(permission_prefix),
@@ -2070,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView):
                         permission='{}.auto_receive'.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 NewReceivingBatch(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)
-
-
 class ReceiveRowForm(colander.MappingSchema):
 
     mode = colander.SchemaNode(colander.String(),
@@ -2112,7 +1991,7 @@ class ReceiveRowForm(colander.MappingSchema):
     quick_receive = colander.SchemaNode(colander.Boolean())
 
     def deserialize(self, *args):
-        result = super(ReceiveRowForm, self).deserialize(*args)
+        result = super().deserialize(*args)
 
         if result['mode'] == 'expired' and not result['expiration_date']:
             msg = "Expiration date is required for items with 'expired' mode."
diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py
index 640dc6a9..099224be 100644
--- a/tailbone/views/reports.py
+++ b/tailbone/views/reports.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,22 +24,18 @@
 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.threads import Thread
-from rattail.util import simple_error, OrderedDict
+from rattail.util import simple_error
 
 import colander
 from deform import widget as dfwidget
@@ -64,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])
 
 
@@ -84,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'
@@ -107,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)
@@ -130,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,
@@ -139,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)
@@ -159,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'):
@@ -180,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)
@@ -193,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'),
@@ -211,7 +212,7 @@ 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
@@ -240,7 +241,7 @@ class ReportOutputView(ExportMasterView):
     ]
 
     def __init__(self, request):
-        super(ReportOutputView, self).__init__(request)
+        super().__init__(request)
         self.report_handler = self.get_report_handler()
 
     def get_report_handler(self):
@@ -248,7 +249,7 @@ class ReportOutputView(ExportMasterView):
         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'
@@ -256,7 +257,7 @@ class ReportOutputView(ExportMasterView):
         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)
@@ -267,6 +268,9 @@ class ReportOutputView(ExportMasterView):
     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()
@@ -276,10 +280,20 @@ class ReportOutputView(ExportMasterView):
             if not report.get('error'):
                 url = self.request.route_url('poser_reports.view',
                                              report_key=poser_key)
-                return tags.link_to(type_key, url)
+                rendered = tags.link_to(type_key, url)
 
-        # fallback to showing value as-is
-        return type_key
+        # 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
@@ -294,16 +308,14 @@ class ReportOutputView(ExportMasterView):
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.params'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.params',
             data=params,
             columns=['key', 'value'],
             labels={'key': "Name"},
         )
-        if self.get_use_buefy():
-            return HTML.literal(
-                g.render_buefy_table_element(data_prop='paramsData'))
-        else:
-            return HTML.literal(g.render_grid())
+        return HTML.literal(
+            g.render_table_element(data_prop='paramsData'))
 
     def get_params_context(self, report):
         params_data = []
@@ -315,11 +327,10 @@ class ReportOutputView(ExportMasterView):
         return params_data
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(ReportOutputView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         output = kwargs['instance']
 
-        if self.get_use_buefy():
-            kwargs['params_data'] = self.get_params_context(output)
+        kwargs['params_data'] = self.get_params_context(output)
 
         # build custom URL to re-build this report
         url = None
@@ -332,11 +343,10 @@ class ReportOutputView(ExportMasterView):
         return kwargs
 
     def template_kwargs_delete(self, **kwargs):
-        kwargs = super(ReportOutputView, self).template_kwargs_delete(**kwargs)
+        kwargs = super().template_kwargs_delete(**kwargs)
 
-        if self.get_use_buefy():
-            report = kwargs['instance']
-            kwargs['params_data'] = self.get_params_context(report)
+        report = kwargs['instance']
+        kwargs['params_data'] = self.get_params_context(report)
 
         return kwargs
 
@@ -345,8 +355,6 @@ class ReportOutputView(ExportMasterView):
         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.report_handler.get_reports()
         if isinstance(reports, OrderedDict):
@@ -356,7 +364,7 @@ class ReportOutputView(ExportMasterView):
 
         # 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')
 
@@ -364,15 +372,12 @@ class ReportOutputView(ExportMasterView):
         # 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))
-            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']))
 
@@ -395,7 +400,6 @@ class ReportOutputView(ExportMasterView):
         and redirects user to view the output.
         """
         app = self.get_rattail_app()
-        use_buefy = self.get_use_buefy()
         type_key = self.request.matchdict['type_key']
         report = self.report_handler.get_report(type_key)
         if not report:
@@ -431,12 +435,12 @@ class ReportOutputView(ExportMasterView):
                 node.default = param.default
 
             # set docstring
-            helptext[param.name] = param.helptext
+            # 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, helptext=helptext)
+        form = forms.Form(schema=schema, request=self.request, helptext=helptext)
         form.submit_label = "Generate this Report"
         form.cancel_url = self.request.get_referrer(
             default=self.request.route_url('{}.create'.format(route_prefix)))
@@ -461,10 +465,12 @@ class ReportOutputView(ExportMasterView):
                     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}
@@ -494,8 +500,10 @@ class ReportOutputView(ExportMasterView):
         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.report_handler.generate_output(session, report, params, user, progress=progress)
 
@@ -601,7 +609,7 @@ class ProblemReportView(MasterView):
     ]
 
     def __init__(self, request):
-        super(ProblemReportView, self).__init__(request)
+        super().__init__(request)
 
         app = self.get_rattail_app()
         self.problem_handler = app.get_problem_report_handler()
@@ -621,14 +629,16 @@ class ProblemReportView(MasterView):
         reports = self.handler.get_all_problem_reports()
         organized = self.handler.organize_problem_reports(reports)
 
-        for system_key, reports in six.iteritems(organized):
-            for report in six.itervalues(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(ProblemReportView, self).configure_grid(g)
+        super().configure_grid(g)
+
+        g.set_searchable('system_key')
 
         g.set_renderer('email_recipients', self.render_email_recipients)
 
@@ -656,7 +666,7 @@ class ProblemReportView(MasterView):
         return ProblemReportSchema()
 
     def configure_form(self, f):
-        super(ProblemReportView, self).configure_form(f)
+        super().configure_form(f)
 
         # email_*
         if self.editing:
@@ -696,13 +706,16 @@ class ProblemReportView(MasterView):
         return ', '.join(recips)
 
     def render_days(self, report_info, field):
-        g = self.get_grid_factory()('days', [],
-                                    columns=['weekday_name', 'enabled'],
-                                    labels={'weekday_name': "Weekday"})
-        return HTML.literal(g.render_buefy_table_element(data_prop='weekdaysData'))
+        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(ProblemReportView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
         report_info = kwargs['instance']
 
         data = []
@@ -725,12 +738,12 @@ class ProblemReportView(MasterView):
                              report['problem_key'])
 
         app.save_setting(session, 'rattail.problems.{}.enabled'.format(key),
-                         six.text_type(data['enabled']).lower())
+                         str(data['enabled']).lower())
 
         for i in range(7):
             daykey = 'day{}'.format(i)
             app.save_setting(session, 'rattail.problems.{}.{}'.format(key, daykey),
-                             six.text_type(data['days'][daykey]).lower())
+                             str(data['days'][daykey]).lower())
 
     def execute_instance(self, report_info, user, progress=None, **kwargs):
         report = report_info['_report']
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index 29bb2ef4..e8a6d8a2 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,16 +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 administrator_role, guest_role, authenticated_role
+from rattail.db.model import Role
 from rattail.excel import ExcelWriter
 
 import colander
@@ -49,7 +45,7 @@ class RoleView(PrincipalMasterView):
     """
     Master view for the Role model.
     """
-    model_class = model.Role
+    model_class = Role
     has_versions = True
     touchable = True
 
@@ -80,7 +76,7 @@ class RoleView(PrincipalMasterView):
     ]
 
     def configure_grid(self, g):
-        super(RoleView, self).configure_grid(g)
+        super().configure_grid(g)
 
         # name
         g.filters['name'].default_active = True
@@ -110,8 +106,11 @@ class RoleView(PrincipalMasterView):
         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
@@ -119,11 +118,11 @@ class RoleView(PrincipalMasterView):
             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
@@ -142,11 +141,14 @@ class RoleView(PrincipalMasterView):
         if role.node_type and role.node_type != self.rattail_config.node_type():
             return False
 
-        if role is administrator_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 authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return False
-        if role is guest_role(self.Session()):
+        if role is auth.get_role_anonymous(self.Session()):
             return False
 
         # only "admin" can delete "admin-ish" roles
@@ -161,6 +163,7 @@ class RoleView(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:
@@ -170,9 +173,8 @@ class RoleView(PrincipalMasterView):
             raise colander.Invalid(node, "Name must be unique")
 
     def configure_form(self, f):
-        super(RoleView, 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()
 
@@ -189,17 +191,17 @@ class RoleView(PrincipalMasterView):
 
         # session_timeout
         f.set_renderer('session_timeout', self.render_session_timeout)
-        if self.editing and role is guest_role(self.Session()):
+        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 administrator_role(self.Session()):
+            if role is auth.get_role_administrator(self.Session()):
                 include = False
-            elif role is authenticated_role(self.Session()):
+            elif role is auth.get_role_authenticated(self.Session()):
                 include = False
-            elif role is guest_role(self.Session()):
+            elif role is auth.get_role_anonymous(self.Session()):
                 include = False
             if not include:
                 f.remove('sync_me', 'sync_users', 'node_type')
@@ -213,7 +215,7 @@ class RoleView(PrincipalMasterView):
         f.set_type('notes', 'text_wrapped')
 
         # users
-        if use_buefy and self.viewing:
+        if self.viewing:
             f.set_renderer('users', self.render_users)
         else:
             f.remove('users')
@@ -224,14 +226,13 @@ class RoleView(PrincipalMasterView):
                                                           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 auth.has_permission(self.Session(), role, key,
-                                           include_guest=False,
+                                           include_anonymous=False,
                                            include_authenticated=False):
                         granted.append(key)
             f.set_default('permissions', granted)
@@ -239,12 +240,14 @@ class RoleView(PrincipalMasterView):
             f.remove_field('permissions')
 
     def render_users(self, role, field):
+        app = self.get_rattail_app()
+        auth = app.get_auth_handler()
 
-        if role is guest_role(self.Session()):
+        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 authenticated_role(self.Session()):
+        if role is auth.get_role_authenticated(self.Session()):
             return ("The authenticated role is implied for all users, "
                     "but only when logged in.")
 
@@ -252,7 +255,8 @@ class RoleView(PrincipalMasterView):
         permission_prefix = self.get_permission_prefix()
         factory = self.get_grid_factory()
         g = factory(
-            key='{}.users'.format(route_prefix),
+            self.request,
+            key=f'{route_prefix}.users',
             data=[],
             columns=[
                 'full_name',
@@ -265,12 +269,12 @@ class RoleView(PrincipalMasterView):
         )
 
         if self.request.has_perm('users.view'):
-            g.main_actions.append(self.make_action('view', icon='eye'))
+            g.actions.append(self.make_action('view', icon='eye'))
         if self.request.has_perm('users.edit'):
-            g.main_actions.append(self.make_action('edit', icon='edit'))
+            g.actions.append(self.make_action('edit', icon='edit'))
 
         return HTML.literal(
-            g.render_buefy_table_element(data_prop='usersData'))
+            g.render_table_element(data_prop='usersData'))
 
     def get_available_permissions(self):
         """
@@ -283,8 +287,8 @@ class RoleView(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:
@@ -298,8 +302,8 @@ class RoleView(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] = {
@@ -311,11 +315,13 @@ class RoleView(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):
         """
@@ -327,7 +333,7 @@ class RoleView(PrincipalMasterView):
         """
         if data is None:
             data = form.validated
-        role = super(RoleView, self).objectify(form, data)
+        role = super().objectify(form, data)
         self.update_permissions(role, data['permissions'])
         return role
 
@@ -342,61 +348,66 @@ class RoleView(PrincipalMasterView):
         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:
                     auth.grant_permission(role, pkey)
                 else:
                     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())
 
-        use_buefy = self.get_use_buefy()
-        if use_buefy:
-            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
+        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?
@@ -405,16 +416,28 @@ class RoleView(PrincipalMasterView):
                            .options(orm.joinedload(model.Role._permissions))
         roles = []
         for role in all_roles:
-            if auth.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)\
@@ -466,7 +489,7 @@ class RoleView(PrincipalMasterView):
                 # and show an 'X' for any role which has this perm
                 for col, role in enumerate(roles, 2):
                     if auth.has_permission(self.Session(), role, key,
-                                           include_guest=False):
+                                           include_anonymous=False):
                         sheet.cell(row=writing_row, column=col, value="X")
 
                 writing_row += 1
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index c38e3136..10a0c2eb 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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 json
-import six
+import colander
 
-from rattail.db import model
-from rattail.settings import Setting
+from rattail.db.model import Setting
+from rattail.settings import Setting as AppSetting
 from rattail.util import import_module_path
 
-import colander
-from webhelpers2.html import tags
-
-from tailbone import forms
+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 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 SettingView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(SettingView, 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(SettingView, 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")
 
@@ -97,7 +242,7 @@ class SettingView(MasterView):
         self.rattail_config.beaker_invalidate_setting(setting.name)
 
         # otherwise delete like normal
-        super(SettingView, self).delete_instance(setting)
+        super().delete_instance(setting)
 
 
 # TODO: deprecate / remove this
@@ -126,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:
@@ -151,29 +296,22 @@ class AppSettingsView(View):
                 option['url'] = self.request.route_url(option['route'])
                 config_options.append(option)
 
-        use_buefy = self.get_use_buefy()
         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])
@@ -199,8 +337,7 @@ class AppSettingsView(View):
 
             # specify error / message if applicable
             # TODO: not entirely clear to me why some field errors are
-            # represented differently?  presumably it depends on
-            # whether Buefy is used by the theme.
+            # represented differently?
             if field.error:
                 s['error'] = True
                 if isinstance(field.error, colander.Invalid):
@@ -255,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
@@ -287,7 +433,7 @@ class AppSettingsView(View):
                            for entry in value.split('\n')]
                 value = ', '.join(entries)
             else:
-                value = six.text_type(value)
+                value = str(value)
             app = self.get_rattail_app()
             app.save_setting(Session(), legacy_name, value)
 
@@ -313,6 +459,9 @@ class AppSettingsView(View):
 def defaults(config, **kwargs):
     base = globals()
 
+    AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
+    AppInfoView.defaults(config)
+
     AppSettingsView = kwargs.get('AppSettingsView', base['AppSettingsView'])
     AppSettingsView.defaults(config)
 
diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py
index b6d9aadf..53bfc446 100644
--- a/tailbone/views/shifts/core.py
+++ b/tailbone/views/shifts/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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 ScheduledShiftView(MasterView):
+class ScheduledShiftView(MasterView, ShiftViewMixin):
     """
     Master view for employee scheduled shifts.
     """
@@ -78,20 +79,20 @@ class ScheduledShiftView(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(ScheduledShiftView, 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 WorkedShiftView(MasterView):
+class WorkedShiftView(MasterView, ShiftViewMixin):
     """
     Master view for employee worked shifts.
     """
@@ -117,26 +118,29 @@ class WorkedShiftView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(WorkedShiftView, 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")
@@ -149,12 +153,12 @@ class WorkedShiftView(MasterView):
         return "WorkedShift: {}, {}".format(shift.employee, date)
 
     def configure_form(self, f):
-        super(WorkedShiftView, 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')
 
@@ -162,12 +166,12 @@ class WorkedShiftView(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(WorkedShiftView, self).get_xlsx_fields()
+        fields = super().get_xlsx_fields()
 
         # add employee name
         i = fields.index('employee_uuid')
@@ -179,7 +183,7 @@ class WorkedShiftView(MasterView):
         return fields
 
     def get_xlsx_row(self, shift, fields):
-        row = super(WorkedShiftView, 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:
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 7a1ccbe5..c8b82724 100644
--- a/tailbone/views/shifts/schedule.py
+++ b/tailbone/views/shifts/schedule.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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)
diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py
index 9898cd04..a8874127 100644
--- a/tailbone/views/shifts/timesheet.py
+++ b/tailbone/views/shifts/timesheet.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
 
diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py
index d2a337cc..43648ea6 100644
--- a/tailbone/views/subdepartments.py
+++ b/tailbone/views/subdepartments.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,12 @@
 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
 
@@ -85,6 +87,9 @@ class SubdepartmentView(MasterView):
     def configure_grid(self, g):
         super(SubdepartmentView, self).configure_grid(g)
 
+        # number
+        g.set_link('number')
+
         # name
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
@@ -95,16 +100,40 @@ class SubdepartmentView(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(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 {
diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py
index 5d4f7d95..bfd52f2b 100644
--- a/tailbone/views/tables.py
+++ b/tailbone/views/tables.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,7 +24,18 @@
 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
 
@@ -34,20 +45,45 @@ 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,19 +97,355 @@ class TableView(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')
 
-        g.set_searchable('name')
+    def configure_form(self, f):
+        super().configure_form(f)
 
-# TODO: deprecate / remove this
-TablesView = TableView
+        # 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):
diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py
index 19a385ba..b2afaeb9 100644
--- a/tailbone/views/taxes.py
+++ b/tailbone/views/taxes.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Tax Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from rattail.db import model
 
 from tailbone.views import MasterView
@@ -53,12 +51,26 @@ class TaxView(MasterView):
     ]
 
     def configure_grid(self, g):
-        super(TaxView, 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
diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py
index 6b8ee036..4ce52009 100644
--- a/tailbone/views/tempmon/appliances.py
+++ b/tailbone/views/tempmon/appliances.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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()
diff --git a/tailbone/views/tempmon/clients.py b/tailbone/views/tempmon/clients.py
index a3fdb31b..1b2d49d8 100644
--- a/tailbone/views/tempmon/clients.py
+++ b/tailbone/views/tempmon/clients.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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)
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 954acf94..515eabc9 100644
--- a/tailbone/views/tempmon/dashboard.py
+++ b/tailbone/views/tempmon/dashboard.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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"}
 
diff --git a/tailbone/views/tempmon/probes.py b/tailbone/views/tempmon/probes.py
index 218caafa..573f9a2d 100644
--- a/tailbone/views/tempmon/probes.py
+++ b/tailbone/views/tempmon/probes.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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
diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py
index a8223dd2..02e3fc51 100644
--- a/tailbone/views/tempmon/readings.py
+++ b/tailbone/views/tempmon/readings.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 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,7 +121,7 @@ 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)
 
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/__init__.py b/tailbone/views/trainwreck/__init__.py
index 33662c67..b5eea351 100644
--- a/tailbone/views/trainwreck/__init__.py
+++ b/tailbone/views/trainwreck/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,3 +27,7 @@ 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
index 163d17b0..d5f077aa 100644
--- a/tailbone/views/trainwreck/base.py
+++ b/tailbone/views/trainwreck/base.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,12 +24,6 @@
 Trainwreck views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
-from rattail.time import localtime
-
 from webhelpers2.html import HTML, tags
 
 from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions
@@ -48,6 +42,7 @@ class TransactionView(MasterView):
     creatable = False
     editable = False
     deletable = False
+    results_downloadable = True
 
     supports_multiple_engines = True
     engine_type_key = 'trainwreck'
@@ -156,15 +151,48 @@ class TransactionView(MasterView):
         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(TransactionView, self).configure_grid(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'
-        g.filters['end_time'].default_value = six.text_type(localtime(self.rattail_config).date())
-        g.set_sort_defaults('end_time', 'desc')
+        # 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')
@@ -186,7 +214,7 @@ class TransactionView(MasterView):
             return 'warning'
 
     def configure_form(self, f):
-        super(TransactionView, self).configure_form(f)
+        super().configure_form(f)
 
         # system
         f.set_enum('system', self.enum.TRAINWRECK_SYSTEM)
@@ -216,38 +244,57 @@ class TransactionView(MasterView):
 
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
-        use_buefy = self.get_use_buefy()
 
         g = factory(
-            key='{}.custorder_xref_markers'.format(route_prefix),
-            data=[] if use_buefy else txn.custorder_xref_markers,
-            columns=['custorder_xref', 'custorder_item_xref'],
-            request=self.request)
+            self.request,
+            key=f'{route_prefix}.custorder_xref_markers',
+            data=[],
+            columns=['custorder_xref', 'custorder_item_xref'])
 
-        if use_buefy:
-            return HTML.literal(
-                g.render_buefy_table_element(data_prop='custorderXrefMarkersData'))
-        else:
-            return HTML.literal(g.render_grid())
+        return HTML.literal(
+            g.render_table_element(data_prop='custorderXrefMarkersData'))
 
     def template_kwargs_view(self, **kwargs):
-        kwargs = super(TransactionView, self).template_kwargs_view(**kwargs)
+        kwargs = super().template_kwargs_view(**kwargs)
+        config = self.rattail_config
 
-        use_buefy = self.get_use_buefy()
-        if use_buefy:
-            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
+        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)
@@ -256,7 +303,7 @@ class TransactionView(MasterView):
         return item.transaction
 
     def configure_row_grid(self, g):
-        super(TransactionView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_sort_defaults('sequence')
 
         g.set_type('unit_quantity', 'quantity')
@@ -276,7 +323,7 @@ class TransactionView(MasterView):
         return "Trainwreck Line Item"
 
     def configure_row_form(self, f):
-        super(TransactionView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # transaction
         f.set_renderer('transaction', self.render_transaction)
@@ -296,7 +343,7 @@ class TransactionView(MasterView):
 
     def render_transaction(self, item, field):
         txn = getattr(item, field)
-        text = six.text_type(txn)
+        text = str(txn)
         url = self.get_action_url('view', txn)
         return tags.link_to(text, url)
 
@@ -306,38 +353,31 @@ class TransactionView(MasterView):
 
         route_prefix = self.get_route_prefix()
         factory = self.get_grid_factory()
-        use_buefy = self.get_use_buefy()
 
         g = factory(
-            key='{}.discounts'.format(route_prefix),
-            data=[] if use_buefy else item.discounts,
+            self.request,
+            key=f'{route_prefix}.discounts',
+            data=[],
             columns=['discount_type', 'description', 'amount'],
-            labels={'discount_type': "Type"},
-            request=self.request)
+            labels={'discount_type': "Type"})
 
-        if use_buefy:
-            return HTML.literal(
-                g.render_buefy_table_element(data_prop='discountsData'))
-        else:
-            g.set_type('amount', 'currency')
-            return HTML.literal(g.render_grid())
+        return HTML.literal(
+            g.render_table_element(data_prop='discountsData'))
 
     def template_kwargs_view_row(self, **kwargs):
-        use_buefy = self.get_use_buefy()
-        if use_buefy:
-            form = kwargs['form']
-            if 'discounts' in form:
+        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
+            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
 
@@ -352,7 +392,7 @@ class TransactionView(MasterView):
 
         # find oldest and newest dates for each database
         engines_data = []
-        for key, engine in six.iteritems(trainwreck_engines):
+        for key, engine in trainwreck_engines.items():
 
             if key == 'default':
                 session = self.Session()
@@ -384,8 +424,26 @@ class TransactionView(MasterView):
             '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(TransactionView, self).configure_get_context()
+        context = super().configure_get_context()
 
         app = self.get_rattail_app()
         trainwreck_handler = app.get_trainwreck_handler()
@@ -399,7 +457,7 @@ class TransactionView(MasterView):
         return context
 
     def configure_gather_settings(self, data):
-        settings = super(TransactionView, self).configure_gather_settings(data)
+        settings = super().configure_gather_settings(data)
 
         app = self.get_rattail_app()
         trainwreck_handler = app.get_trainwreck_handler()
@@ -416,7 +474,7 @@ class TransactionView(MasterView):
         return settings
 
     def configure_remove_settings(self):
-        super(TransactionView, self).configure_remove_settings()
+        super().configure_remove_settings()
         app = self.get_rattail_app()
 
         names = [
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/upgrades.py b/tailbone/views/upgrades.py
index 0b5e4b87..ffa88032 100644
--- a/tailbone/views/upgrades.py
+++ b/tailbone/views/upgrades.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,22 +24,17 @@
 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
 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.util import OrderedDict
 
 from deform import widget as dfwidget
 from webhelpers2.html import tags, HTML
@@ -56,13 +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",
@@ -102,7 +98,7 @@ class UpgradeView(MasterView):
     ]
 
     def __init__(self, request):
-        super(UpgradeView, self).__init__(request)
+        super().__init__(request)
 
         if hasattr(self, 'get_handler'):
             warnings.warn("defining get_handler() is deprecated.  please "
@@ -122,7 +118,8 @@ class UpgradeView(MasterView):
         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()
@@ -149,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
@@ -172,7 +178,7 @@ 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
@@ -244,25 +250,24 @@ class UpgradeView(MasterView):
         code = getattr(upgrade, field)
         text = self.enum.UPGRADE_STATUS[code]
 
-        if self.get_use_buefy():
-            if code == self.enum.UPGRADE_STATUS_EXECUTING:
+        if code == self.enum.UPGRADE_STATUS_EXECUTING:
 
-                text = HTML.tag('span', c=[text])
+            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'})
+            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]),
-                    ]),
-                ])
+            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
@@ -271,9 +276,10 @@ class UpgradeView(MasterView):
         f.fields = ['system', 'description', 'notes', 'enabled']
 
     def clone_instance(self, original):
+        app = self.get_rattail_app()
         cloned = self.model_class()
         cloned.system = original.system
-        cloned.created = make_utc()
+        cloned.created = app.make_utc()
         cloned.created_by = self.request.user
         cloned.description = original.description
         cloned.notes = original.notes
@@ -295,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,
@@ -310,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
                                          + " / "
@@ -341,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'"
@@ -353,59 +348,34 @@ class UpgradeView(MasterView):
     commit_hash_pattern = re.compile(r'^.{40}$')
 
     def get_changelog_projects(self):
-        projects = {
-            'rattail': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst',
-            },
-            'Tailbone': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst',
-            },
-            'pyCOREPOS': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst',
-            },
-            'rattail_corepos': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_corepos': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst',
-            },
-            'onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst',
-            },
-            'rattail-onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md',
-            },
-            'rattail_tempmon': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone-onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md',
-            },
-            'rattail_woocommerce': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_woocommerce': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_theo': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst',
-            },
+        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',
+            'tailbone_corepos': 'tailbone-corepos',
+            'tailbone-onager': 'tailbone-onager',
+            'tailbone_theo': 'theo',
+            'tailbone_woocommerce': 'tailbone-woocommerce',
         }
+
+        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
@@ -451,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)
@@ -539,17 +510,17 @@ class UpgradeView(MasterView):
 
     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(UpgradeView, self).configure_get_context(**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(UpgradeView, self).configure_gather_settings(data)
+        settings = super().configure_gather_settings(data)
 
         keys = []
         for system in json.loads(data['upgrade_systems']):
@@ -570,7 +541,7 @@ class UpgradeView(MasterView):
         return settings
 
     def configure_remove_settings(self):
-        super(UpgradeView, self).configure_remove_settings()
+        super().configure_remove_settings()
         app = self.get_rattail_app()
         model = self.model
 
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 31842d0b..dfed0a11 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,15 +24,10 @@
 User Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
 import sqlalchemy as sa
 from sqlalchemy import orm
 
 from rattail.db.model import User, UserEvent
-from rattail.db.auth import (administrator_role, guest_role,
-                             authenticated_role, set_user_password)
 
 import colander
 from deform import widget as dfwidget
@@ -41,6 +36,7 @@ from webhelpers2.html import HTML, tags
 from tailbone import forms
 from tailbone.views import MasterView, View
 from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
+from tailbone.util import raw_datetime
 
 
 class UserView(PrincipalMasterView):
@@ -48,12 +44,14 @@ class UserView(PrincipalMasterView):
     Master view for the User model.
     """
     model_class = User
-    has_rows = True
-    model_row_class = UserEvent
     has_versions = True
     touchable = True
     mergeable = True
 
+    labels = {
+        'api_tokens': "API Tokens",
+    }
+
     grid_columns = [
         'username',
         'person',
@@ -70,25 +68,43 @@ class UserView(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',
     ]
 
     def __init__(self, request):
-        super(UserView, self).__init__(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(UserView, self).query(session)
+        query = super().query(session)
         model = self.model
 
         # bring in the related Person(s)
@@ -98,7 +114,7 @@ class UserView(PrincipalMasterView):
         return query
 
     def configure_grid(self, g):
-        super(UserView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
 
         del g.filters['salt']
@@ -168,12 +184,12 @@ class UserView(PrincipalMasterView):
         """
         if value:
             model = self.model
-            person = self.Session.query(model.Person).get(value)
+            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(UserView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
         user = f.model_instance
 
@@ -189,14 +205,18 @@ class UserView(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")
 
@@ -213,10 +233,24 @@ class UserView(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:
@@ -224,7 +258,7 @@ class UserView(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:
@@ -248,10 +282,10 @@ class UserView(PrincipalMasterView):
         # fs.confirm_password.attrs(autocomplete='new-password')
 
         if self.viewing:
-            permissions = self.request.registry.settings.get('tailbone_permissions', {})
+            permissions = self.request.registry.settings.get('wutta_permissions', {})
             f.set_renderer('permissions', PermissionsRenderer(request=self.request,
                                                               permissions=permissions,
-                                                              include_guest=True,
+                                                              include_anonymous=True,
                                                               include_authenticated=True))
         else:
             f.remove('permissions')
@@ -259,18 +293,90 @@ class UserView(PrincipalMasterView):
         if self.viewing or self.deleting:
             f.remove('set_password')
 
-    def get_possible_roles(self):
+    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 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)
 
         # basic list, minus exclusions so far
         roles = self.Session.query(model.Role)\
@@ -285,12 +391,14 @@ class UserView(PrincipalMasterView):
         return roles.order_by(model.Role.name)
 
     def objectify(self, form, data=None):
-        model = self.model
+        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(UserView, self).objectify(form, data)
+        user = super().objectify(form, data)
 
         # create/update person as needed
         names = {}
@@ -319,8 +427,8 @@ class UserView(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)
@@ -333,10 +441,12 @@ class UserView(PrincipalMasterView):
         if 'roles' not in data:
             return
 
-        model = self.model
+        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
@@ -358,7 +468,7 @@ class UserView(PrincipalMasterView):
         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.
@@ -373,7 +483,7 @@ class UserView(PrincipalMasterView):
         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)
 
@@ -383,7 +493,7 @@ class UserView(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 = sorted(user.roles, key=lambda r: r.name)
@@ -400,13 +510,12 @@ class UserView(PrincipalMasterView):
                            .filter(model.UserEvent.user == user)
 
     def configure_row_grid(self, g):
-        super(UserView, 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
@@ -432,6 +541,21 @@ class UserView(PrincipalMasterView):
                 users.append(user)
         return users
 
+    def find_by_perm_configure_results_grid(self, g):
+        g.append('username')
+        g.set_link('username')
+
+        g.append('person')
+        g.set_link('person')
+
+    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.
@@ -497,11 +621,14 @@ class UserView(PrincipalMasterView):
         styles = self.rattail_config.getlist('tailbone', 'themes.styles',
                                              default=[])
         for name in styles:
-            css = self.rattail_config.get('tailbone',
-                                          'themes.style.{}'.format(name))
+            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['buefy_css_options'] = options
+        context['theme_style_options'] = options
 
         return context
 
@@ -514,22 +641,42 @@ class UserView(PrincipalMasterView):
         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': 'tailbone.{}'.format(user.uuid),
-             'option': 'buefy_css'},
+            {'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)
-        return self.configure_gather_settings(
+        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):
@@ -553,6 +700,25 @@ class UserView(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')
+
         # edit preferences for any user
         config.add_tailbone_permission(permission_prefix,
                                        '{}.preferences'.format(permission_prefix),
@@ -593,12 +759,12 @@ class UserEventView(MasterView):
     ]
 
     def get_data(self, session=None):
-        query = super(UserEventView, 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(UserEventView, 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)
@@ -635,4 +801,8 @@ def defaults(config, **kwargs):
 
 
 def includeme(config):
-    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 7d35780e..210df39e 100644
--- a/tailbone/views/vendors/__init__.py
+++ b/tailbone/views/vendors/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Views pertaining to vendors
 """
 
-from __future__ import unicode_literals, absolute_import
-
 from .core import VendorView
 
 
@@ -36,3 +34,4 @@ def 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 87b2de75..addf153c 100644
--- a/tailbone/views/vendors/core.py
+++ b/tailbone/views/vendors/core.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,10 +24,6 @@
 Vendor Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
-import six
-
 from rattail.db import model
 
 from webhelpers2.html import tags
@@ -60,6 +56,7 @@ class VendorView(MasterView):
         'phone',
         'email',
         'contact',
+        'terms',
     ]
 
     form_fields = [
@@ -73,10 +70,11 @@ class VendorView(MasterView):
         'default_email',
         'orders_email',
         'contact',
+        'terms',
     ]
 
     def configure_grid(self, g):
-        super(VendorView, self).configure_grid(g)
+        super().configure_grid(g)
 
         g.filters['name'].default_active = True
         g.filters['name'].default_verb = 'contains'
@@ -90,7 +88,8 @@ class VendorView(MasterView):
         g.set_link('abbreviation')
 
     def configure_form(self, f):
-        super(VendorView, self).configure_form(f)
+        super().configure_form(f)
+        app = self.get_rattail_app()
         vendor = f.model_instance
 
         f.set_type('lead_time_days', 'quantity')
@@ -109,7 +108,7 @@ class VendorView(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:
@@ -121,12 +120,13 @@ class VendorView(MasterView):
     def objectify(self, form, data=None):
         if data is None:
             data = form.validated
-        vendor = super(VendorView, 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:
@@ -143,7 +143,8 @@ class VendorView(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:
@@ -153,7 +154,7 @@ class VendorView(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)
 
@@ -165,7 +166,7 @@ class VendorView(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'),
@@ -182,18 +183,18 @@ class VendorView(MasterView):
         ]
 
     def configure_get_context(self, **kwargs):
-        context = super(VendorView, self).configure_get_context(**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(VendorView, self).configure_gather_settings(
+        settings = super().configure_gather_settings(
             data, **kwargs)
 
         supported_vendor_settings = self.configure_get_supported_vendor_settings()
-        for setting in six.itervalues(supported_vendor_settings):
+        for setting in supported_vendor_settings.values():
             name = 'rattail.vendor.{}'.format(setting['key'])
             settings.append({'name': name,
                              'value': data[name]})
@@ -201,12 +202,12 @@ class VendorView(MasterView):
         return settings
 
     def configure_remove_settings(self, **kwargs):
-        super(VendorView, self).configure_remove_settings(**kwargs)
+        super().configure_remove_settings(**kwargs)
         app = self.get_rattail_app()
         names = []
 
         supported_vendor_settings = self.configure_get_supported_vendor_settings()
-        for setting in six.itervalues(supported_vendor_settings):
+        for setting in supported_vendor_settings.values():
             names.append('rattail.vendor.{}'.format(setting['key']))
 
         if names:
@@ -231,7 +232,7 @@ class VendorView(MasterView):
             settings[key] = {
                 'key': key,
                 'value': vendor.uuid if vendor else None,
-                'label': six.text_type(vendor) if vendor else None,
+                'label': str(vendor) if vendor else None,
             }
 
         return settings
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/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
index dff57e96..d8094e4b 100644
--- a/tailbone/views/workorders.py
+++ b/tailbone/views/workorders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,8 +24,6 @@
 Work Order Views
 """
 
-from __future__ import unicode_literals, absolute_import
-
 import sqlalchemy as sa
 
 from rattail.db.model import WorkOrder, WorkOrderEvent
@@ -85,12 +83,12 @@ class WorkOrderView(MasterView):
     ]
 
     def __init__(self, request):
-        super(WorkOrderView, self).__init__(request)
+        super().__init__(request)
         app = self.get_rattail_app()
         self.workorder_handler = app.get_workorder_handler()
 
     def configure_grid(self, g):
-        super(WorkOrderView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
 
         # customer
@@ -115,9 +113,8 @@ class WorkOrderView(MasterView):
             return 'warning'
 
     def configure_form(self, f):
-        super(WorkOrderView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
-        use_buefy = self.get_use_buefy()
         SelectWidget = forms.widgets.JQuerySelectWidget
 
         # id
@@ -198,11 +195,7 @@ class WorkOrderView(MasterView):
         if status_code in self.enum.WORKORDER_STATUS:
             text = self.enum.WORKORDER_STATUS[status_code]
             if status_code == self.enum.WORKORDER_STATUS_CANCELED:
-                use_buefy = self.get_use_buefy()
-                if use_buefy:
-                    return HTML.tag('span', class_='has-text-danger', c=text)
-                else:
-                    return HTML.tag('span', style='color: red;', c=text)
+                return HTML.tag('span', class_='has-text-danger', c=text)
             return text
         return str(status_code)
 
@@ -215,7 +208,7 @@ class WorkOrderView(MasterView):
         return event.workorder
 
     def configure_row_grid(self, g):
-        super(WorkOrderView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_enum('type_code', self.enum.WORKORDER_EVENT)
         g.set_sort_defaults('occurred')
 
@@ -360,7 +353,7 @@ class WorkOrderView(MasterView):
 class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     def __init__(self, *args, **kwargs):
-        super(StatusFilter, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         from drild import enum
 
@@ -376,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     @property
     def verb_labels(self):
-        labels = dict(super(StatusFilter, self).verb_labels)
+        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(StatusFilter, self).valueless_verbs)
+        verbs = list(super().valueless_verbs)
         verbs.extend([
             'is_active',
             'not_active',
@@ -392,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     @property
     def default_verbs(self):
-        verbs = list(super(StatusFilter, self).default_verbs)
+        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
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
index 10c3460b..d0edb412 100644
--- a/tailbone/webapi.py
+++ b/tailbone/webapi.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2021 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,13 +24,14 @@
 Tailbone Web API
 """
 
-from __future__ import unicode_literals, absolute_import
+import simplejson
 
+from cornice.renderer import CorniceRenderer
 from pyramid.config import Configurator
-from pyramid.authentication import SessionAuthenticationPolicy
 
 from tailbone import app
-from tailbone.auth import TailboneAuthorizationPolicy
+from tailbone.auth import TailboneSecurityPolicy
+from tailbone.providers import get_all_providers
 
 
 def make_rattail_config(settings):
@@ -45,11 +46,11 @@ 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_authorization_policy(TailboneAuthorizationPolicy())
-    pyramid_config.set_authentication_policy(SessionAuthenticationPolicy())
+    pyramid_config.set_security_policy(TailboneSecurityPolicy(api_mode=True))
 
     # always require CSRF token protection
     pyramid_config.set_default_csrf_options(require_csrf=True,
@@ -61,6 +62,12 @@ def make_pyramid_config(settings):
     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:
@@ -70,22 +77,42 @@ def make_pyramid_config(settings):
     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_tailbone_permission_group', 'tailbone.auth.add_permission_group')
-    pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
+    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, **settings):
+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)
 
-    # bring in some Tailbone
-    pyramid_config.include('tailbone.subscribers')
-    pyramid_config.include('tailbone.api')
+    # 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 48b51b39..6983dbea 100644
--- a/tasks.py
+++ b/tasks.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2022 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,27 +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(c, tests=False):
+def release(c, skip_tests=False):
     """
     Release a new version of 'Tailbone'.
     """
-    if tests:
-        c.run('tox')
+    if not skip_tests:
+        c.run('pytest')
 
+    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/Tailbone-{}.tar.gz'.format(__version__))
+
+    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_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 401b5e62..3896befb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,32 +1,19 @@
 
 [tox]
-envlist = py27, py35, py37
+envlist = py38, py39, py310, py311
 
 [testenv]
-commands =
-        pip install --upgrade pip
-        pip install --upgrade setuptools wheel
-        pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon
-        pytest {posargs}
-
-[testenv:py27]
-commands =
-        pip install --upgrade pip
-        pip install --upgrade setuptools wheel
-        pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon SQLAlchemy-Utils<0.36.7 SQLAlchemy-Continuum<1.3.12
-        pytest {posargs}
+deps = rattail-tempmon
+extras = tests
+commands = pytest {posargs}
 
 [testenv:coverage]
 basepython = python3
-commands =
-        pip install --upgrade pip
-        pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[auth,bouncer,db] rattail-tempmon
-        pytest --cov=tailbone --cov-report=html
+extras = tests
+commands = pytest --cov=tailbone --cov-report=html
 
 [testenv:docs]
 basepython = python3
 changedir = docs
-commands =
-        pip install --upgrade pip
-        pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[auth,bouncer,db] 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