Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
88
.env.example
Normal file
@@ -0,0 +1,88 @@
|
||||
# shopdb-flask environment template.
|
||||
#
|
||||
# Copy to .env and fill in the values. .env is gitignored. ProductionConfig
|
||||
# refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or
|
||||
# CORS_ORIGINS are missing or use the dev defaults.
|
||||
#
|
||||
# See the DEPLOY wiki page for the full per-site deployment runbook.
|
||||
|
||||
# ---- Flask ----
|
||||
FLASK_APP=wsgi.py
|
||||
|
||||
# Set to 'production' for live sites. Other valid values: 'development',
|
||||
# 'testing'. Production triggers ProductionConfig.validate() at boot.
|
||||
FLASK_ENV=production
|
||||
|
||||
# ---- Required secrets (production refuses to boot without these) ----
|
||||
|
||||
# Generate strong random values, e.g.:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
SECRET_KEY=change-this-to-a-secure-random-string
|
||||
JWT_SECRET_KEY=change-this-to-another-secure-random-string
|
||||
|
||||
# ---- Database (required) ----
|
||||
|
||||
# Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database>?charset=utf8mb4
|
||||
# In docker-compose, host is `db` (the service name).
|
||||
# The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as
|
||||
# utf8mb4 too (see the DEPLOY wiki page). Both must be utf8mb4 to match the schema.
|
||||
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4
|
||||
|
||||
# ---- CORS (required, no wildcards in production) ----
|
||||
|
||||
# Comma-separated list of explicit origins permitted to call the API.
|
||||
# Example for a single-host facility deploy:
|
||||
# CORS_ORIGINS=https://shopdb.facility-a.example.com
|
||||
# Wildcard '*' is rejected by ProductionConfig.validate().
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# ---- JWT lifecycle (optional, defaults shown) ----
|
||||
JWT_ACCESS_TOKEN_EXPIRES=3600
|
||||
JWT_REFRESH_TOKEN_EXPIRES=2592000
|
||||
|
||||
# ---- Logging (optional) ----
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ---- docker-compose only ----
|
||||
# These are read by docker-compose.yml; not used by the Flask app directly.
|
||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
|
||||
MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
|
||||
MYSQL_PORT=3306
|
||||
API_PORT=5001
|
||||
|
||||
# ---- Zabbix integration (optional, for printer supply monitoring) ----
|
||||
ZABBIX_URL=
|
||||
ZABBIX_TOKEN=
|
||||
|
||||
# ---- Per-plugin collector API keys (optional) ----
|
||||
# Per ADR-006, each plugin can accept external collector input at
|
||||
# /api/collector/<pluginname>. The framework checks
|
||||
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
|
||||
# COLLECTOR_API_KEY=
|
||||
# COLLECTOR_API_KEY_COMPUTERS=
|
||||
|
||||
# ---- Employee directory database (optional, read-only) ----
|
||||
# Separate HR/employee lookup DB consumed by the notifications plugin and the
|
||||
# public shopfloor kiosks. Leave unset if the feature is not used; there is no
|
||||
# safe default for the password, so an unset password fails loud.
|
||||
# EMPLOYEE_DB_HOST=
|
||||
# EMPLOYEE_DB_USER=
|
||||
# EMPLOYEE_DB_PASSWORD=
|
||||
# EMPLOYEE_DB_NAME=wjf_employees
|
||||
|
||||
# ---- cmmc_usb database (USB check-in/out) ----
|
||||
# Separate read-write MySQL DB used by the USB plugin to track device
|
||||
# check-in/out, lockers, and the check-in/out log. Leave unset if the feature
|
||||
# is not used; there is no safe default for the password, so an unset password
|
||||
# fails loud.
|
||||
# CMMC_USB_DB_HOST=
|
||||
# CMMC_USB_DB_USER=
|
||||
# CMMC_USB_DB_PASSWORD=
|
||||
# CMMC_USB_DB_NAME=cmmc_usb
|
||||
|
||||
# ---- Subpath deployment (optional) ----
|
||||
# Serve the app under a URL prefix instead of the server root, e.g. as an IIS
|
||||
# Application at /ops under an existing site. The frontend must be rebuilt with
|
||||
# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the
|
||||
# app owns its own site/port (the default). See the INSTALL-WINDOWS-IIS wiki page.
|
||||
# MOUNT_PATH=/ops
|
||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env filter=git-crypt diff=git-crypt
|
||||
78
.gitignore
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node/Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
*.local
|
||||
|
||||
# Secrets and credentials (defensive)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa
|
||||
id_rsa.*
|
||||
*.ppk
|
||||
*.p12
|
||||
*.pfx
|
||||
secrets.json
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
*_secret
|
||||
*_secrets
|
||||
credentials.json
|
||||
scripts/site_imports/wjf/idmap.json
|
||||
476
CHANGELOG.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to shopdb-flask are recorded here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
The product version (`__version__`) and the plugin-contract version
|
||||
(`__contract_version__`) are distinct series with independent bump rules; see
|
||||
ADR-007 and ADR-002.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Asset detail pages (machines, PCs, printers, network devices, measuring
|
||||
tools) now share one canonical card skeleton: Identity -> type-specific ->
|
||||
status -> Location & Organization -> domain -> Custom Fields -> Warranty ->
|
||||
Relationships -> Notes -> audit footer. The location card reads "Location &
|
||||
Organization" on every page. The network device page was rebuilt into the
|
||||
family (its "Asset Information" folded into Identity, "Record Info" converted
|
||||
to the standard audit footer). Printer Notes moved out of mid-page to
|
||||
second-to-last and the printer gained an audit footer. Template reordering
|
||||
only; no data or API changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- List pages keep the current page (and search term) in the URL query, so
|
||||
paging to page 9, opening an item, and hitting browser Back returns to page 9
|
||||
instead of resetting to page 1. Applies to all 18 list views via a shared
|
||||
useListQuery composable; page 1 with no search stays a bare path.
|
||||
- PC detail Installed Applications no longer 500s and silently disappears on
|
||||
real PCs (ComputerInstalledApp had no to_dict); the section renders app
|
||||
name, version, and description again.
|
||||
- Employee detail skips its USB panels when the usb plugin is disabled (no
|
||||
more 404 console noise).
|
||||
- Shopfloor kiosk header text is readable (light on the dark navy header).
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- Collector-driven PC -> printer relationships. The computers collector schema
|
||||
gained optional `defaultprinter` (string) and `printers` (array of strings)
|
||||
fields carrying Win32_Printer identifiers. On ingest each identifier resolves
|
||||
to a printer asset (by windows name / share / hostname / asset number-name or
|
||||
a communications IP) and the PC is linked to it: the default via a
|
||||
`defaultprinter` relationship, the rest via `connectedto`. The links render in
|
||||
the shared Relationships card on both the PC and printer detail pages. The
|
||||
sync is idempotent and archives collector-created links to printers no longer
|
||||
reported (tagged `assetrelationships.label = 'collector:printers'`, so
|
||||
manually-created links are never touched); unresolved identifiers become
|
||||
response warnings, never failures. The collector response carries
|
||||
`printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md.
|
||||
- Searchable custom fields. Each custom-field definition gained a `searchable`
|
||||
flag (Settings > Custom Fields). When on, that field's stored values are
|
||||
matched by global search and a hit routes to the owning asset's detail page.
|
||||
The asset's `search_<type>_enabled` domain toggle still applies, and matches
|
||||
dedupe against built-in-field asset hits so an asset appears once. Inactive or
|
||||
non-searchable fields are never matched.
|
||||
- Single-label sheet-position printing. The single asset-label page
|
||||
(`/print/asset-label/:assettype/:id`) gained an Output control that toggles
|
||||
between the standalone label (unchanged default) and placing that one label at
|
||||
a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the
|
||||
other five cells blank. This prints a single label onto the correct physical
|
||||
spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the
|
||||
legacy shopdb behavior and complementing the batch page's start-cell offset.
|
||||
The ULINE 6-up cell layout and dimensions are replicated from
|
||||
`AssetLabelBatch.vue` (left untouched); encode resolution stays shared via
|
||||
`assetLabel.js`.
|
||||
|
||||
## [0.7.0] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`;
|
||||
`smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings
|
||||
settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and
|
||||
is a graceful no-op (logs a warning, returns False) when email is disabled or
|
||||
the host is unset. The SMTP password is never logged. Three flows use it:
|
||||
(1) New-user welcome + forced first-login password change. Admin-created users
|
||||
(POST `/api/users`) are flagged `mustchangepassword` (new `users` column,
|
||||
migration `7d23_user_mustchangepassword`, default false) and sent a
|
||||
best-effort welcome email with the facility name, username, temporary
|
||||
password, and sign-in link; the account is created even if mail fails
|
||||
(response carries a `warning`). Login returns `mustchangepassword`; the
|
||||
frontend forces the user through a new `/change-password` view (POST
|
||||
`/api/auth/change-password`, jwt-guarded) before the app, and changing the
|
||||
password clears the flag and resets lockout counters. A self-service "Change
|
||||
password" entry is also available from the user menu.
|
||||
(2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe
|
||||
and surfaces any SMTP error with the password scrubbed; wires up the Email /
|
||||
SMTP settings page "Send Test Email" button.
|
||||
(3) On-demand alert/report delivery. POST `/api/reports/email`
|
||||
(reports.export) mails `{subject, columns, rows}` as an HTML table to a
|
||||
supplied recipient or the site `alert_recipients`; an "Email report" button on
|
||||
the Warranty and Toner report pages posts the loaded rows. There is no
|
||||
scheduler: automation is an external cron hitting the endpoint with a scoped
|
||||
API token (PAT). Documented in `docs/CONFIG.md`.
|
||||
- Shared asset label/code generator: a single `/print/asset-label/<assettype>/<id>`
|
||||
page (public, like the other `/print/*` routes) that any asset detail page
|
||||
opens via a "Print Label" button (machines, computers, printers, network
|
||||
devices, measuring tools). A no-print controls panel toggles the layout
|
||||
(`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode),
|
||||
and what the code encodes: the asset page link, asset number, serial number,
|
||||
a per-type custom target template, or - for measuring tools by default - the
|
||||
tool's inspection location code so every tool at one operation shares one
|
||||
code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New
|
||||
`printing` settings seed and surface on the Printing & Labels settings page:
|
||||
`qr_target_machine`, `qr_target_computer`, `qr_target_network_device`,
|
||||
`qr_target_measuring_tool`; `label_default_style` (default `card`) and
|
||||
`label_default_codetype` (default `qr`); and a per-asset-type default for what
|
||||
the code encodes, `label_default_encodes_<type>` (machines default to their
|
||||
machine number, measuring tools to their inspection location code, the rest to
|
||||
a page link), all overridable on the label page itself. When the chosen field
|
||||
has no value (e.g. serial number on an asset with none), the label states so
|
||||
instead of rendering an empty code. Asset payloads now carry a derived
|
||||
`locationcode` (leading token of the resolved own/inherited location name).
|
||||
- Batch label sheets: a "Print Labels" button on each asset list page opens
|
||||
`/print/asset-label-batch/<assettype>`, a multi-select sheet that lays the
|
||||
chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up
|
||||
mini-label format), with a start-cell offset to reuse partial sheets. Shares
|
||||
the same code-type and encode settings/defaults as the single label, so a
|
||||
batch of measuring tools encodes each tool's inspection location code just
|
||||
like the single label does. Restores the ULINE batch printing the legacy
|
||||
shopdb had, generalized across all asset types.
|
||||
- Support-team contact UX: the settings Support Teams page now manages each
|
||||
team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus
|
||||
Add/Edit/Delete) instead of an inline row expander, and the application
|
||||
detail Support card renders Email (`mailto:`) and Microsoft Teams chat
|
||||
(`teams.microsoft.com/l/chat`) action buttons for every contact that has an
|
||||
SSO. Both link targets derive as `sso@<domain>` from a new `site` setting
|
||||
`contact_email_domain` (default `geaerospace.com`; blank hides the buttons),
|
||||
surfaced in Site & Facility settings under Naming & Patterns and read on the
|
||||
frontend via `getContactEmailDomain()`.
|
||||
- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC
|
||||
permissions its own routes enforce, instead of core accumulating every
|
||||
plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The
|
||||
core catalog (`Permission.CORE_PERMISSIONS`) now holds only genuinely core
|
||||
sets (assets, applications, reports, settings, users, audit, apitokens,
|
||||
collector); the 36 permissions for machines, computers, printers, network,
|
||||
knowledgebase, notifications, usb, warranty, and measuringtools moved into
|
||||
each owning plugin's hook. New core helper `full_permission_catalog()` merges
|
||||
core plus every ENABLED plugin's permissions and backs all three consumers:
|
||||
`flask seed permissions`, the role grid (`GET /api/users/permissions`), and
|
||||
API-token scope validation (`ApiToken.unknown_scope_names`). Plugin install
|
||||
and enable seed the plugin's own permissions idempotently. A disabled plugin
|
||||
drops out of the catalog (no new scope grants or role assignments), but its
|
||||
existing `Permission` rows and role links persist so current roles keep
|
||||
working. Docs: `docs/PLUGIN-HOOKS.md` new section, `docs/PLUGIN-GUIDE.md`
|
||||
permissions walkthrough rewritten to the hook, `docs/PLUGIN-QUICKSTART.md`
|
||||
hooks table row.
|
||||
- Personal API tokens (PATs) so scripts and integrations authenticate without
|
||||
the hourly-expiring login JWT (immediate consumer: long legacy-import runs
|
||||
that die when the JWT expires mid-run). New core `apitokens` table + migration
|
||||
`7d21_apitokens` (stores only the sha256 hash of each secret; the full secret
|
||||
`shopdb_pat_<40 hex>` is shown ONCE at creation). New core blueprint
|
||||
`/api/apitokens` (list own / admin `?all=true`; create; rename or deactivate;
|
||||
revoke). A `Bearer shopdb_pat_...` header is recognized before any JWT decode
|
||||
by a before_request shim that mints a request-scoped JWT for the token's
|
||||
owner, so the entire existing auth+authz stack (jwt_required,
|
||||
require_permission, require_role, import mode, current_user) authenticates the
|
||||
PAT as its owner with zero decorator changes; an invalid, revoked, or expired
|
||||
PAT gets a clean 401. `lastusedat` is stamped on use (throttled to at most one
|
||||
write per 60s). Any authenticated user manages their own tokens; admins may
|
||||
list or revoke anyone's. New Settings > API Tokens page (`ApiTokensList.vue`)
|
||||
with a create modal that reveals the secret once (copy button) and an admin
|
||||
All Tokens section. Docs: `docs/IMPORT-API.md` and `docs/CONFIG.md` updated to
|
||||
recommend a PAT for imports. Core feature; no plugin-contract change.
|
||||
- Optional permission scopes on personal API tokens. A token MAY carry a scopes
|
||||
list (permission names, migration `7d22_apitokens_scopes` adds the nullable
|
||||
`apitokens.scopes` JSON column); NULL keeps the original behavior (acts as its
|
||||
owner). A scoped token grants ONLY the listed permissions, intersected with
|
||||
what the owner actually holds at use time, and SUSPENDS the admin-role bypass,
|
||||
so a scoped token minted by an admin is genuinely limited: it is denied on
|
||||
role-gated (`require_role`) endpoints and gets no import mode. The shim mints
|
||||
the request JWT with a `patscopes` claim that `require_permission`,
|
||||
`require_role`, and `import_mode_active` read; normal login JWTs carry no such
|
||||
claim and are unaffected (zero regression). Scopes are validated at write time
|
||||
against the token OWNER's permissions (the scope ceiling - a token can never
|
||||
grant more than its owner holds; when an admin edits another user's token the
|
||||
ceiling is that owner's permissions), rejecting unknown or unheld names 400.
|
||||
Minting/managing tokens now requires the new `apitokens.create` permission
|
||||
(category `apitokens`; admins hold it by default, grantable via the roles UI)
|
||||
rather than being open to any authenticated user. The Settings > API Tokens
|
||||
create/edit modals gain a "Restrict permissions" section (a category-grouped
|
||||
checkbox grid limited to the permissions the creator holds) and the token
|
||||
lists show a full-access / N-permissions access chip.
|
||||
- Managed collector service tokens: the collector ingest API
|
||||
(`/api/collector/<plugin>` + the legacy `/pc` `/apps` `/heartbeat` `/bulk`
|
||||
`/status` endpoints) now ALSO accepts a managed API token scoped to the new
|
||||
`collector.ingest` permission (category `collector`), on top of the existing
|
||||
`COLLECTOR_API_KEY[_<PLUGIN>]` env keys (which stay supported as a
|
||||
bootstrap/legacy fallback - nothing breaks). The token may be presented in
|
||||
`X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer`
|
||||
token; both transports validate the PAT the same way the login shim does
|
||||
(hash lookup, active, unexpired, active owner) via a shared
|
||||
`resolve_api_token` helper refactored out of `apitoken_auth.py`, require
|
||||
`collector.ingest` in the token's scope list AND that the owner holds it, and
|
||||
stamp `lastusedat` (same 60s throttle). A token scoped to ONLY
|
||||
`collector.ingest` is a collector service token: it authorizes the collector
|
||||
API and NOTHING else - the existing scoped-token machinery denies it on every
|
||||
permission- and role-gated route and on import mode, so a leaked collector
|
||||
token cannot touch the regular API. Recommended flow (documented): an admin
|
||||
mints the scoped token (the scope suspends the admin bypass, containing it);
|
||||
rotate by minting a new one, deploying via `site-config.json`, watching
|
||||
`lastusedat`, then revoking the old. The Settings > API Tokens create modal
|
||||
gains a "Collector service token" quick-preset (pre-selects only
|
||||
`collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed
|
||||
collector tokens" section) and `docs/CONFIG.md`. Core feature; no
|
||||
plugin-contract change.
|
||||
- Vendor-model photos on asset detail heroes: computers and printers now
|
||||
surface the linked model's `imageurl` in their extension payloads (the
|
||||
field machines already exposed), and the machine, PC, printer, network
|
||||
device, and measuring tool detail pages render the photo in the hero card
|
||||
when present (hidden cleanly when absent). Network devices and measuring
|
||||
tools have no model link yet, so their heroes stay photo-less until one
|
||||
is added.
|
||||
- Dualpath "single machine" site toggle (`dualpath_single_machine`, default
|
||||
on). A Dualpath relationship pair is one physical dual-bay machine (single
|
||||
controller, bay-selector switch); when on, the machines list, dashboard and
|
||||
machines-by-type counts, and the floor map collapse each pair to one entry
|
||||
(the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden)
|
||||
and show a combined `2007 / 2008` label. The data model is unchanged (both
|
||||
bay records always exist); detail pages stay per-bay and always show a
|
||||
sibling-bay banner regardless of the toggle. Contract surface (plugin
|
||||
contract bumped 0.8.0 -> 0.9.0, additive): new `shopdb.api` helpers
|
||||
`resolve_dualpath_pairs` and `dualpath_single_machine_enabled`, consumed by
|
||||
the machines plugin to collapse pairs contract-purely.
|
||||
- Relationship propagation, wired and data-driven: relationship types
|
||||
declare propagation-through pairs (relationshiptypepropagations M:N,
|
||||
replacing the never-consumed single column); creating a controls link on
|
||||
one Dualpath bay auto-creates it on the partner bay, and
|
||||
`flask relationships propagate` backfills existing data.
|
||||
- Employee photos, mode-aware: self-hosted directory employees support
|
||||
upload/replace/delete (admin), served publicly for kiosk cards; external
|
||||
directory mode passes the HR-supplied picture URL through read-only. One
|
||||
resolver feeds the shopfloor recognition/recert cards and the employee
|
||||
detail hero in either mode.
|
||||
- Vendor-model photo management. New admin-gated core endpoints
|
||||
`POST /api/models/<modelid>/image` (multipart `file`, png/jpg/jpeg/gif/webp/svg,
|
||||
one image per model, replace semantics) and
|
||||
`DELETE /api/models/<modelid>/image`, plus the public
|
||||
`GET /api/models/image/<filename>` serve route. Uploads land in
|
||||
`instance/modelimages/` (survives upgrades, backed up with the rest of
|
||||
`instance/`) and set `models.imageurl` to the served URL; the manual Image URL
|
||||
field still accepts external URLs and the shipped `/images/models/*` assets
|
||||
(upload is additive). Delete only removes files we own under the instance dir.
|
||||
The Models settings page grows a thumbnail, Upload/Replace, and Remove
|
||||
controls in the edit modal. Asset hero images (e.g. the machine badge) read
|
||||
`imageurl` unchanged, so uploaded photos render with no consumer changes.
|
||||
|
||||
- Application support teams with contacts, replacing the legacy
|
||||
supportteams/appowners pair. New core `supportteamcontacts` table (multiple
|
||||
named contacts per team, ordered by `sortorder`); `supportteams` keeps
|
||||
`teamname` (now unique) and `teamurl` (a ServiceNow group deep link) and
|
||||
sheds the single-owner `appownerid` FK. New core blueprint at
|
||||
`/api/supportteams` (team + nested contact CRUD, admin-gated; `?teamname`
|
||||
exact-match lookup for import; delete a team 409s while any application still
|
||||
references it). Migration `7d18_supportteamcontacts` migrates each legacy
|
||||
team's app owner into one contact. Application payloads now flatten
|
||||
`supportteamname`, `teamurl`, and the team's active `contacts`; a Support
|
||||
card on the application detail page and a new `settings/supportteams`
|
||||
management page render them.
|
||||
|
||||
- Import mode: a complete, idempotent HTTP migration surface so a migration script
|
||||
can import the classic ASP shopdb through the API alone (no direct DB writes).
|
||||
- Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new
|
||||
`shopdb.api` helpers `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime` in `shopdb/utils/import_mode.py`. When the caller is
|
||||
an admin AND sends header `X-Import-Mode: true`, create/update endpoints
|
||||
accept optional `createddate` / `modifieddate` (ISO or legacy
|
||||
`YYYY-MM-DD HH:MM:SS`, naive-UTC) and preserve them instead of stamping now.
|
||||
Non-admin or missing header: the fields are ignored exactly as before.
|
||||
Wired into every timestamped import target: assets (all five type plugins),
|
||||
vendors, models, modeltypes, businessunits, locations, operating systems,
|
||||
applications, knowledge base, USB devices, and asset relationships.
|
||||
- Natural-key exact-match lookup filters for the documented
|
||||
lookup-then-upsert idempotency recipe: `assetnumber` on all five asset
|
||||
plugin list endpoints; `vendor`, `modelnumber`, `modeltype`,
|
||||
`businessunit`, `locationname`, `osname`/`osversion`, `appname`,
|
||||
knowledge base `linkurl`/`shortdescription`, warranty `servicetag`/`vendor`,
|
||||
and notification `ticketnumber`.
|
||||
- Backdated event history: in import mode the selfhosted USB checkout/checkin
|
||||
endpoints accept optional `checkouttime` / `checkintime` overrides so
|
||||
migrated `usbcheckouts` rows keep their real event times.
|
||||
- New operator manual `docs/IMPORT-API.md` grounded in the real `prodscratch`
|
||||
legacy schema: order of operations, a full table-by-table mapping, honest
|
||||
no-target list with dispositions, a worked idempotent Python importer, and
|
||||
row-count parity checks.
|
||||
|
||||
### Changed
|
||||
|
||||
- System Settings is no longer one tabbed page. The inner tab bar is gone and
|
||||
each section is its own routed settings page reached through the settings
|
||||
rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types
|
||||
(the former Integrations dumping ground, now split three-plus ways), plus
|
||||
Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication,
|
||||
Audit & Logging, Asset Identifiers, and Global Search. The rail regroups
|
||||
these under Site & Facility, Integrations, Communication, Search & Identity,
|
||||
and Access & Security. Shared load/save/upload plumbing moved into a
|
||||
`useSystemSettings` composable so the pages stay thin. Old bookmarks keep
|
||||
working: `/settings/system` and every `/settings/system?tab=<key>` redirect
|
||||
to the matching new page.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Audit log: hovering a user's SSO now shows their full name (best-effort,
|
||||
resolved from the employee directory in either mode).
|
||||
- Refreshed the internal status docs to match the code (project active
|
||||
state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0),
|
||||
corrected the get_asset_panels endpoint path in the hook docstring, and
|
||||
removed leftover debug console.log lines.
|
||||
- Measuring tools wired into the remaining cross-cutting surfaces an audit
|
||||
found them missing from: generic asset serialization (typedata + pluginid,
|
||||
which also fixes relationship-card links to tools), map subtype
|
||||
filtering/coloring and the MapEditor filter, dashboard totals, warranty
|
||||
asset links (via a new by-asset detail route), and the two ADR-010 hook
|
||||
declarations (presentation route token corrected; the calibration
|
||||
map-overlay endpoint now actually exists). The login avatar also resolves
|
||||
through the employee-photo helper, so self-hosted uploads show.
|
||||
- Measuring tools are now wired into the asset-identifier matrix and global
|
||||
search. The Settings identifier matrix gains a Measuring Tool column and the
|
||||
gauge-lab and maintenance reference inputs/rows on the measuring-tool form
|
||||
and detail pages honor those per-type toggles (a maintenance-reference field
|
||||
was added, matching the other asset types). Global search gains a Measuring
|
||||
Tools domain toggle and filter chip, routes measuring-tool hits to
|
||||
`/measuringtools/<id>` (previously the generic `/assets/<id>` fallback), and
|
||||
matches on `gaugelabreference` so a gage-tag lookup resolves the tool.
|
||||
- Site & Facility settings page renders booleans as toggles and the
|
||||
directory-mode settings as dropdowns, with labels and help text for every
|
||||
field (no more raw keys or type-true/false boxes).
|
||||
- System Settings tabs follow the URL: clicking a settings-rail link that
|
||||
only changes the ?tab= query (Branding, Floor Map) now switches the right
|
||||
panel, tab clicks update the URL, and browser back/forward restore tabs.
|
||||
- Following a relationship link between two assets of the same type now loads
|
||||
the destination page instead of stale content (router-view keyed on path;
|
||||
query-only URL changes still avoid a remount).
|
||||
- Asset relationships card no longer lists a symmetric peer twice. Relationship
|
||||
types gain `relationshiptypes.isdirectional` (migration
|
||||
`7d19_relationshiptype_directional`; seeded false for the connection-like
|
||||
types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet,
|
||||
USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/
|
||||
defaultprinter). The card now collapses every stored direction row of a
|
||||
symmetric type into one direction-blind "Connected" entry per peer (deleting
|
||||
it removes all collapsed rows), while directional types drop the
|
||||
Outgoing/Incoming headers for inline `Type -> peer` / `<- Type from peer`
|
||||
phrasing. The type CRUD and the per-asset relationships endpoint carry
|
||||
`isdirectional`; the Relationship Types settings page gains a Directional
|
||||
toggle.
|
||||
|
||||
## [0.6.0] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- measuringtools plugin (ADR-005): gage-lab instruments as Asset extensions
|
||||
with type lookup (color-coded), calibration tracking (derived
|
||||
overdue/due-soon/current status), calibration report, and full frontend.
|
||||
Built as the framework exemplar; docs/PLUGIN-GUIDE.md walks through its
|
||||
construction step by step as the plugin-system tutorial.
|
||||
- CSV export on the Warranty and Toner report pages; per-report filter
|
||||
controls (business unit, asset type, location, application, limit) on the
|
||||
inline core reports; report open-state is URL-backed and deep-linkable.
|
||||
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin now
|
||||
carries its own chain with a stamp-only anchor; new plugin schema changes
|
||||
land in `plugins/<name>/migrations/`, never the core chain. Deploys run
|
||||
`flask plugin upgrade-all` after `flask db upgrade`.
|
||||
- Frontend plugin route gating (ADR-009): a disabled backend plugin's pages
|
||||
redirect to the dashboard; new public `GET /api/plugins/enabled`.
|
||||
- `get_reports()` plugin hook (plugin contract 0.5.0 -> 0.6.0): plugins
|
||||
contribute their own report cards; warranty and toner cards moved off the
|
||||
hardcoded frontend list.
|
||||
- Reports hub grouped by category with a search filter.
|
||||
- Configurable QR label targets: `qr_target_printer` / `qr_target_usb`
|
||||
settings (blank = the asset's own page, else a URL template with
|
||||
placeholders) and a `usb_label_style` barcode/QR toggle for USB
|
||||
mini-labels. New Settings > Printing & Labels section.
|
||||
- Site palette theming: optional `brand_primary_dark_color`,
|
||||
`brand_accent_color`, `brand_sidebar_color` settings applied at bootstrap.
|
||||
- Collector integration guide rewrite: header-only auth reference and a
|
||||
paste-ready GE-Enforce PowerShell reporting function.
|
||||
|
||||
### Changed
|
||||
|
||||
- Equipment -> machines rename (backend). The equipment plugin is now the
|
||||
machines plugin: `/api/equipment` -> `/api/machines`, tables
|
||||
`equipment`/`equipmenttypes` -> `machines`/`machinetypes` (columns
|
||||
`equipmentid` -> `machineid`, `equipmenttypeid` -> `machinetypeid`,
|
||||
`equipmenttype` -> `machinetype`), permissions `equipment.*` ->
|
||||
`machines.*`, assettype value `equipment` -> `machine`. The legacy core
|
||||
`machinetypes` lookup (it types the vendor MODELS catalog, not machine
|
||||
instances) is renamed to `modeltypes` (`/api/machinetypes` ->
|
||||
`/api/modeltypes`, `models.machinetypeid` -> `models.modeltypeid`) to
|
||||
free the name. Data flips migrate assettypes, auditlog entitytype,
|
||||
settings keys (`identifier_*_equipment_enabled` ->
|
||||
`identifier_*_machine_enabled`, `search_equipment_enabled` ->
|
||||
`search_machine_enabled`), and permission rows in place; plugins.json
|
||||
registry entries carry over automatically. Upgrade: run
|
||||
`flask db upgrade` then `flask plugin upgrade-all`.
|
||||
|
||||
- Inter (variable) replaces Roboto, bundled locally - no Google Fonts
|
||||
fetch, so air-gapped installs render correctly. Tables use tabular
|
||||
numerals.
|
||||
- ServiceNow defaults point at the current geaerospaceqa.service-now.com
|
||||
global search (search, incident, and change links).
|
||||
|
||||
### Fixed
|
||||
|
||||
- USB frontend remapped to the actual API shape (`device_id` /
|
||||
`device_desc`): device list, detail, form, label batch, and the employee
|
||||
profile's checked-out/history panels were all reading dead legacy fields.
|
||||
- External-mode `GET /api/usb/checkouts/active` now honors the `badge`
|
||||
filter.
|
||||
- Warranties list page no longer demands login (matches every other list
|
||||
page; reads were already public).
|
||||
- Removed the dead legacy Warranty Status report (always-zero buckets from
|
||||
a retired column); the warranty plugin's report is the real one.
|
||||
- Pruned dead usbApi client methods that had no backend routes.
|
||||
|
||||
## [0.5.0] - 2026-07-10
|
||||
|
||||
First release cut with a version, tag, changelog, and CI. Focused on
|
||||
letting other GE Aerospace sites stand up their own self-hosted instance
|
||||
(single-tenant per ADR-004).
|
||||
|
||||
### Added
|
||||
|
||||
- First-run setup wizard (`/setup`): creates the initial superadmin
|
||||
in-app, configures each plugin (create tables here vs connect your own
|
||||
database), uploads light/dark floor-map blueprints, and seeds starter
|
||||
reference data.
|
||||
- Self-hosted employee directory and USB plugins: in-app management plus
|
||||
CSV import, no external database required. Both ship default-disabled
|
||||
with an enable-time provisioning note.
|
||||
- Dell warranty plugin: real Dell provider, bulk warranty sync,
|
||||
add-warranty from asset pages, PC hero warranty badge, disk-cached Dell
|
||||
API token.
|
||||
- Custom fields, and a two-pane settings shell with tabbed, searchable
|
||||
System Settings and Settings index pages.
|
||||
- Dashboard defaults (visitor-IP to business-unit mapping) for kiosk
|
||||
displays; printer installer endpoint (data plus floor-map positions).
|
||||
- Global toast notifications replacing `alert()` calls.
|
||||
- Multi-stage Docker build that compiles the Vue frontend and ships
|
||||
`frontend/dist`, which Flask serves.
|
||||
- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides;
|
||||
reconciled README, DEPLOY, status docs, and ROADMAP.
|
||||
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort
|
||||
CI (backend tests, naming/style gate, frontend build).
|
||||
|
||||
### Changed
|
||||
|
||||
- Plugin contract (`__contract_version__`) settled at 0.5.0: full plugin
|
||||
import surface exposed via `shopdb.api`, dead search hook removed, and
|
||||
the dashboard-widgets hook wired to a real consumer.
|
||||
- Role-based access control now enforced on write routes, including
|
||||
admin-only guards on dashboard-defaults writes.
|
||||
- Branding, ServiceNow integration, employee-ID pattern, printer
|
||||
hostname template, and floor-plan blueprints are settings-driven and
|
||||
per-site configurable, with GE defaults preserved as shipped fallbacks
|
||||
(branding and floor-plan configurability landed in this release; some
|
||||
consumer wiring continues under Unreleased).
|
||||
|
||||
### Security
|
||||
|
||||
- Dashboard-defaults writes now require admin authorization instead of
|
||||
any authenticated user.
|
||||
- Collector error responses no longer leak exception detail; failures are
|
||||
logged server-side with generic client-facing messages.
|
||||
- Login rate limiting added (IP-based fixed window) on top of the
|
||||
existing account lockout.
|
||||
|
||||
### BREAKING
|
||||
|
||||
- Collector API key must now be sent in the `X-API-Key` header. The
|
||||
api-key-in-querystring fallback has been removed. Update any collector
|
||||
integration that passed the key as a query parameter. See
|
||||
`docs/COLLECTOR-INTEGRATION.md`.
|
||||
|
||||
162
CONTRIBUTING.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Contributing to ShopDB Flask
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Naming principles
|
||||
|
||||
1. Spell it out. If a non-technical user looking at a report would not understand a column at a glance, rename it.
|
||||
2. No project-specific shorthand. A name either uses a word in full, or uses an acronym from the allowed list below.
|
||||
3. Consistency beats cleverness. Once a name is committed and used in code, do not rename it for marginal improvement.
|
||||
4. When unsure, ask: "would a new shop-floor IT person understand this?"
|
||||
|
||||
### Database
|
||||
|
||||
- **Table names**: lowercase, concatenated, plural. No dashes, no underscores.
|
||||
- Good: `machines`, `pctypes`, `networkdevices`, `businessunits`, `auditlogs`
|
||||
- Bad: `machine_types`, `network-devices`, `BusinessUnits`
|
||||
- **Column names**: lowercase, concatenated, singular. No dashes, no underscores.
|
||||
- Good: `machineid`, `machinenumber`, `lastzabbixsync`, `isactive`
|
||||
- Bad: `machine_id`, `last_zabbix_sync`, `IsActive`
|
||||
- **Foreign keys**: referenced table name (singular) + `id`.
|
||||
- Good: `locationid` references `locations`, `vendorid` references `vendors`
|
||||
- **Booleans**: prefix with `is` or `has`.
|
||||
- Good: `isactive`, `isshopfloor`, `hasprinter`
|
||||
- **Index names**: `idx_<table>_<column>`. Underscores allowed here only because indexes are infrastructure, not data.
|
||||
- Good: `idx_machines_locationid`
|
||||
|
||||
### Allowed acronyms (closed list, expand by PR)
|
||||
|
||||
- **Universal**: `id`, `url`, `api`, `http`, `https`, `json`, `jwt`, `sql`, `os`, `ip`, `dns`, `csv`, `pdf`, `cors`, `ttl`, `uuid`, `html`, `css`, `orm`
|
||||
- **Domain (shop floor / IT infra)**: `cmm`, `cnc`, `pc`, `usb`, `vnc`, `winrm`, `ssh`, `ssl`, `tls`, `tcp`, `udp`, `smtp`, `ldap`, `vlan`, `sso`, `dnc`, `focas`, `clm`, `mtconnect`
|
||||
|
||||
Any acronym not on these lists must be spelled out. Proposing a new acronym is a separate PR that updates this list.
|
||||
|
||||
**Third-party imports are exempt.** Library and stdlib module paths cannot be renamed. `from sqlalchemy.orm import ...`, `import importlib.util`, `from werkzeug.utils import ...` are all fine even if they contain words that would otherwise be banned.
|
||||
|
||||
### Banned shorthand
|
||||
|
||||
`cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`, and any other domain-specific shortening. Spell it out: `config`, `context`, `manager`, `request`, `response`, `environment`, `utilities`. Note: `db` as a standalone variable name is banned, but `db` as a prefix in `database` or as part of project name `shopdb` is fine.
|
||||
|
||||
**Suffix exception.** Banned shorthand applies to standalone identifiers. As a suffix where the prefix establishes meaning, it is acceptable:
|
||||
|
||||
- `printers_bp`, `auth_bp`, `network_bp` are fine. The prefix names what blueprint, the `_bp` suffix is conventional for Flask blueprints.
|
||||
- `request_obj` is fine when disambiguating from imported `request`.
|
||||
- `bp` alone as a variable name is not fine. Always pair with a meaningful prefix.
|
||||
|
||||
### Code
|
||||
|
||||
#### Python
|
||||
|
||||
- **Variables and functions holding a database value**: match the column name exactly. No conversion to snake_case.
|
||||
- DB column `machineid` -> Python variable `machineid`, attribute `Machine.machineid`, dict key `{"machineid": 1}`
|
||||
- DB column `lastzabbixsync` -> Python variable `lastzabbixsync`
|
||||
- **Pure code variables and functions** (loop counters, helpers, anything that does not mirror a DB field): snake_case per PEP 8.
|
||||
- Good: `loop_count`, `current_user`, `validate_input()`
|
||||
- **Classes**: PascalCase. Spell out fully.
|
||||
- Good: `PrinterData`, `AssetType`, `NetworkDevice`
|
||||
- **Modules / file names**: lowercase. Underscores allowed where it improves readability (PEP 8 permits this for modules).
|
||||
- Good: `employee_service.py`, `asset_routes.py`
|
||||
|
||||
#### JavaScript / Vue
|
||||
|
||||
- **Variables**: camelCase per JS convention.
|
||||
- Good: `currentUser`, `isLoading`
|
||||
- **Variables holding API field values**: match the API field name exactly. Do NOT convert to camelCase.
|
||||
- API returns `{"machineid": 1}` -> JS `response.machineid`, NOT `response.machineId`
|
||||
- API returns `{"lastzabbixsync": "..."}` -> JS `device.lastzabbixsync`
|
||||
- **Components**: PascalCase. Spell out fully.
|
||||
- Good: `AssetDetail.vue`, `MachineForm.vue`, `PrinterList.vue`
|
||||
- **CSS classes**: lowercase with dashes.
|
||||
- Good: `asset-detail`, `machine-form`
|
||||
|
||||
#### API
|
||||
|
||||
- **Endpoints**: lowercase, plural nouns. No underscores or dashes.
|
||||
- Good: `/api/machines`, `/api/networkdevices`, `/api/businessunits`
|
||||
- **Query parameters**: lowercase, concatenated. Match column names where applicable.
|
||||
- Good: `?locationid=5`, `?isactive=true`
|
||||
- **Response keys**: match database column names exactly.
|
||||
- Good: `{"machineid": 1, "lastzabbixsync": "2026-01-12T10:00:00Z"}`
|
||||
|
||||
### Examples
|
||||
|
||||
```python
|
||||
# Good - DB-mirrored fields keep column names; pure code uses snake_case
|
||||
class PrinterData(db.Model):
|
||||
__tablename__ = 'printerdata'
|
||||
|
||||
machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'))
|
||||
lastzabbixsync = db.Column(db.DateTime)
|
||||
isnetworkprinter = db.Column(db.Boolean)
|
||||
|
||||
|
||||
def list_printers_by_location(locationid: int) -> list[dict]:
|
||||
printers = PrinterData.query.filter_by(locationid=locationid).all()
|
||||
result_count = len(printers)
|
||||
return [{"machineid": p.machineid, "isnetworkprinter": p.isnetworkprinter} for p in printers]
|
||||
```
|
||||
|
||||
```python
|
||||
# Bad - PEP 8-style underscores on DB fields, banned shorthand, project acronym not on allowed list
|
||||
class PrinterData(db.Model):
|
||||
__tablename__ = 'printer_data'
|
||||
|
||||
machine_id = db.Column(db.Integer)
|
||||
last_zabbix_sync = db.Column(db.DateTime)
|
||||
is_net_prn = db.Column(db.Boolean)
|
||||
|
||||
|
||||
def list_prns_by_loc(loc_id):
|
||||
bp_ctx = get_context()
|
||||
cfg = load_cfg()
|
||||
return PrinterData.query.filter_by(loc_id=loc_id).all()
|
||||
```
|
||||
|
||||
### Style policy (non-naming)
|
||||
|
||||
- No emojis in code, comments, documentation, string literals, or UI.
|
||||
- No em-dashes (U+2014), en-dashes (U+2013), Unicode arrows, or smart quotes anywhere. Plain ASCII only.
|
||||
- Comments default to none. Add one only when the WHY is non-obvious. Keep comments terse (lite caveman style is fine for inline `#` and `//` comments). Docstrings stay normal English.
|
||||
- Dark theme is default. Keep UI functional and professional.
|
||||
|
||||
### File structure
|
||||
|
||||
- Core models: `shopdb/core/models/`
|
||||
- Core API routes: `shopdb/core/api/`
|
||||
- Core services: `shopdb/core/services/`
|
||||
- Plugin code: `plugins/<plugin>/{models,api,services,schemas}/`
|
||||
|
||||
### Plugin development
|
||||
|
||||
When creating a new plugin:
|
||||
|
||||
1. Create directory `plugins/<name>/`. Use the scaffold (`flask plugin new <name>`) when available.
|
||||
2. Include `manifest.json` with metadata. This is the single source of truth for plugin name, version, dependencies, api_prefix.
|
||||
3. Extend `BasePlugin` class.
|
||||
4. Follow naming conventions above.
|
||||
5. Run `flask plugin install <name>` to install.
|
||||
|
||||
See the PLUGIN-QUICKSTART wiki page (when published) for the 30-minute walkthrough.
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
|
||||
```bash
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
Tests are required for:
|
||||
- New routes (smoke + happy path + 1-2 edge cases)
|
||||
- New plugin hooks (contract test)
|
||||
- Schema migrations (before/after data integrity)
|
||||
|
||||
## Code review checklist
|
||||
|
||||
- [ ] No underscores in table or column names
|
||||
- [ ] No banned shorthand or unapproved acronyms in any new identifier
|
||||
- [ ] DB-mirrored Python and JS variables match column names exactly
|
||||
- [ ] API response keys match column names exactly
|
||||
- [ ] No em-dashes, en-dashes, smart quotes, Unicode arrows, or emojis (run `grep -rP '[^\x00-\x7F]' --include='*.py' --include='*.vue' --include='*.js' .`)
|
||||
- [ ] Plugin follows `BasePlugin` interface and contract version
|
||||
- [ ] Tests included for new functionality
|
||||
80
Dockerfile
Normal file
@@ -0,0 +1,80 @@
|
||||
# shopdb-flask single-site container.
|
||||
#
|
||||
# One image, one site. Per ADR-004, each adopting facility runs its own
|
||||
# stack with its own DB, secrets, and enabled-plugin list. This image
|
||||
# bundles all eleven core plugins (computers, employees, knowledgebase,
|
||||
# machines, measuringtools, network, notifications, printers, slides, usb,
|
||||
# warranty);
|
||||
# install them at runtime with `flask plugin install <name>`.
|
||||
#
|
||||
# The frontend is built in a first stage and its dist output is copied into
|
||||
# the final image so Flask can serve the SPA (register_frontend_routes in
|
||||
# shopdb/__init__.py resolves <repo>/frontend/dist).
|
||||
#
|
||||
# Build:
|
||||
# docker build -t shopdb-flask .
|
||||
# Run (with .env):
|
||||
# docker run --env-file .env -p 5001:5001 shopdb-flask
|
||||
|
||||
# ---- Stage 1: build the Vue frontend ----
|
||||
FROM node:20-slim AS frontendbuild
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy only the manifests first so `npm ci` caches on dependency changes.
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
# Output lands in /build/dist (Vite default), copied into the final stage below.
|
||||
|
||||
# ---- Stage 2: Python application image ----
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& pip install --no-cache-dir gunicorn
|
||||
|
||||
COPY shopdb/ ./shopdb/
|
||||
COPY plugins/ ./plugins/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY wsgi.py ./
|
||||
|
||||
# Built SPA from stage 1. Flask serves it via register_frontend_routes.
|
||||
COPY --from=frontendbuild /build/dist ./frontend/dist
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash shopdb \
|
||||
&& chown -R shopdb:shopdb /app
|
||||
USER shopdb
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl --fail --silent http://localhost:5001/api/auth/login -X POST \
|
||||
-H "Content-Type: application/json" -d '{}' \
|
||||
| grep -q "VALIDATION_ERROR" || exit 1
|
||||
|
||||
CMD ["gunicorn", \
|
||||
"--bind", "0.0.0.0:5001", \
|
||||
"--workers", "4", \
|
||||
"--timeout", "60", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-", \
|
||||
"wsgi:app"]
|
||||
243
README.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# ShopDB Flask
|
||||
|
||||
A modern rewrite of the classic ASP/VBScript ShopDB application using Flask (Python) and Vue 3. This application manages shop floor machines, PCs, printers, applications, and related infrastructure for manufacturing environments.
|
||||
|
||||
## Overview
|
||||
|
||||
ShopDB tracks and manages:
|
||||
- **Machines** - CNC equipment, CMMs, inspection systems, etc.
|
||||
- **PCs** - Shopfloor computers, engineering workstations
|
||||
- **Printers** - Network printers with Zabbix supply integration
|
||||
- **Network devices** - Switches, routers, and the subnet browser
|
||||
- **Measuring tools** - Gage-lab instruments with calibration tracking
|
||||
- **Applications** - Software deployed across the shop floor, with per-PC install tracking
|
||||
- **Employees** - Directory, recognition and training notifications
|
||||
- **Warranties** - Coverage records with Dell warranty lookups
|
||||
- **USB devices** - CMMC check-in/out tracking
|
||||
- **Knowledge Base** - Documentation and troubleshooting guides
|
||||
- **GE-Enforce manifests** - Imaging/software manifest editing and fleet compliance
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Python 3.12 with Flask
|
||||
- SQLAlchemy ORM
|
||||
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
|
||||
- JWT authentication
|
||||
- Plugin architecture for extensibility
|
||||
|
||||
**Frontend:**
|
||||
- Vue 3 with Composition API
|
||||
- Vue Router for navigation
|
||||
- Pinia for state management
|
||||
- Vite build system
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
shopdb-flask/
|
||||
├── shopdb/ # Flask application
|
||||
│ ├── core/
|
||||
│ │ ├── api/ # REST API endpoints
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Validation schemas
|
||||
│ │ └── services/ # Business logic
|
||||
│ ├── plugins/ # Plugin system
|
||||
│ └── utils/ # Shared utilities
|
||||
├── frontend/ # Vue 3 application
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ ├── router/ # Route definitions
|
||||
│ │ └── stores/ # Pinia stores
|
||||
│ └── public/ # Static assets
|
||||
├── plugins/ # Bundled and external plugins
|
||||
├── migrations/ # Alembic migration chain (flask db upgrade)
|
||||
├── scripts/ # Import and utility scripts
|
||||
└── tests/ # Test suite
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
To maintain consistency with the legacy ShopDB database and codebase, the following naming standards apply:
|
||||
|
||||
### Database
|
||||
|
||||
- **Table names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `assets`, `computers`, `printers`, `businessunits`
|
||||
- **Column names:** Lowercase, single word, no underscores or dashes
|
||||
- Examples: `assetid`, `assetnumber`, `hostname`, `isactive`, `createddate`
|
||||
- **Foreign keys:** Referenced table name + `id`
|
||||
- Examples: `locationid`, `vendorid`, `modelnumberid`, `computertypeid`
|
||||
- **Boolean columns:** Prefixed with `is` or `has`
|
||||
- Examples: `isactive`, `isshopfloor`, `iscolor`, `isdhcp`, `islicenced`
|
||||
|
||||
### Code
|
||||
|
||||
- **Python variables:** Follow database naming where applicable (lowercase, no underscores for model fields)
|
||||
- **JavaScript variables:** camelCase for local variables, but match API field names from backend
|
||||
- **Vue components:** PascalCase for component names
|
||||
- **CSS classes:** Lowercase with dashes for multi-word classes
|
||||
|
||||
### API
|
||||
|
||||
- **Endpoints:** Lowercase, plural nouns
|
||||
- Examples: `/api/machines`, `/api/computers`, `/api/locations`
|
||||
- **Query parameters:** Lowercase, single word
|
||||
- Examples: `?locationid=5`, `?isactive=true`, `?assettype=computer`
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
- No emojis in code, comments, documentation, or UI
|
||||
- Keep UI functional and professional
|
||||
- Dark theme is the default
|
||||
- Consistent table layouts across all list views
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12
|
||||
- Node.js 18+
|
||||
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
|
||||
|
||||
ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the
|
||||
test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory
|
||||
SQLite). Do not run dev or production against SQLite.
|
||||
|
||||
### Distribution
|
||||
|
||||
The application is distributed through the internal GE Aerospace git server. Clone
|
||||
it from there; there is no public package or image registry.
|
||||
|
||||
### Fast path (Docker)
|
||||
|
||||
The Docker image builds the Vue frontend and serves it from the API container,
|
||||
so a container deploy needs no separate Node build step.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS,
|
||||
# and the MYSQL_* passwords (see the CONFIG wiki page for every variable).
|
||||
|
||||
docker compose up -d --build
|
||||
|
||||
# Create the schema and seed the platform data (idempotent, safe to re-run):
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask seed permissions
|
||||
docker compose exec api flask seed settings
|
||||
docker compose exec api flask seed reference-data
|
||||
```
|
||||
|
||||
Then browse to the site and complete the first-run setup wizard at `/setup`
|
||||
(it creates the first admin account and captures site identity). To create the
|
||||
admin headlessly instead of using the wizard:
|
||||
|
||||
```bash
|
||||
docker compose exec api flask seed admin --username admin --email admin@facility.example.com
|
||||
```
|
||||
|
||||
### Manual path (venv + Node)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials and secrets.
|
||||
|
||||
flask db upgrade
|
||||
flask seed permissions
|
||||
flask seed settings
|
||||
flask seed reference-data
|
||||
flask run
|
||||
|
||||
# Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # dev server on :5173
|
||||
npm run build # production build into frontend/dist (served by Flask)
|
||||
```
|
||||
|
||||
Complete first-run setup at `/setup`, or run `flask seed admin` for a headless
|
||||
admin account.
|
||||
|
||||
To import a site's legacy data, use the HTTP import surface: an admin API
|
||||
token plus the IMPORT-API page in the project wiki drive the whole migration
|
||||
through documented endpoints (`X-Import-Mode` preserves original timestamps).
|
||||
`scripts/site_imports/wjf/` is the West Jefferson reference loader.
|
||||
|
||||
For the full per-site deployment runbook see the DEPLOY page in the project wiki;
|
||||
for every environment variable and Setting key see the CONFIG page in the project wiki.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (`.env`):
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | MySQL connection string |
|
||||
| `SECRET_KEY` | Flask secret key |
|
||||
| `JWT_SECRET_KEY` | JWT signing key |
|
||||
| `JWT_ACCESS_TOKEN_EXPIRES` | Access token TTL (seconds) |
|
||||
| `LOG_LEVEL` | Logging verbosity |
|
||||
|
||||
## API Documentation
|
||||
|
||||
The REST API follows standard conventions:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/machines` | List machines (filterable by type) |
|
||||
| GET | `/api/machines/:id` | Get machine details |
|
||||
| POST | `/api/machines` | Create machine |
|
||||
| PUT | `/api/machines/:id` | Update machine |
|
||||
| DELETE | `/api/machines/:id` | Soft delete machine |
|
||||
|
||||
Each asset plugin exposes the same CRUD pattern on its own prefix
|
||||
(`/api/computers`, `/api/printers`, `/api/network`, `/api/measuringtools`),
|
||||
and cross-cutting asset endpoints live under `/api/assets`.
|
||||
|
||||
Query parameters for list endpoints:
|
||||
- `page` - Page number (default: 1)
|
||||
- `perpage` - Items per page
|
||||
- `sort` - Sort field
|
||||
- `dir` - Sort direction (asc/desc)
|
||||
- `search` - Search term
|
||||
- `assettype` - Filter by asset type (computer, printer, machine, networkdevice, measuringtool)
|
||||
|
||||
## Plugin System
|
||||
|
||||
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
|
||||
|
||||
The image bundles twelve plugins; only the ones a site installs are loaded:
|
||||
|
||||
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
|
||||
- **employees** - Employee directory
|
||||
- **geenforce** - GE-Enforce imaging/software manifests and fleet compliance
|
||||
- **machines** - CNC, CMM, and other shop-floor machines
|
||||
- **measuringtools** - Gage-lab instruments with calibration tracking
|
||||
- **knowledgebase** - Documentation and troubleshooting guides
|
||||
- **network** - Network devices and subnets
|
||||
- **notifications** - Shopfloor notifications and recognition feed
|
||||
- **printers** - Extended printer management with Zabbix integration
|
||||
- **slides** - TV/kiosk slideshows
|
||||
- **usb** - CMMC USB check-in/out tracking
|
||||
- **warranty** - Warranty records with Dell lookups
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
This project replicates functionality from the classic ASP/VBScript ShopDB site. Key mappings:
|
||||
|
||||
| Legacy | Modern |
|
||||
|--------|--------|
|
||||
| ASP/VBScript | Flask/Python |
|
||||
| Classic ADO | SQLAlchemy |
|
||||
| Server-side HTML | Vue 3 SPA |
|
||||
| Session auth | JWT tokens |
|
||||
|
||||
## License
|
||||
|
||||
Internal use only.
|
||||
79
deploy/windows/web.config
Normal file
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
IIS site config for shopdb-flask via HttpPlatformHandler.
|
||||
|
||||
IIS launches waitress (a Windows-friendly WSGI server; gunicorn does NOT run
|
||||
on Windows) and forwards requests to it on a private loopback port that IIS
|
||||
assigns via %HTTP_PLATFORM_PORT%. One process serves both /api and the built
|
||||
Vue SPA (frontend/dist), so no separate static site is needed.
|
||||
|
||||
Prerequisites on the box:
|
||||
- HttpPlatformHandler IIS module installed
|
||||
(https://www.iis.net/downloads/microsoft/httpplatformhandler)
|
||||
- URL Rewrite module installed (only for the optional X-Forwarded-For rule)
|
||||
- Python 3.12 + a venv at APP_ROOT\venv with requirements.txt + waitress
|
||||
- Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them
|
||||
OUT of this file. Lock .env ACLs to the app pool identity + admins.
|
||||
|
||||
Replace APP_ROOT (C:\shopdb-flask below) with the real deploy path. The IIS
|
||||
site's physical path MUST be APP_ROOT (where wsgi.py lives).
|
||||
-->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
|
||||
<handlers>
|
||||
<add name="httpplatformhandler" path="*" verb="*"
|
||||
modules="httpPlatformHandler" resourceType="Unspecified" />
|
||||
</handlers>
|
||||
|
||||
<httpPlatform
|
||||
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
|
||||
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 wsgi:app"
|
||||
stdoutLogEnabled="true"
|
||||
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
|
||||
startupTimeLimit="120"
|
||||
startupRetryCount="3">
|
||||
<environmentVariables>
|
||||
<!-- FLASK_ENV MUST be production here or wsgi.py defaults to the dev
|
||||
config (SQL echo, debug, wrong DB URL). Real secrets go in .env. -->
|
||||
<environmentVariable name="FLASK_ENV" value="production" />
|
||||
<environmentVariable name="PYTHONPATH" value="C:\shopdb-flask" />
|
||||
<!-- Subpath method only: when this web.config sits in an IIS
|
||||
Application (e.g. /ops) under an existing site instead of its own
|
||||
site, tell the app its mount path. Must match the alias the
|
||||
Application was created with AND the VITE_BASE_PATH the frontend
|
||||
was built with ('/ops/'). Omit for the own-site method.
|
||||
<environmentVariable name="MOUNT_PATH" value="/ops" />
|
||||
-->
|
||||
</environmentVariables>
|
||||
</httpPlatform>
|
||||
|
||||
<!--
|
||||
OPTIONAL: forward the real client IP so audit logs and the kiosk
|
||||
visitor-location feature (IP -> business unit) see the caller, not the
|
||||
loopback that HttpPlatformHandler connects from.
|
||||
|
||||
This block is COMMENTED OUT by default because it needs the URL Rewrite
|
||||
module; with it uncommented but URL Rewrite not installed, IIS returns
|
||||
HTTP 500.19 ("configuration section not well-formed / cannot be read").
|
||||
Install URL Rewrite (https://www.iis.net/downloads/microsoft/url-rewrite)
|
||||
and then uncomment the <rewrite> block below to enable it.
|
||||
|
||||
<rewrite>
|
||||
<allowedServerVariables>
|
||||
<add name="HTTP_X_FORWARDED_FOR" />
|
||||
</allowedServerVariables>
|
||||
<rules>
|
||||
<rule name="Set X-Forwarded-For" stopProcessing="false">
|
||||
<match url=".*" />
|
||||
<serverVariables>
|
||||
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
|
||||
</serverVariables>
|
||||
<action type="None" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
-->
|
||||
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
65
docker-compose.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
# shopdb-flask single-site docker-compose template.
|
||||
#
|
||||
# Per ADR-004, each adopting facility runs its own stack. This template
|
||||
# brings up MySQL + the API container and exposes the API on port 5001.
|
||||
# The Vue frontend is served separately by the API in production builds
|
||||
# (see register_frontend_routes in shopdb/__init__.py); for dev, run
|
||||
# `npm run dev` in frontend/ on a separate port.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env
|
||||
# # edit .env with site-specific secrets and origins
|
||||
# docker compose up -d
|
||||
#
|
||||
# Refresh after pulling new code:
|
||||
# docker compose build api
|
||||
# docker compose up -d api
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
# utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the
|
||||
# image default. Keeps every site's schema on the same charset/collation.
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||
MYSQL_DATABASE: shopdb_flask
|
||||
MYSQL_USER: shopdb
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
# Bind MySQL to loopback only. The api container reaches db over the
|
||||
# compose network regardless of this mapping; the published port is just
|
||||
# for local admin tools (mysqldump, a client on the host). Exposing 3306
|
||||
# on all interfaces would put the database on the facility network.
|
||||
ports:
|
||||
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
FLASK_ENV: production
|
||||
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ZABBIX_URL: ${ZABBIX_URL:-}
|
||||
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
|
||||
ports:
|
||||
- "${API_PORT:-5001}:5001"
|
||||
volumes:
|
||||
- ./plugins:/app/plugins:ro
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
120
frontend/DEVELOPMENT-STANDARDS.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Frontend Development Standards
|
||||
|
||||
## Naming Convention (LOCKED)
|
||||
|
||||
The shopdb-flask naming convention lives in the root `CONTRIBUTING.md`. Read it before naming any variable, component, API param, or CSS class.
|
||||
|
||||
Frontend-specific reminders pulled from the convention:
|
||||
|
||||
- Variables holding API field values: match the API field name exactly. Do NOT convert to camelCase. (`response.machineid`, NOT `response.machineId`)
|
||||
- Pure JS variables: camelCase (`currentUser`, `isLoading`)
|
||||
- Vue components: PascalCase, spelled out (`AssetDetail.vue`, `MachineForm.vue`)
|
||||
- CSS classes: lowercase with dashes (`asset-detail`, `machine-form`)
|
||||
- API params sent to backend: match DB column names without underscores (`params.locationid = 5`, NOT `params.location_id`)
|
||||
- No emojis, em-dashes, smart quotes, or Unicode arrows anywhere. Plain ASCII only.
|
||||
- Banned shorthand as standalone variables: `cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`. Spell them out (`canvasContext`, `response`, `manager`, etc.). Suffix usage like `printers_bp` is allowed.
|
||||
|
||||
Pre-commit hook at `scripts/check-naming-and-style.sh` enforces these rules.
|
||||
|
||||
## CSS Styling Standards
|
||||
|
||||
### Use CSS Variables for ALL Colors
|
||||
|
||||
**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`.
|
||||
|
||||
Available variables:
|
||||
```css
|
||||
--primary /* Primary brand color */
|
||||
--primary-dark /* Darker primary for hover states */
|
||||
--secondary /* Secondary/muted color */
|
||||
--success /* Success states (green) */
|
||||
--warning /* Warning states (orange) */
|
||||
--danger /* Error/danger states (red) */
|
||||
--bg /* Page background */
|
||||
--bg-card /* Card/panel background */
|
||||
--text /* Primary text color */
|
||||
--text-light /* Secondary/muted text */
|
||||
--border /* Border color */
|
||||
--link /* Link color (bright blue in dark mode) */
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```css
|
||||
.my-card {
|
||||
background: white;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
```
|
||||
|
||||
**Good:**
|
||||
```css
|
||||
.my-card {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
```
|
||||
|
||||
### Detail Pages - Use Global Styles
|
||||
|
||||
All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`:
|
||||
|
||||
- `.detail-page` - Container wrapper
|
||||
- `.hero-card` - Main hero section with image and info
|
||||
- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details`
|
||||
- `.section-card` - Info sections
|
||||
- `.section-title` - Section headers
|
||||
- `.info-list`, `.info-row`, `.info-label`, `.info-value`
|
||||
- `.content-grid`, `.content-column` - Two-column layout
|
||||
- `.audit-footer` - Created/modified timestamps
|
||||
|
||||
**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications).
|
||||
|
||||
### PrinterDetail.vue is the Master Template for Detail Pages
|
||||
|
||||
Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns.
|
||||
|
||||
### List Pages - Use Global Styles
|
||||
|
||||
All list pages should use the **unified global styles** from `style.css`:
|
||||
|
||||
- `.page-header` - Header with title and action button
|
||||
- `.filters` - Search and filter controls
|
||||
- `.card` - Main content container
|
||||
- `.table-container` - Scrollable table wrapper
|
||||
- `table`, `th`, `td` - Table styling
|
||||
- `.pagination` - Page navigation
|
||||
- `.badge`, `.badge-success`, etc. - Status badges
|
||||
- `.actions` - Action button column
|
||||
|
||||
**PrintersList.vue is the Master Template for List Pages**
|
||||
|
||||
Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS.
|
||||
|
||||
**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base).
|
||||
|
||||
### Dark Mode Support
|
||||
|
||||
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
|
||||
|
||||
## Component Organization
|
||||
|
||||
- **Global styles**: `src/assets/style.css`
|
||||
- **Page-specific styles**: Scoped `<style scoped>` block, only for unique elements
|
||||
- **Font sizes**: Use `rem` units, base is 18px for readability on 1080p
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
assets/
|
||||
style.css # Global styles, CSS variables, detail page styles
|
||||
views/
|
||||
machines/
|
||||
MachineDetail.vue # Uses global styles only
|
||||
pcs/
|
||||
PCDetail.vue # Global + PC-specific (app-list, etc.)
|
||||
printers/
|
||||
PrinterDetail.vue # Global + printer-specific (supplies-grid)
|
||||
applications/
|
||||
ApplicationDetail.vue # Global + app-specific (version-list, pc-list)
|
||||
```
|
||||
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ShopDB</title>
|
||||
<link rel="icon" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4554
frontend/package-lock.json
generated
Normal file
35
frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"axios": "^1.6.0",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"pinia": "^2.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="#4181ff"/>
|
||||
<text x="32" y="33" fill="#ffffff" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" text-anchor="middle" dominant-baseline="central">S</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
8
frontend/public/ge-aerospace-logo.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
1
frontend/public/ge-monogram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/public/images/models/computers/Latitude-5450.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/models/computers/Optiplex-5050.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
frontend/public/images/models/computers/Optiplex-5060.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend/public/images/models/computers/Optiplex-7000-Plus.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/images/models/computers/Optiplex-7000.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
frontend/public/images/models/computers/Optiplex-7080.jpg
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/public/images/models/machines/1000C1000.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
frontend/public/images/models/machines/2SP-V80.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
frontend/public/images/models/machines/7107sf.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/models/machines/Cisco9120.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/images/models/machines/IDF.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/images/models/machines/M710uc.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
frontend/public/images/models/machines/M719uc.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
frontend/public/images/models/machines/OptiPlex-7070.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/images/models/machines/Optiplex-7000.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
frontend/public/images/models/machines/a81nx.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
BIN
frontend/public/images/models/machines/abtech-eas1000.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
frontend/public/images/models/machines/abtech-eas1000.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/images/models/machines/c4500.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/images/models/machines/cmm.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/images/models/machines/d218.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
frontend/public/images/models/machines/eddy.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
frontend/public/images/models/machines/ezeddy.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/public/images/models/machines/furnace.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
frontend/public/images/models/machines/g750.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/models/machines/hbroach.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
frontend/public/images/models/machines/keyence-vr3100.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
frontend/public/images/models/machines/latitude5440.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/images/models/machines/leandrum.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/machines/loc650.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/images/models/machines/mx3100.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/images/models/machines/nt4300.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/machines/p600s.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
frontend/public/images/models/machines/phoenixbroach.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
frontend/public/images/models/machines/powerturn.png
Normal file
|
After Width: | Height: | Size: 510 KiB |
BIN
frontend/public/images/models/machines/precision5560.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/images/models/machines/rb2.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/images/models/machines/shotpeen.png
Normal file
|
After Width: | Height: | Size: 690 KiB |
BIN
frontend/public/images/models/machines/turnburn.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
frontend/public/images/models/machines/vp9000.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/images/models/machines/vt5502sp.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
frontend/public/images/models/machines/vtm100.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
frontend/public/images/models/machines/zoller600.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
frontend/public/images/models/printers/AltaLink-C8130.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
frontend/public/images/models/printers/AltaLink-C8130.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
frontend/public/images/models/printers/DTC4500e.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
frontend/public/images/models/printers/Epson-C3500.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/images/models/printers/HP-DesignJet-T1700dr.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/images/models/printers/LaserJet -CP2025.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
frontend/public/images/models/printers/LaserJet-4001n.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/images/models/printers/LaserJet-4250.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M254dw.jpg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M254dw.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M255dw.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M404.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M406.jpg
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M406.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M454dn.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M506.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M602.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M607.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/images/models/printers/LaserJet-P3015dn.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
frontend/public/images/models/printers/LaserJet-Pro-M252dw.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/printers/Laserjet-Pro-M251nw.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/images/models/printers/Versalink-B405.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/printers/Versalink-B405.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
frontend/public/images/models/printers/Versalink-B7125.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
frontend/public/images/models/printers/Versalink-C405.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
frontend/public/images/models/printers/Versalink-C7125.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/images/models/printers/Versalink-C7125.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
frontend/public/images/models/printers/Xerox-EC8036.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/images/models/printers/Xerox-EC8036.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
frontend/public/images/models/printers/zt411.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/printers/zt411.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
11
frontend/public/static/images/floorplan-placeholder.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="3300" height="2550" viewBox="0 0 3300 2550">
|
||||
<defs>
|
||||
<pattern id="grid" width="150" height="150" patternUnits="userSpaceOnUse">
|
||||
<path d="M 150 0 L 0 0 0 150" fill="none" stroke="#d0d4d9" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="3300" height="2550" fill="#f4f6f8"/>
|
||||
<rect width="3300" height="2550" fill="url(#grid)"/>
|
||||
<rect x="20" y="20" width="3260" height="2510" fill="none" stroke="#b8bec6" stroke-width="4"/>
|
||||
<text x="1650" y="1275" fill="#8a9099" font-family="Arial, Helvetica, sans-serif" font-size="90" font-weight="600" text-anchor="middle" dominant-baseline="central">Upload your facility floor plan in Settings > Map</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
6
frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
1126
frontend/src/api/index.js
Normal file
1759
frontend/src/assets/style.css
Normal file
793
frontend/src/components/AssetRelationships.vue
Normal file
@@ -0,0 +1,793 @@
|
||||
<template>
|
||||
<div class="relationships-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Relationships</h3>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="showAddModal = true"
|
||||
>
|
||||
Add Relationship
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading relationships...</div>
|
||||
|
||||
<div v-else-if="!hasRelationships" class="empty-state">
|
||||
No relationships defined.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Symmetric connections: one direction-blind entry per peer -->
|
||||
<div v-if="connectedItems.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Connected</h4>
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="item in connectedItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<div class="rel-line">
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
</div>
|
||||
<div class="rel-meta">
|
||||
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteItem(item)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directional relationships: natural per-row phrasing, no jargon -->
|
||||
<div v-if="directionalItems.length > 0" class="relationship-group">
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="item in directionalItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<div class="rel-line">
|
||||
<template v-if="item.direction === 'outgoing'">
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
<span class="rel-arrow">-></span>
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rel-arrow"><-</span>
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
<span class="rel-from">from</span>
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
<div class="rel-meta">
|
||||
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteItem(item)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add Relationship Modal -->
|
||||
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add Relationship</h3>
|
||||
<button class="btn-icon" @click="closeModal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Direction</label>
|
||||
<select v-model="newRel.direction" class="form-control">
|
||||
<option value="outgoing">This asset -> Target asset</option>
|
||||
<option value="incoming">Source asset -> This asset</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Relationship Type</label>
|
||||
<select v-model="newRel.relationshiptypeid" class="form-control" required>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="t in relationshipTypes"
|
||||
:key="t.relationshiptypeid"
|
||||
:value="t.relationshiptypeid"
|
||||
>
|
||||
{{ t.relationshiptype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ newRel.direction === 'outgoing' ? 'Target Asset' : 'Source Asset' }}</label>
|
||||
<div class="asset-search">
|
||||
<input
|
||||
type="text"
|
||||
v-model="assetSearchQuery"
|
||||
class="form-control"
|
||||
placeholder="Search assets..."
|
||||
@input="searchAssets"
|
||||
/>
|
||||
<div v-if="assetSearchResults.length > 0" class="search-results">
|
||||
<div
|
||||
v-for="asset in assetSearchResults"
|
||||
:key="asset.assetid"
|
||||
class="search-result-item"
|
||||
:class="{ selected: newRel.targetAssetId === asset.assetid }"
|
||||
@click="selectAsset(asset)"
|
||||
>
|
||||
<span class="result-icon">{{ getAssetIcon(asset.assettype) }}</span>
|
||||
<span class="result-name">{{ asset.name || asset.assetnumber }}</span>
|
||||
<span class="result-type">{{ asset.assettype }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedAsset" class="selected-asset">
|
||||
<span class="result-icon">{{ getAssetIcon(selectedAsset.assettype) }}</span>
|
||||
<span>{{ selectedAsset.name || selectedAsset.assetnumber }}</span>
|
||||
<button class="btn-icon" @click="clearSelectedAsset">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Notes (optional)</label>
|
||||
<textarea
|
||||
v-model="newRel.notes"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
placeholder="Additional notes about this relationship..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSave"
|
||||
@click="saveRelationship"
|
||||
>
|
||||
Add Relationship
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
|
||||
import { assetsApi, relationshipTypesApi } from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { apiError } from '../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
assetid: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// Alternative: lookup by machine/asset number
|
||||
machinenumber: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const resolvedAssetId = ref(null)
|
||||
const lookupFailed = ref(false)
|
||||
const outgoing = ref([])
|
||||
const incoming = ref([])
|
||||
const relationshipTypes = ref([])
|
||||
|
||||
function colorForType(name) {
|
||||
const t = relationshipTypes.value.find(rt => rt.relationshiptype === name)
|
||||
return t?.color || null
|
||||
}
|
||||
const showAddModal = ref(false)
|
||||
|
||||
// New relationship form
|
||||
const newRel = ref({
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: '',
|
||||
targetAssetId: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const assetSearchQuery = ref('')
|
||||
const assetSearchResults = ref([])
|
||||
const selectedAsset = ref(null)
|
||||
let searchTimeout = null
|
||||
|
||||
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
|
||||
|
||||
// Symmetric types collapse to one entry per {peer, type} unordered pair. All
|
||||
// stored direction rows (both directions, plus any legacy duplicates) fold
|
||||
// into one displayed entry; its rowIds carries every collapsed relationshipid
|
||||
// so a delete removes them all.
|
||||
const connectedItems = computed(() => {
|
||||
const byPair = new Map()
|
||||
const rows = [
|
||||
...outgoing.value.map(rel => ({ rel, peer: rel.targetasset })),
|
||||
...incoming.value.map(rel => ({ rel, peer: rel.sourceasset })),
|
||||
]
|
||||
for (const { rel, peer } of rows) {
|
||||
if (rel.isdirectional !== false) continue
|
||||
const selfid = resolvedAssetId.value
|
||||
const peerid = peer?.assetid
|
||||
const lo = Math.min(selfid, peerid)
|
||||
const hi = Math.max(selfid, peerid)
|
||||
const key = `${rel.relationshiptypeid}:${lo}:${hi}`
|
||||
const existing = byPair.get(key)
|
||||
if (existing) {
|
||||
existing.rowIds.push(rel.relationshipid)
|
||||
if (!existing.notes && rel.notes) existing.notes = rel.notes
|
||||
} else {
|
||||
byPair.set(key, {
|
||||
key,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer,
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(byPair.values())
|
||||
})
|
||||
|
||||
// Directional types keep one entry per stored row with arrow phrasing.
|
||||
const directionalItems = computed(() => {
|
||||
const items = []
|
||||
for (const rel of outgoing.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `out-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.targetasset,
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
for (const rel of incoming.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `in-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.sourceasset,
|
||||
direction: 'incoming',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await Promise.all([loadRelationships(), loadRelationshipTypes()])
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.assetid, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.machinenumber, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
async function resolveAssetId() {
|
||||
// If assetid is provided directly, use it
|
||||
if (props.assetid) {
|
||||
resolvedAssetId.value = props.assetid
|
||||
lookupFailed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to look up by machine number
|
||||
if (props.machinenumber) {
|
||||
try {
|
||||
const response = await assetsApi.lookup(props.machinenumber)
|
||||
resolvedAssetId.value = response.data.data?.assetid
|
||||
lookupFailed.value = !resolvedAssetId.value
|
||||
} catch (error) {
|
||||
resolvedAssetId.value = null
|
||||
lookupFailed.value = true
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationships() {
|
||||
if (!resolvedAssetId.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await assetsApi.getRelationships(resolvedAssetId.value)
|
||||
outgoing.value = response.data.data?.outgoing || []
|
||||
incoming.value = response.data.data?.incoming || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load relationships:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationshipTypes() {
|
||||
try {
|
||||
const response = await relationshipTypesApi.list()
|
||||
relationshipTypes.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load relationship types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function searchAssets() {
|
||||
clearTimeout(searchTimeout)
|
||||
if (!assetSearchQuery.value || assetSearchQuery.value.length < 2) {
|
||||
assetSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await assetsApi.search(assetSearchQuery.value, { perpage: 10 })
|
||||
// Filter out the current asset
|
||||
assetSearchResults.value = (response.data.data || []).filter(
|
||||
a => a.assetid !== resolvedAssetId.value
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to search assets:', error)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectAsset(asset) {
|
||||
selectedAsset.value = asset
|
||||
newRel.value.targetAssetId = asset.assetid
|
||||
assetSearchQuery.value = ''
|
||||
assetSearchResults.value = []
|
||||
}
|
||||
|
||||
function clearSelectedAsset() {
|
||||
selectedAsset.value = null
|
||||
newRel.value.targetAssetId = null
|
||||
}
|
||||
|
||||
async function saveRelationship() {
|
||||
if (!canSave.value) return
|
||||
|
||||
try {
|
||||
const data = {
|
||||
relationshiptypeid: parseInt(newRel.value.relationshiptypeid),
|
||||
notes: newRel.value.notes || null
|
||||
}
|
||||
|
||||
if (newRel.value.direction === 'outgoing') {
|
||||
data.sourceassetid = resolvedAssetId.value
|
||||
data.targetassetid = newRel.value.targetAssetId
|
||||
} else {
|
||||
data.sourceassetid = newRel.value.targetAssetId
|
||||
data.targetassetid = resolvedAssetId.value
|
||||
}
|
||||
|
||||
await assetsApi.createRelationship(data)
|
||||
await loadRelationships()
|
||||
closeModal()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
console.error('Failed to create relationship:', error)
|
||||
toast.error(apiError(error, 'Failed to create relationship'))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete every collapsed direction row behind a displayed entry.
|
||||
async function deleteItem(item) {
|
||||
if (!confirm('Remove this relationship?')) return
|
||||
|
||||
try {
|
||||
await Promise.all(item.rowIds.map(id => assetsApi.deleteRelationship(id)))
|
||||
await loadRelationships()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete relationship:', error)
|
||||
toast.error('Failed to delete relationship')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showAddModal.value = false
|
||||
newRel.value = {
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: '',
|
||||
targetAssetId: null,
|
||||
notes: ''
|
||||
}
|
||||
selectedAsset.value = null
|
||||
assetSearchQuery.value = ''
|
||||
assetSearchResults.value = []
|
||||
}
|
||||
|
||||
function getAssetIcon(assettype) {
|
||||
const icons = {
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
function getAssetRoute(asset) {
|
||||
if (!asset) return '#'
|
||||
|
||||
const routeMap = {
|
||||
'machine': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'measuring_tool': '/measuringtools'
|
||||
}
|
||||
|
||||
// serializer sends assettypename; old shape used assettype
|
||||
const typename = asset.assettypename || asset.assettype
|
||||
const basePath = routeMap[typename] || '/assets'
|
||||
|
||||
// pluginid is the extension-table id the detail routes key on
|
||||
const id = asset.pluginid
|
||||
|| asset.typedata?.networkdeviceid
|
||||
|| asset.typedata?.machineid
|
||||
|| asset.assetid
|
||||
return `${basePath}/${id}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relationships-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.25rem;
|
||||
/* Match .section-card spacing so relationships is its own card with a gap
|
||||
below (not visually merged with the Notes card) and does not split across
|
||||
a multicol break. */
|
||||
margin-bottom: 25px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.relationship-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.relationship-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.relationship-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.relationship-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rel-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rel-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rel-name {
|
||||
font-weight: 500;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rel-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rel-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rel-arrow {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-from {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rel-type-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-notes {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-icon.delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-icon.delete:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-header .btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Asset Search */
|
||||
.asset-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.search-result-item.selected {
|
||||
background: rgba(65, 129, 255, 0.15);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.selected-asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(65, 129, 255, 0.1);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.selected-asset .btn-icon {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/components/ColorSwatchPicker.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="color-swatch-picker">
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="c in palette"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="swatch"
|
||||
:class="{ active: modelValue === c }"
|
||||
:style="{ backgroundColor: c }"
|
||||
:title="c"
|
||||
@click="$emit('update:modelValue', c)"
|
||||
></button>
|
||||
</div>
|
||||
<div class="custom">
|
||||
<input
|
||||
type="color"
|
||||
:value="isHex(modelValue) ? modelValue : '#000000'"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
placeholder="#RRGGBB"
|
||||
maxlength="20"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<span class="preview" :style="colorStyle(modelValue)">Aa</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { PALETTE, colorStyle } from '@/utils/colorStyle'
|
||||
|
||||
defineProps({ modelValue: { type: String, default: '' } })
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const palette = PALETTE
|
||||
function isHex(c) {
|
||||
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-swatch-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.swatch {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.swatch.active {
|
||||
border-color: var(--bg);
|
||||
box-shadow: 0 0 0 2px var(--text);
|
||||
}
|
||||
.custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.custom input[type="text"] {
|
||||
width: 120px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: monospace;
|
||||
}
|
||||
.custom input[type="color"] {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/components/CustomFieldsInputs.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div v-if="fields.length" class="custom-fields">
|
||||
<div class="form-group" v-for="f in fields" :key="f.fieldid">
|
||||
<label>{{ f.label }}</label>
|
||||
|
||||
<select v-if="f.datatype === 'select'" v-model="local[f.fieldid]" class="form-control">
|
||||
<option value="">-- none --</option>
|
||||
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
|
||||
<label v-else-if="f.datatype === 'boolean'" class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
:checked="local[f.fieldid] === 'true'"
|
||||
@change="local[f.fieldid] = $event.target.checked ? 'true' : 'false'" />
|
||||
Yes
|
||||
</label>
|
||||
|
||||
<input v-else-if="f.datatype === 'date'" type="date" v-model="local[f.fieldid]" class="form-control" />
|
||||
<input v-else-if="f.datatype === 'number'" type="number" v-model="local[f.fieldid]" class="form-control" />
|
||||
<input v-else type="text" v-model="local[f.fieldid]" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { customFieldsApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
// Asset type whose fields to render (needed even for a brand-new asset).
|
||||
assettypeid: { type: [Number, String], default: null },
|
||||
// Existing asset id, if editing - used to preload stored values.
|
||||
assetid: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const fields = ref([])
|
||||
const local = reactive({})
|
||||
|
||||
async function load() {
|
||||
fields.value = []
|
||||
Object.keys(local).forEach(k => delete local[k])
|
||||
if (!props.assettypeid) return
|
||||
try {
|
||||
// Definitions for this asset type (form-visible, active).
|
||||
const defsResponse = await customFieldsApi.list({ assettypeid: props.assettypeid })
|
||||
const defs = (defsResponse.data.data || []).filter(f => f.showonform)
|
||||
fields.value = defs
|
||||
|
||||
// Seed blanks, then overlay stored values if editing.
|
||||
for (const f of defs) local[f.fieldid] = f.datatype === 'boolean' ? 'false' : ''
|
||||
if (props.assetid) {
|
||||
const valResponse = await customFieldsApi.forAsset(props.assetid)
|
||||
for (const f of (valResponse.data.data || [])) {
|
||||
if (f.value != null) local[f.fieldid] = String(f.value)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading custom field defs:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist current values against an asset id (parent calls after asset save).
|
||||
async function save(assetid) {
|
||||
if (!assetid || !fields.value.length) return
|
||||
const values = {}
|
||||
for (const f of fields.value) values[f.fieldid] = local[f.fieldid]
|
||||
await customFieldsApi.saveForAsset(assetid, values)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => [props.assettypeid, props.assetid], load)
|
||||
|
||||
defineExpose({ save, hasFields: () => fields.value.length > 0 })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
48
frontend/src/components/CustomFieldsSection.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<!-- Only render the card when there is at least one field with a value to show -->
|
||||
<div class="section-card" v-if="visibleFields.length">
|
||||
<h3 class="section-title">{{ title }}</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-for="f in visibleFields" :key="f.fieldid">
|
||||
<span class="info-label">{{ f.label }}</span>
|
||||
<span class="info-value">{{ displayValue(f) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { customFieldsApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
title: { type: String, default: 'Additional Details' },
|
||||
})
|
||||
|
||||
const fields = ref([])
|
||||
|
||||
const visibleFields = computed(() =>
|
||||
fields.value.filter(f => f.showondetail && f.value != null && String(f.value).trim() !== ''))
|
||||
|
||||
function displayValue(f) {
|
||||
if (f.datatype === 'boolean') {
|
||||
return ['true', '1', 'yes'].includes(String(f.value).toLowerCase()) ? 'Yes' : 'No'
|
||||
}
|
||||
return f.value
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.assetid) { fields.value = []; return }
|
||||
try {
|
||||
const response = await customFieldsApi.forAsset(props.assetid)
|
||||
fields.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading custom fields:', err)
|
||||
fields.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => props.assetid, load)
|
||||
</script>
|
||||
53
frontend/src/components/EmailReportButton.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<button class="btn btn-secondary" :disabled="sending" @click="emailReport">
|
||||
{{ sending ? 'Sending...' : 'Email report' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { reportsApi } from '../api'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { apiError } from '../utils/apiError'
|
||||
|
||||
// On-demand report delivery. Emails the given rows as an HTML table to the
|
||||
// site's Alert Recipients (or an explicit `to`). Automatic/scheduled sending is
|
||||
// out of scope for this app; point an external cron at POST /api/reports/email
|
||||
// with an API token to automate.
|
||||
const props = defineProps({
|
||||
subject: { type: String, required: true },
|
||||
columns: { type: Array, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
intro: { type: String, default: '' },
|
||||
to: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const sending = ref(false)
|
||||
|
||||
async function emailReport() {
|
||||
sending.value = true
|
||||
try {
|
||||
const payload = {
|
||||
subject: props.subject,
|
||||
columns: props.columns,
|
||||
rows: props.rows,
|
||||
intro: props.intro,
|
||||
}
|
||||
if (props.to) payload.to = props.to
|
||||
const response = await reportsApi.email(payload)
|
||||
const result = response.data?.data || {}
|
||||
if (result.sent) {
|
||||
toast.success('Report emailed.')
|
||||
} else if (result.error) {
|
||||
toast.error(`Report email failed: ${result.error}`)
|
||||
} else {
|
||||
toast.info(response.data?.message || 'Email is not configured.')
|
||||
}
|
||||
} catch (event) {
|
||||
toast.error(apiError(event, 'Failed to email report'))
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||