diff --git a/deploy/site-profile-universal.json b/deploy/site-profile-universal.json index 03454d5..64b7539 100644 --- a/deploy/site-profile-universal.json +++ b/deploy/site-profile-universal.json @@ -13,6 +13,7 @@ "printedparts", "printers", "slides", + "tools", "usb", "warranty" ], diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 6ae28bc..bc8146d 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -104,7 +104,7 @@ import ToastHost from '../components/ToastHost.vue' import { Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, - Box, KeyRound, LogOut + Box, KeyRound, LogOut, Wrench } from 'lucide-vue-next' import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' @@ -166,6 +166,7 @@ const iconMap = { 'shield': ShieldCheck, 'ruler': Ruler, 'box': Box, + 'wrench': Wrench, } // Default navigation (used as fallback if API fails) diff --git a/plugins/tools/__init__.py b/plugins/tools/__init__.py new file mode 100644 index 0000000..0c0692f --- /dev/null +++ b/plugins/tools/__init__.py @@ -0,0 +1,5 @@ +"""Tech Tools plugin: browser-side utilities for the shop floor.""" + +from .plugin import ToolsPlugin + +__all__ = ['ToolsPlugin'] diff --git a/plugins/tools/frontend/routes.js b/plugins/tools/frontend/routes.js new file mode 100644 index 0000000..aa15285 --- /dev/null +++ b/plugins/tools/frontend/routes.js @@ -0,0 +1,26 @@ +/** + * Tech Tools plugin routes. + * + * `default` = AppLayout child routes; `toplevel` = full-screen routes. + * + * The index sits inside the app shell. Each tool that PRINTS is toplevel, the + * way every other label page in this repo is: printing from inside AppLayout + * would put the sidebar and header on the label stock. + */ +export default [ + { + path: 'tools', + name: 'tools', + component: () => import('./views/ToolsIndex.vue'), + meta: { requiresAuth: true, plugin: 'tools' } + }, +] + +export const toplevel = [ + { + path: '/tools/codes', + name: 'tools-codes', + component: () => import('./views/CodeGenerator.vue'), + meta: { requiresAuth: true, plugin: 'tools' } + }, +] diff --git a/plugins/tools/frontend/tools.js b/plugins/tools/frontend/tools.js new file mode 100644 index 0000000..aa93420 --- /dev/null +++ b/plugins/tools/frontend/tools.js @@ -0,0 +1,67 @@ +// The Tech Tools catalog. +// +// This is the ONLY place a tool has to be declared. Adding one is: write the +// view, add a route in routes.js, add an entry here. The index page groups by +// category and searches over name + description + keywords. +// +// Fields: +// id - stable slug, also the key the index uses +// name - card title +// description - one line, says what it does, not how +// category - group heading on the index +// route - path to open +// standalone - true when the route lives OUTSIDE AppLayout (print pages), +// so the index opens it as a normal link rather than a +// router-link and the user keeps this tab +// keywords - extra search terms someone might type instead of the name +export const TOOLS = [ + { + id: 'code-generator', + name: 'Barcode / QR Generator', + description: 'Make QR or CODE128 labels from typed text, a URL, or a CSV, sized for your label stock.', + category: 'labels', + route: '/tools/codes', + standalone: true, + keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'], + }, +] + +export const CATEGORY_LABELS = { + labels: 'Labels & Printing', + convert: 'Conversion', + network: 'Network', +} + +export function categoryLabel(category) { + return CATEGORY_LABELS[category] || category +} + +// Tools matching a search string, or all of them when the box is empty. +export function searchTools(query) { + const needle = (query || '').trim().toLowerCase() + if (!needle) return TOOLS + return TOOLS.filter(tool => { + const haystack = [tool.name, tool.description, ...(tool.keywords || [])] + .join(' ') + .toLowerCase() + return haystack.includes(needle) + }) +} + +// Tools grouped for the index, categories in CATEGORY_LABELS order so the +// page does not reshuffle as tools are added. +export function groupTools(query) { + const matched = searchTools(query) + const order = Object.keys(CATEGORY_LABELS) + const seen = [...new Set(matched.map(tool => tool.category))] + seen.sort((a, b) => { + const ai = order.indexOf(a) + const bi = order.indexOf(b) + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) + }) + return seen.map(category => ({ + category, + label: categoryLabel(category), + tools: matched.filter(tool => tool.category === category), + })) +} diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue new file mode 100644 index 0000000..3b15fca --- /dev/null +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -0,0 +1,678 @@ + + + + + diff --git a/plugins/tools/frontend/views/ToolsIndex.vue b/plugins/tools/frontend/views/ToolsIndex.vue new file mode 100644 index 0000000..ec547dc --- /dev/null +++ b/plugins/tools/frontend/views/ToolsIndex.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/plugins/tools/manifest.json b/plugins/tools/manifest.json new file mode 100644 index 0000000..3e7b40a --- /dev/null +++ b/plugins/tools/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "tools", + "version": "1.0.0", + "description": "Tech Tools: shop-floor utilities that run entirely in the browser (barcode/QR label generator, with room for more)", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.16.0,<1.0.0", + "provides": { + "features": ["code_generator"] + } +} diff --git a/plugins/tools/plugin.py b/plugins/tools/plugin.py new file mode 100644 index 0000000..ae9c361 --- /dev/null +++ b/plugins/tools/plugin.py @@ -0,0 +1,56 @@ +"""Tech Tools plugin. + +A section for small technician utilities. Every tool here runs entirely in the +browser: no API, no tables, no state. That is deliberate - these are the things +someone reaches for at a bench with no network guarantee, and an air-gapped +site gets them for free. + +The plugin exists so the section can carry a nav entry and be included in or +left out of a per-site build (ADR-013). The tools themselves are frontend-only, +which is why get_blueprint and get_models are both empty. +""" + +import logging +from typing import List, Optional, Type + +from flask import Blueprint, Flask + +from shopdb.plugins.base import BasePlugin, PluginMeta + +logger = logging.getLogger(__name__) + + +class ToolsPlugin(BasePlugin): + """Registers the Tech Tools section. All tools are client-side.""" + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name='tools', + version='1.0.0', + description='Tech Tools: browser-side technician utilities', + author='ShopDB Team', + dependencies=[], + core_version='>=0.16.0,<1.0.0', + ) + + def get_blueprint(self) -> Optional[Blueprint]: + # No API surface. The tools never leave the browser. + return None + + def get_models(self) -> List[Type]: + # No tables. Nothing here is persisted. + return [] + + def get_navigation_items(self) -> List[dict]: + return [ + { + 'name': 'Tech Tools', + 'icon': 'wrench', + 'route': '/tools', + 'position': 47, + }, + ] + + def on_install(self, app: Flask) -> None: + logger.info('Tools plugin installed') diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 19ad305..dc733f0 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -71,6 +71,8 @@ EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev' # notifications indexes businessunitid, then adds the per-type grace window and # the shared board category. EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder' +# warranty adds the proof-of-cover document columns on top of its anchor. +EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof' # Plugins built after the cutover: their 0001 baseline really creates tables the # core chain never owned. @@ -239,9 +241,21 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch): first = app.extensions['plugin_manager'].upgrade_all_plugins() second = app.extensions['plugin_manager'].upgrade_all_plugins() - assert set(first) == set(PLUGIN_TABLE_OWNERS) - assert all(status == 'ok' for status in first.values()), first - assert all(status == 'ok' for status in second.values()), second + # A plugin that owns no tables (Tech Tools: every tool is + # client-side) carries no chain, so upgrade-all reports it + # 'no-migrations'. Assert that explicitly rather than letting it + # widen the 'ok' check, which would also swallow a table-owning + # plugin whose chain silently went missing. + schemaless = set(first) - set(PLUGIN_TABLE_OWNERS) + for name in schemaless: + assert first[name] == 'no-migrations', (name, first[name]) + assert second[name] == 'no-migrations', (name, second[name]) + + migrated = {name: status for name, status in first.items() + if name in PLUGIN_TABLE_OWNERS} + assert set(migrated) == set(PLUGIN_TABLE_OWNERS) + assert all(status == 'ok' for status in migrated.values()), migrated + assert all(second[name] == 'ok' for name in PLUGIN_TABLE_OWNERS), second insp = inspect(db.engine) for plugin in PLUGIN_TABLE_OWNERS: diff --git a/tests/test_plugins/test_tools.py b/tests/test_plugins/test_tools.py new file mode 100644 index 0000000..ac1e30d --- /dev/null +++ b/tests/test_plugins/test_tools.py @@ -0,0 +1,55 @@ +"""Tests for the Tech Tools plugin. + +The plugin owns no API and no tables, so the whole backend contract is: it +loads, it advertises one nav entry, and it claims no database. That last part +matters - a plugin that accidentally returns models would pull the tools +section into the migration and prune-schema machinery it has no business in. +""" + +import json +import os + +from plugins.tools.plugin import ToolsPlugin + + +PLUGIN_DIR = os.path.join(os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))), 'plugins', 'tools') + + +def test_meta_matches_manifest(): + """The manifest is the single source of truth per ADR-002.""" + with open(os.path.join(PLUGIN_DIR, 'manifest.json'), encoding='utf-8') as handle: + manifest = json.load(handle) + + meta = ToolsPlugin().meta + assert meta.name == manifest['name'] + assert meta.version == manifest['version'] + assert meta.core_version == manifest['core_version'] + + +def test_no_backend_surface(): + """Client-side only: no blueprint to register, no tables to migrate.""" + plugin = ToolsPlugin() + assert plugin.get_blueprint() is None + assert plugin.get_models() == [] + + +def test_navigation_item(): + items = ToolsPlugin().get_navigation_items() + assert len(items) == 1 + item = items[0] + assert item['name'] == 'Tech Tools' + assert item['route'] == '/tools' + # The icon has to exist in AppLayout's iconMap or the nav entry renders + # with no glyph. + assert item['icon'] == 'wrench' + + +def test_nav_icon_is_mapped_in_the_frontend(): + """Guard the one cross-file coupling: nav icon name -> AppLayout iconMap.""" + layout = os.path.join(os.path.dirname(PLUGIN_DIR), '..', 'frontend', 'src', + 'views', 'AppLayout.vue') + with open(os.path.normpath(layout), encoding='utf-8') as handle: + source = handle.read() + for item in ToolsPlugin().get_navigation_items(): + assert f"'{item['icon']}':" in source