Rename the equipment domain to machines; retype the models catalog (ADR-011)
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

The equipment plugin is now the machines plugin, ending the UI-vs-code
vocabulary split while the contract is pre-1.0 and nothing external
depends on the old names.

- plugins/equipment -> plugins/machines: manifest, class, /api/machines,
  machines.* permissions, registry key (with an auto-migrating load shim
  for existing installs).
- Tables: equipment -> machines (equipmentid -> machineid) and
  equipmenttypes -> machinetypes, renamed in the plugin's own migration
  chain (machines0002rename), idempotent for both upgrading and fresh
  installs.
- The legacy core machinetypes lookup actually types the vendor MODELS
  catalog, so it is renamed losslessly to modeltypes
  (models.modeltypeid, /api/modeltypes, Model Types settings page)
  rather than collapsed, freeing the machinetypes name. Core migration
  7d17_machines_rename also flips data in place: assettypes row
  equipment -> machine, auditlog entitytype, identifier_/search_
  settings keys, permission rows, and renames alembic_version_equipment.
- Frontend: machinesApi/modeltypesApi, item.machine response shape,
  assettype value compares 'equipment' -> 'machine' (map, search,
  custom fields, relationships), routes machines.js with plugin gating
  retagged, /print/machine-badge, Machine Types (subtypes) and Model
  Types (catalog) settings pages, machines-by-type report id.
- Docs swept; ADRs left as history per the authoring rule.

Upgrade: flask db upgrade then flask plugin upgrade-all.

Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models
retyped, zero equipment tables remain); fresh scratch-MySQL install
produces the new names; 341 tests green; naming/style green; frontend
builds; live E2E on machines list/detail, PC relationships, map,
reports, and both settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 15:17:42 -04:00
parent 3c43c8d5c8
commit 48d3160bc5
84 changed files with 4755 additions and 4317 deletions

View File

@@ -41,6 +41,22 @@ ADR-007 and ADR-002.
### 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.

View File

@@ -38,7 +38,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
- `__contract_version__` at 0.6.0 (product `__version__` 0.5.0 - distinct series, ADR-007)
- 11 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, measuringtools, network, notifications, printers, slides, usb, warranty
- 11 bundled plugins all satisfy contract: computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent.
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0

466
README.md
View File

@@ -1,233 +1,233 @@
# 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 integration
- **Applications** - Software deployed across the shop floor
- **Knowledge Base** - Documentation and troubleshooting guides
## Tech Stack
**Backend:**
- Python 3.x with Flask
- SQLAlchemy ORM
- MySQL 5.6+ database
- 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: `machines`, `pctypes`, `machinetypes`, `businessunits`
- **Column names:** Lowercase, single word, no underscores or dashes
- Examples: `machineid`, `machinenumber`, `pctypeid`, `isactive`, `createddate`
- **Foreign keys:** Referenced table name + `id`
- Examples: `locationid`, `vendorid`, `modelnumberid`, `pctypeid`
- **Boolean columns:** Prefixed with `is` or `has`
- Examples: `isactive`, `isshopfloor`, `isvnc`, `iswinrm`, `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/pctypes`, `/api/locations`
- **Query parameters:** Lowercase, single word
- Examples: `?type=pc`, `?locationid=5`, `?isactive=true`
## 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.8+
- Node.js 18+
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
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 internally through the GE Aerospace Gitea. 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 docs/CONFIG.md 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 data from the legacy ShopDB MySQL database (one-time, see
`migrations/DATA_MIGRATION_GUIDE.md`):
```bash
python scripts/import_from_mysql.py
```
For the full per-site deployment runbook see [docs/DEPLOY.md](docs/DEPLOY.md);
for every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
## 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 |
Query parameters for list endpoints:
- `page` - Page number (default: 1)
- `per_page` - Items per page (default: 25)
- `sort` - Sort field
- `order` - Sort direction (asc/desc)
- `search` - Search term
- `type` - Filter by machine type (pc, printer, equipment)
## Plugin System
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
The image bundles ten plugins; only the ones a site installs are loaded:
- **computers** - Shopfloor PCs and workstations
- **employees** - Employee directory
- **equipment** - CNC, CMM, and inspection equipment
- **knowledgebase** - Documentation and troubleshooting guides
- **network** - Network devices
- **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** - Dell warranty 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.
# 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 integration
- **Applications** - Software deployed across the shop floor
- **Knowledge Base** - Documentation and troubleshooting guides
## Tech Stack
**Backend:**
- Python 3.x with Flask
- SQLAlchemy ORM
- MySQL 5.6+ database
- 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: `machines`, `pctypes`, `machinetypes`, `businessunits`
- **Column names:** Lowercase, single word, no underscores or dashes
- Examples: `machineid`, `machinenumber`, `pctypeid`, `isactive`, `createddate`
- **Foreign keys:** Referenced table name + `id`
- Examples: `locationid`, `vendorid`, `modelnumberid`, `pctypeid`
- **Boolean columns:** Prefixed with `is` or `has`
- Examples: `isactive`, `isshopfloor`, `isvnc`, `iswinrm`, `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/pctypes`, `/api/locations`
- **Query parameters:** Lowercase, single word
- Examples: `?type=pc`, `?locationid=5`, `?isactive=true`
## 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.8+
- Node.js 18+
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
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 internally through the GE Aerospace Gitea. 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 docs/CONFIG.md 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 data from the legacy ShopDB MySQL database (one-time, see
`migrations/DATA_MIGRATION_GUIDE.md`):
```bash
python scripts/import_from_mysql.py
```
For the full per-site deployment runbook see [docs/DEPLOY.md](docs/DEPLOY.md);
for every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
## 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 |
Query parameters for list endpoints:
- `page` - Page number (default: 1)
- `per_page` - Items per page (default: 25)
- `sort` - Sort field
- `order` - Sort direction (asc/desc)
- `search` - Search term
- `type` - Filter by asset type (computer, printer, machine, network_device)
## Plugin System
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
The image bundles ten plugins; only the ones a site installs are loaded:
- **computers** - Shopfloor PCs and workstations
- **employees** - Employee directory
- **machine** - CNC, CMM, and other shop-floor machines
- **knowledgebase** - Documentation and troubleshooting guides
- **network** - Network devices
- **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** - Dell warranty 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.

View File

@@ -146,7 +146,7 @@ them under `instance/branding/`.
|-----|---------|-------|
| `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. |
| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. |
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the equipment badge print page. |
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the machine badge print page. |
| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. |
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value (maps to `--primary`). Blank = built-in theme color. |
| `brand_primary_dark_color` | (empty) | Primary hover/active color (maps to `--primary-dark`). Blank = auto-derived by darkening the primary color ~15%. |

View File

@@ -104,12 +104,12 @@ docker compose exec api flask seed reference-data
## Step 5: Pick plugins to enable
The image bundles ten plugins (computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
The image bundles eleven plugins (computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
```bash
docker compose exec api flask plugin list
docker compose exec api flask plugin install computers
docker compose exec api flask plugin install equipment
docker compose exec api flask plugin install machines
# ... repeat for each plugin the site tracks
```

View File

@@ -109,7 +109,7 @@ def meta(self) -> PluginMeta:
The plugin owns two tables (`plugins/measuringtools/models/measuringtool.py`):
- `measuringtooltypes` - the site-managed lookup (Caliper, Micrometer, ...) with a
display color, the same shape as `equipmenttypes` / `computertypes`.
display color, the same shape as `machinetypes` / `computertypes`.
- `measuringtools` - the one-to-one extension of a core `Asset`, carrying only the
metrology domain fields.
@@ -523,7 +523,7 @@ form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdm
**API client, addition only.** `frontend/src/api/index.js` gets a
`measuringtoolsApi` object appended after `warrantyApi` (`list`, `get`, `create`,
`update`, `remove`, `calibrationReport`, and a nested `types` CRUD). Do not
reorganize the file; just add the block, mirroring `equipmentApi`.
reorganize the file; just add the block, mirroring `machinesApi`.
**Views mirror the master templates.** The frontend has master templates the
frontend CLAUDE.md points to (`PrintersList.vue` for lists, `PrinterDetail.vue`

View File

@@ -8,7 +8,7 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
| Plugin | Tracks | Notes |
|--------|--------|-------|
| `equipment` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens | Manually entered. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). |
| `machines` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens | Manually entered. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). |
| `computers` | Shop-floor PCs and engineering workstations | Fed by the PXE pipeline collector per [ADR-006](adr/ADR-006-collector-contract.md). |
| `printers` | Network and shop-floor printers | Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. |
| `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. |

View File

@@ -33,7 +33,7 @@ shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document cap
- Frontend scaffolding skill (the backend has `flask plugin new`; the frontend stub is currently manual copy-paste).
- Marketplace listing site (PLUGINS.md is a one-pager; a proper listing with links to sister-site plugins becomes useful when there are more than three external plugins).
- Plugin contract surface diff tooling. Today version bumps are manual judgment; a CI check that diffs the contract surface against the previous tag would catch missed bumps. See ADR-002.
- Calibration cycles, maintenance windows, downtime tracking (domain extensions; would likely live in the `equipment` and `measuringtools` plugins).
- Calibration cycles, maintenance windows, downtime tracking (domain extensions; would likely live in the `machines` and `measuringtools` plugins).
### Deferred (out of scope for 1.0)

View File

@@ -58,45 +58,45 @@ export const authApi = {
}
}
// Equipment API (plugin)
export const equipmentApi = {
// Machines API (plugin)
export const machinesApi = {
list(params = {}) {
return api.get('/equipment', { params })
return api.get('/machines', { params })
},
get(id) {
return api.get(`/equipment/${id}`)
return api.get(`/machines/${id}`)
},
getByAsset(assetId) {
return api.get(`/equipment/by-asset/${assetId}`)
return api.get(`/machines/by-asset/${assetId}`)
},
create(data) {
return api.post('/equipment', data)
return api.post('/machines', data)
},
update(id, data) {
return api.put(`/equipment/${id}`, data)
return api.put(`/machines/${id}`, data)
},
delete(id) {
return api.delete(`/equipment/${id}`)
return api.delete(`/machines/${id}`)
},
dashboardSummary() {
return api.get('/equipment/dashboard/summary')
return api.get('/machines/dashboard/summary')
},
// Equipment types
// Machine types
types: {
list(params = {}) {
return api.get('/equipment/types', { params })
return api.get('/machines/types', { params })
},
get(id) {
return api.get(`/equipment/types/${id}`)
return api.get(`/machines/types/${id}`)
},
create(data) {
return api.post('/equipment/types', data)
return api.post('/machines/types', data)
},
update(id, data) {
return api.put(`/equipment/types/${id}`, data)
return api.put(`/machines/types/${id}`, data)
},
remove(id) {
return api.delete(`/equipment/types/${id}`)
return api.delete(`/machines/types/${id}`)
}
}
}
@@ -178,19 +178,19 @@ export const relationshipTypesApi = {
}
}
// Machine Types API
export const machinetypesApi = {
// Model Types API (the vendor models catalog: modeltypeid)
export const modeltypesApi = {
list(params = {}) {
return api.get('/machinetypes', { params })
return api.get('/modeltypes', { params })
},
create(data) {
return api.post('/machinetypes', data)
return api.post('/modeltypes', data)
},
update(id, data) {
return api.put(`/machinetypes/${id}`, data)
return api.put(`/modeltypes/${id}`, data)
},
delete(id) {
return api.delete(`/machinetypes/${id}`)
return api.delete(`/modeltypes/${id}`)
}
}
@@ -667,8 +667,8 @@ export const reportsApi = {
list() {
return api.get('/reports')
},
equipmentByType(params = {}) {
return api.get('/reports/equipment-by-type', { params })
machinesByType(params = {}) {
return api.get('/reports/machines-by-type', { params })
},
assetsByStatus(params = {}) {
return api.get('/reports/assets-by-status', { params })

View File

@@ -976,7 +976,7 @@ td.actions {
width: 180px;
height: 180px;
object-fit: contain;
background: rgba(0, 0, 0, 0.3);
background: var(--bg);
border-radius: 0.25rem;
padding: 1rem;
}
@@ -1208,15 +1208,16 @@ td.actions {
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.25rem;
text-decoration: none;
color: inherit;
transition: background 0.15s;
transition: border-color 0.15s;
}
.equipment-item:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--primary);
}
.equipment-info {
@@ -1251,7 +1252,8 @@ td.actions {
.network-item {
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.25rem;
}

View File

@@ -27,14 +27,14 @@
:key="rel.relationshipid"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettypename || rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettype }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettypename || rel.targetasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
@@ -59,14 +59,14 @@
:key="rel.relationshipid"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettypename || rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettype }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettypename || rel.sourceasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
@@ -173,7 +173,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
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'
@@ -384,10 +384,11 @@ function closeModal() {
function getAssetIcon(assettype) {
const icons = {
'equipment': Cog,
'machine': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe
'network_device': Globe,
'measuring_tool': Ruler
}
return icons[assettype] || Package
}
@@ -396,21 +397,22 @@ function getAssetRoute(asset) {
if (!asset) return '#'
const routeMap = {
'equipment': '/machines',
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network'
'network_device': '/network',
'measuring_tool': '/measuringtools'
}
const basePath = routeMap[asset.assettype] || '/assets'
// serializer sends assettypename; old shape used assettype
const typename = asset.assettypename || asset.assettype
const basePath = routeMap[typename] || '/assets'
// Use typedata ID if available
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
return `/network/${asset.typedata.networkdeviceid}`
}
// For equipment/computer/printer, use machineid from typedata or assetid
const id = asset.typedata?.machineid || asset.assetid
// 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>

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ const state = reactive({
let inflight = null
const KEY_RE = /^identifier_(.+?)(?:_(equipment|computer|printer|network_device))?_enabled$/
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device))?_enabled$/
function applySetting(key, value) {
const match = KEY_RE.exec(key)

View File

@@ -66,9 +66,9 @@ const routes = [
},
// Print pages (standalone, no sidebar/header)
{
path: '/print/equipment-badge/:id',
name: 'print-equipment-badge',
component: () => import('../views/print/EquipmentBadge.vue')
path: '/print/machine-badge/:id',
name: 'print-machine-badge',
component: () => import('../views/print/MachineBadge.vue')
},
{
path: '/print/printer-qr',

View File

@@ -60,9 +60,9 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/machinetypes',
name: 'machinetypes',
component: () => import('../../views/settings/MachineTypesList.vue'),
path: 'settings/modeltypes',
name: 'modeltypes',
component: () => import('../../views/settings/ModelTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
@@ -108,9 +108,9 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' }
},
{
path: 'settings/equipmenttypes',
name: 'equipment-types',
component: () => import('../../views/settings/EquipmentTypesList.vue'),
path: 'settings/machinetypes',
name: 'machinetypes',
component: () => import('../../views/settings/MachineTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{

View File

@@ -1,29 +1,29 @@
/**
* Equipment plugin routes
* Machines plugin routes
*/
export default [
{
path: 'machines',
name: 'machines',
component: () => import('../../views/machines/MachinesList.vue'),
meta: { plugin: 'equipment' }
meta: { plugin: 'machines' }
},
{
path: 'machines/new',
name: 'machine-new',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'equipment' }
meta: { requiresAuth: true, plugin: 'machines' }
},
{
path: 'machines/:id',
name: 'machine-detail',
component: () => import('../../views/machines/MachineDetail.vue'),
meta: { plugin: 'equipment' }
meta: { plugin: 'machines' }
},
{
path: 'machines/:id/edit',
name: 'machine-edit',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'equipment' }
meta: { requiresAuth: true, plugin: 'machines' }
}
]

View File

@@ -4,7 +4,7 @@
// because the API sends the underscore form and some map data the spaced form.
const ASSET_TYPE_LABELS = {
'equipment': 'Equipment',
'machine': 'Machines',
'computer': 'Computers',
'printer': 'Printers',
'network_device': 'Network Devices',
@@ -12,7 +12,7 @@ const ASSET_TYPE_LABELS = {
}
const ASSET_TYPE_ROUTES = {
'equipment': '/machines',
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
@@ -21,7 +21,7 @@ const ASSET_TYPE_ROUTES = {
// Plugin-specific id field inside asset.typedata for each asset type.
const ASSET_TYPE_ID_KEYS = {
'equipment': 'equipmentid',
'machine': 'machineid',
'computer': 'computerid',
'printer': 'printerid',
'network_device': 'networkdeviceid',

View File

@@ -3,7 +3,7 @@
// ShopFloorMap.vue - keep the two in sync if the palette changes.
export const assetTypeColorsMap = {
equipment: '#F44336', // Red
machine: '#F44336', // Red
computer: '#2196F3', // Blue
printer: '#4CAF50', // Green
'network device': '#FF9800', // Orange
@@ -15,7 +15,7 @@ const DEFAULT_COLOR = '#BDBDBD'
// Canonical form for comparing asset-type strings. The API sends the machine
// type as 'network_device' but subtype keys and labels use 'network device',
// so normalize underscores to spaces before any comparison. Without this,
// network-device subtypes silently fail to match (equipment/computer/printer
// network-device subtypes silently fail to match (machine/computer/printer
// are single words and were unaffected, which is why only network broke).
export function normalizeAssetType(assettype) {
return (assettype || '').toLowerCase().replace(/_/g, ' ')
@@ -33,7 +33,7 @@ export function getAssetTypeColor(assettype) {
export function getSubtypeId(asset) {
if (!asset || !asset.typedata) return null
const typeLower = normalizeAssetType(asset.assettype)
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'machine') return asset.typedata.machinetypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid

View File

@@ -50,7 +50,7 @@ export async function getQrLogo() {
return (value === undefined || value === null) ? '/ge-monogram.svg' : value
}
// Logo printed on equipment inspection badges.
// Logo printed on machine inspection badges.
export async function getBadgeLogo() {
return getSetting('badge_logo', '/ge-aerospace-logo.svg')
}

View File

@@ -15,7 +15,7 @@
</div>
<div class="stat-card success">
<div class="label">Active</div>
<div class="value">{{ stats.activemachines || 0 }}</div>
<div class="value">{{ stats.activeassets || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">In Repair</div>

View File

@@ -14,7 +14,7 @@
<h3>Assets</h3>
<select v-model="filterType" class="form-control">
<option value="">All Types</option>
<option value="equipment">Machines</option>
<option value="machine">Machines</option>
<option value="computer">Computers</option>
<option value="printer">Printers</option>
<option value="network_device">Network Devices</option>
@@ -155,7 +155,7 @@ async function loadAssets() {
function getTypeIcon(assettype) {
const icons = {
'equipment': Cog,
'machine': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe

View File

@@ -130,7 +130,7 @@ const subtypeLabel = computed(() => {
if (!selectedType.value) return 'Select type first'
if (!currentSubtypes.value.length) return 'No subtypes'
const labels = {
'equipment': 'All Machine Types',
'machine': 'All Machine Types',
'computer': 'All Computer Types',
'network device': 'All Device Types',
'printer': 'All Printer Types'
@@ -182,8 +182,8 @@ const filteredAssets = computed(() => {
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
if (typeLower === 'equipment') {
return a.typedata.equipmenttypeid === subtypeId
if (typeLower === 'machine') {
return a.typedata.machinetypeid === subtypeId
} else if (typeLower === 'computer') {
return a.typedata.computertypeid === subtypeId
} else if (typeLower === 'network device') {

View File

@@ -110,7 +110,6 @@ const copiedId = ref(null)
const typeLabels = {
machine: 'Machine',
equipment: 'Machine',
pc: 'PC',
computer: 'PC',
application: 'App',
@@ -124,7 +123,7 @@ const typeLabels = {
const filterTypeMap = {
all: null,
equipment: ['equipment'],
machines: ['machine'],
computers: ['computer'],
printers: ['printer'],
network: ['network_device', 'subnet'],
@@ -136,7 +135,7 @@ const filterTypeMap = {
const filterList = [
{ key: 'all', label: 'All' },
{ key: 'equipment', label: 'Machines' },
{ key: 'machines', label: 'Machines' },
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' },
{ key: 'network', label: 'Network' },
@@ -383,8 +382,7 @@ watch(results, () => {
flex-shrink: 0;
}
.result-type.machine,
.result-type.equipment {
.result-type.machine {
background: #e3f2fd;
color: #1565c0;
}
@@ -484,8 +482,7 @@ watch(results, () => {
}
@media (prefers-color-scheme: dark) {
.result-type.machine,
.result-type.equipment {
.result-type.machine {
background: rgba(21, 101, 192, 0.2);
color: #64b5f6;
}

View File

@@ -1,348 +1,352 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Machine Details</h2>
<div class="header-actions">
<router-link :to="`/print/equipment-badge/${equipment?.equipment?.equipmentid}`" class="btn btn-secondary" v-if="equipment" target="_blank">
Print Badge
</router-link>
<router-link :to="`/machines/${equipment?.equipment?.equipmentid}/edit`" class="btn btn-primary" v-if="equipment">
Edit
</router-link>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="equipment">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<h1>{{ equipment.assetnumber }}</h1>
<span v-if="equipment.name" class="hero-alias">{{ equipment.name }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-primary">
{{ equipment.assettypename || 'Machine' }}
</span>
<span class="badge badge-lg" :class="getStatusClass(equipment.statusname)">
{{ equipment.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="equipment.equipment?.equipmenttypename">
<span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ equipment.equipment.equipmenttypename }}</span>
</div>
<div class="hero-detail" v-if="equipment.equipment?.vendorname">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ equipment.equipment.vendorname }}</span>
</div>
<div class="hero-detail" v-if="equipment.equipment?.modelname">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ equipment.equipment.modelname }}</span>
</div>
<div class="hero-detail" v-if="equipment.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ equipment.locationname }}</span>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
<div class="content-column">
<!-- Identity Section -->
<div class="section-card">
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ equipment.assetnumber }}</span>
</div>
<div class="info-row" v-if="equipment.name">
<span class="info-label">Name</span>
<span class="info-value">{{ equipment.name }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'equipment') && equipment.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ equipment.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'equipment') && equipment.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ equipment.maintenancereference }}</span>
</div>
<div class="info-row" v-if="equipment.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ equipment.serialnumber }}</span>
</div>
</div>
</div>
<!-- Hardware Section -->
<div class="section-card">
<h3 class="section-title">Hardware</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">{{ equipment.equipment?.equipmenttypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ equipment.equipment?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ equipment.equipment?.modelname || '-' }}</span>
</div>
</div>
</div>
<!-- Controller Section (for CNC machines) -->
<div class="section-card" v-if="equipment.equipment?.controllervendorname || equipment.equipment?.controllermodelname">
<h3 class="section-title">Controller</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ equipment.equipment?.controllervendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ equipment.equipment?.controllermodelname || '-' }}</span>
</div>
</div>
</div>
<!-- Equipment Configuration -->
<div class="section-card">
<h3 class="section-title">Configuration</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Requires Manual Config</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: equipment.equipment?.requiresmanualconfig }">
{{ equipment.equipment?.requiresmanualconfig ? 'Yes' : 'No' }}
</span>
</span>
</div>
</div>
</div>
<!-- Maintenance Section -->
<div class="section-card" v-if="equipment.equipment?.lastmaintenancedate || equipment.equipment?.nextmaintenancedate">
<h3 class="section-title">Maintenance</h3>
<div class="info-list">
<div class="info-row" v-if="equipment.equipment?.lastmaintenancedate">
<span class="info-label">Last Maintenance</span>
<span class="info-value">{{ formatDate(equipment.equipment.lastmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="equipment.equipment?.nextmaintenancedate">
<span class="info-label">Next Maintenance</span>
<span class="info-value">{{ formatDate(equipment.equipment.nextmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="equipment.equipment?.maintenanceintervaldays">
<span class="info-label">Interval</span>
<span class="info-value">{{ equipment.equipment.maintenanceintervaldays }} days</span>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="equipment.mapx != null && equipment.mapy != null"
:left="equipment.mapx"
:top="equipment.mapy"
:machineName="equipment.assetnumber"
>
<span class="location-link">{{ equipment.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ equipment.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ equipment.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Connected PC -->
<div class="section-card">
<h3 class="section-title">Connected PC</h3>
<div v-if="!controllingPc" class="empty-message">
No controlling PC assigned
</div>
<div v-else class="connected-device">
<router-link :to="`/pcs/${controllingPc.pluginid || controllingPc.assetid}`" class="device-link">
<div class="device-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</div>
<div class="device-info">
<span class="device-name">{{ controllingPc.assetnumber }}</span>
<span class="device-alias" v-if="controllingPc.name">{{ controllingPc.name }}</span>
</div>
</router-link>
<span class="connection-type">{{ controllingPc.relationshipType }}</span>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="equipment.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="equipment.assetid" :items="warranties" />
<!-- Notes -->
<div class="section-card" v-if="equipment.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ equipment.notes }}</p>
</div>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(equipment.createddate) }}<template v-if="equipment.createdby"> by {{ equipment.createdby }}</template></span>
<span>Modified {{ formatDate(equipment.modifieddate) }}<template v-if="equipment.modifiedby"> by {{ equipment.modifiedby }}</template></span>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Machine not found</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const equipment = ref(null)
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => equipment.value?.assetid)
const relationships = ref({ incoming: [], outgoing: [] })
// type name is data, sites may seed 'controls' or 'Controls' - compare folded
function isControls(rel) {
return (rel.relationshiptypename || '').toLowerCase() === 'controls'
}
const controllingPc = computed(() => {
// For equipment, find a related computer in any "Controls" relationship
// Check both incoming (computer controls this) and outgoing (legacy data may have equipment -> computer)
// First check incoming - computer as source controlling this equipment
for (const rel of relationships.value.incoming || []) {
if (rel.sourceasset?.assettypename === 'computer' && isControls(rel)) {
return {
...rel.sourceasset,
relationshipType: rel.relationshiptypename
}
}
}
// Also check outgoing - legacy data may have equipment -> computer Controls relationships
for (const rel of relationships.value.outgoing || []) {
if (rel.targetasset?.assettypename === 'computer' && isControls(rel)) {
return {
...rel.targetasset,
relationshipType: rel.relationshiptypename
}
}
}
return null
})
onMounted(async () => {
try {
const response = await equipmentApi.get(route.params.id)
equipment.value = response.data.data
// Load relationships using asset ID
if (equipment.value?.assetid) {
try {
const relResponse = await assetsApi.getRelationships(equipment.value.assetid)
relationships.value = relResponse.data.data || { incoming: [], outgoing: [] }
} catch (e) {
console.log('Relationships not available')
}
}
} catch (error) {
console.error('Error loading equipment:', error)
} finally {
loading.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'spare') return 'badge-warning'
if (s === 'retired' || s === 'disposed') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.feature-tag {
display: inline-block;
padding: 0.3rem 0.625rem;
font-size: 0.875rem;
border-radius: 5px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.active {
background: #e3f2fd;
color: #1976d2;
}
@media (prefers-color-scheme: dark) {
.feature-tag.active {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>
<template>
<div class="detail-page">
<div class="page-header">
<h2>Machine Details</h2>
<div class="header-actions">
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Badge
</router-link>
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
Edit
</router-link>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<h1>{{ machine.assetnumber }}</h1>
<span v-if="machine.name" class="hero-alias">{{ machine.name }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-primary">
{{ machine.assettypename || 'Machine' }}
</span>
<span class="badge badge-lg" :class="getStatusClass(machine.statusname)">
{{ machine.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="machine.machine?.machinetypename">
<span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ machine.machine.machinetypename }}</span>
</div>
<div class="hero-detail" v-if="machine.machine?.vendorname">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ machine.machine.vendorname }}</span>
</div>
<div class="hero-detail" v-if="machine.machine?.modelname">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ machine.machine.modelname }}</span>
</div>
<div class="hero-detail" v-if="machine.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ machine.locationname }}</span>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
<div class="content-column">
<!-- Identity Section -->
<div class="section-card">
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ machine.assetnumber }}</span>
</div>
<div class="info-row" v-if="machine.name">
<span class="info-label">Name</span>
<span class="info-value">{{ machine.name }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'machine') && machine.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ machine.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'machine') && machine.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ machine.maintenancereference }}</span>
</div>
<div class="info-row" v-if="machine.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ machine.serialnumber }}</span>
</div>
</div>
</div>
<!-- Hardware Section -->
<div class="section-card">
<h3 class="section-title">Hardware</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">{{ machine.machine?.machinetypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ machine.machine?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ machine.machine?.modelname || '-' }}</span>
</div>
</div>
</div>
<!-- Controller Section (for CNC machines) -->
<div class="section-card" v-if="machine.machine?.controllervendorname || machine.machine?.controllermodelname">
<h3 class="section-title">Controller</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ machine.machine?.controllervendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ machine.machine?.controllermodelname || '-' }}</span>
</div>
</div>
</div>
<!-- Machine Configuration -->
<div class="section-card">
<h3 class="section-title">Configuration</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Requires Manual Config</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: machine.machine?.requiresmanualconfig }">
{{ machine.machine?.requiresmanualconfig ? 'Yes' : 'No' }}
</span>
</span>
</div>
</div>
</div>
<!-- Maintenance Section -->
<div class="section-card" v-if="machine.machine?.lastmaintenancedate || machine.machine?.nextmaintenancedate">
<h3 class="section-title">Maintenance</h3>
<div class="info-list">
<div class="info-row" v-if="machine.machine?.lastmaintenancedate">
<span class="info-label">Last Maintenance</span>
<span class="info-value">{{ formatDate(machine.machine.lastmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="machine.machine?.nextmaintenancedate">
<span class="info-label">Next Maintenance</span>
<span class="info-value">{{ formatDate(machine.machine.nextmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="machine.machine?.maintenanceintervaldays">
<span class="info-label">Interval</span>
<span class="info-value">{{ machine.machine.maintenanceintervaldays }} days</span>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx"
:top="machine.mapy"
:machineName="machine.assetnumber"
>
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ machine.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Connected PC -->
<div class="section-card">
<h3 class="section-title">Connected PC</h3>
<div v-if="!controllingPc" class="empty-message">
No controlling PC assigned
</div>
<div v-else class="connected-device">
<router-link :to="`/pcs/${controllingPc.pluginid || controllingPc.assetid}`" class="device-link">
<div class="device-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</div>
<div class="device-info">
<span class="device-name">{{ controllingPc.assetnumber }}</span>
<span class="device-alias" v-if="controllingPc.name">{{ controllingPc.name }}</span>
</div>
</router-link>
<span class="connection-type">{{ controllingPc.relationshipType }}</span>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="machine.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
<!-- All relationships (dualpath, controls, ...) -->
<AssetRelationships v-if="machine.assetid" :assetId="machine.assetid" />
<!-- Notes -->
<div class="section-card" v-if="machine.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ machine.notes }}</p>
</div>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span>
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Machine not found</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { machinesApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const machine = ref(null)
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
const relationships = ref({ incoming: [], outgoing: [] })
// type name is data, sites may seed 'controls' or 'Controls' - compare folded
function isControls(rel) {
return (rel.relationshiptypename || '').toLowerCase() === 'controls'
}
const controllingPc = computed(() => {
// For a machine, find a related computer in any "Controls" relationship
// Check both incoming (computer controls this) and outgoing (legacy data may have machine -> computer)
// First check incoming - computer as source controlling this machine
for (const rel of relationships.value.incoming || []) {
if (rel.sourceasset?.assettypename === 'computer' && isControls(rel)) {
return {
...rel.sourceasset,
relationshipType: rel.relationshiptypename
}
}
}
// Also check outgoing - legacy data may have machine -> computer Controls relationships
for (const rel of relationships.value.outgoing || []) {
if (rel.targetasset?.assettypename === 'computer' && isControls(rel)) {
return {
...rel.targetasset,
relationshipType: rel.relationshiptypename
}
}
}
return null
})
onMounted(async () => {
try {
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
// Load relationships using asset ID
if (machine.value?.assetid) {
try {
const relResponse = await assetsApi.getRelationships(machine.value.assetid)
relationships.value = relResponse.data.data || { incoming: [], outgoing: [] }
} catch (e) {
console.log('Relationships not available')
}
}
} catch (error) {
console.error('Error loading machine:', error)
} finally {
loading.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'spare') return 'badge-warning'
if (s === 'retired' || s === 'disposed') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.feature-tag {
display: inline-block;
padding: 0.3rem 0.625rem;
font-size: 0.875rem;
border-radius: 5px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.active {
background: #e3f2fd;
color: #1976d2;
}
@media (prefers-color-scheme: dark) {
.feature-tag.active {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -7,7 +7,7 @@
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveEquipment">
<form v-else @submit.prevent="saveMachine">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
<div class="form-row">
@@ -34,8 +34,8 @@
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'equipment') || isEnabled('maintenancereference', 'equipment')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'equipment')">
<div class="form-row" v-if="isEnabled('gaugelabreference', 'machine') || isEnabled('maintenancereference', 'machine')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'machine')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
@@ -46,7 +46,7 @@
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'equipment')">
<div class="form-group" v-if="isEnabled('maintenancereference', 'machine')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
@@ -88,23 +88,23 @@
</div>
</div>
<!-- Equipment Hardware Section -->
<!-- Machine Hardware Section -->
<h3 class="form-section-title">Machine Hardware</h3>
<div class="form-row">
<div class="form-group">
<label for="equipmenttypeid">Equipment Type</label>
<label for="machinetypeid">Machine Type</label>
<select
id="equipmenttypeid"
v-model="form.equipmenttypeid"
id="machinetypeid"
v-model="form.machinetypeid"
class="form-control"
>
<option value="">Select type...</option>
<option
v-for="et in equipmentTypes"
:key="et.equipmenttypeid"
:value="et.equipmenttypeid"
v-for="mt in machineTypes"
:key="mt.machinetypeid"
:value="mt.machinetypeid"
>
{{ et.equipmenttype }}
{{ mt.machinetype }}
</option>
</select>
</div>
@@ -294,7 +294,7 @@
{{ pc.assetnumber }}{{ pc.name ? ` (${pc.name})` : '' }}
</option>
</select>
<small class="form-help">Select the PC that controls this equipment</small>
<small class="form-help">Select the PC that controls this machine</small>
</div>
<div class="form-group" v-if="controllingPcId">
@@ -312,7 +312,7 @@
{{ rt.relationshiptype }}
</option>
</select>
<small class="form-help">How the PC connects to this equipment</small>
<small class="form-help">How the PC connects to this machine</small>
</div>
</div>
@@ -328,8 +328,8 @@
></textarea>
</div>
<!-- Site-defined custom fields for equipment -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="EQUIPMENT_ASSETTYPEID" :assetid="currentAssetId" />
<!-- Site-defined custom fields for machines -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
@@ -347,7 +347,7 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
@@ -362,8 +362,8 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for equipment (see /api/assets/types).
const EQUIPMENT_ASSETTYPEID = 1
// Seeded asset-type id for machines (see /api/assets/types).
const MACHINE_ASSETTYPEID = 1
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
@@ -380,7 +380,7 @@ const form = ref({
maintenancereference: '',
serialnumber: '',
statusid: 1,
equipmenttypeid: '',
machinetypeid: '',
vendorid: '',
modelnumberid: '',
controllervendorid: '',
@@ -394,7 +394,7 @@ const form = ref({
mapy: null
})
const equipmentTypes = ref([])
const machineTypes = ref([])
const statuses = ref([])
const vendors = ref([])
const locations = ref([])
@@ -405,9 +405,9 @@ const relationshipTypes = ref([])
const controllingPcId = ref(null)
const relationshipTypeId = ref(null)
const existingRelationshipId = ref(null)
const currentEquipment = ref(null)
const currentMachine = ref(null)
// Filter models by selected equipment vendor
// Filter models by selected machine vendor
const filteredModels = computed(() => {
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
@@ -443,7 +443,7 @@ onMounted(async () => {
try {
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
equipmentApi.types.list(),
machinesApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }),
locationsApi.list({ perpage: 500 }),
@@ -453,7 +453,7 @@ onMounted(async () => {
assetsApi.types.list() // Used for relationship types, will fix below
])
equipmentTypes.value = typesRes.data.data || []
machineTypes.value = typesRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
locations.value = locRes.data.data || []
@@ -479,12 +479,12 @@ onMounted(async () => {
relationshipTypeId.value = controlsType.relationshiptypeid
}
// Load equipment if editing
// Load machine if editing
if (isEdit.value) {
const response = await equipmentApi.get(route.params.id)
const response = await machinesApi.get(route.params.id)
const data = response.data.data
currentAssetId.value = data.assetid || null
currentEquipment.value = data
currentMachine.value = data
form.value = {
assetnumber: data.assetnumber || '',
@@ -493,15 +493,15 @@ onMounted(async () => {
maintenancereference: data.maintenancereference || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || 1,
equipmenttypeid: data.equipment?.equipmenttypeid || '',
vendorid: data.equipment?.vendorid || '',
modelnumberid: data.equipment?.modelnumberid || '',
controllervendorid: data.equipment?.controllervendorid || '',
controllermodelid: data.equipment?.controllermodelid || '',
machinetypeid: data.machine?.machinetypeid || '',
vendorid: data.machine?.vendorid || '',
modelnumberid: data.machine?.modelnumberid || '',
controllervendorid: data.machine?.controllervendorid || '',
controllermodelid: data.machine?.controllermodelid || '',
locationid: data.locationid || '',
businessunitid: data.businessunitid || '',
requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
islocationonly: data.equipment?.islocationonly || false,
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
islocationonly: data.machine?.islocationonly || false,
notes: data.notes || '',
mapx: data.mapx ?? null,
mapy: data.mapy ?? null
@@ -553,7 +553,7 @@ function clearMapPosition() {
tempMapPosition.value = null
}
async function saveEquipment() {
async function saveMachine() {
error.value = ''
saving.value = true
@@ -565,7 +565,7 @@ async function saveEquipment() {
maintenancereference: form.value.maintenancereference || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || 1,
equipmenttypeid: form.value.equipmenttypeid || null,
machinetypeid: form.value.machinetypeid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
controllervendorid: form.value.controllervendorid || null,
@@ -579,17 +579,17 @@ async function saveEquipment() {
mapy: form.value.mapy
}
let savedEquipment
let savedMachine
let assetId
if (isEdit.value) {
const response = await equipmentApi.update(route.params.id, data)
savedEquipment = response.data.data
assetId = savedEquipment.assetid
const response = await machinesApi.update(route.params.id, data)
savedMachine = response.data.data
assetId = savedMachine.assetid
} else {
const response = await equipmentApi.create(data)
savedEquipment = response.data.data
assetId = savedEquipment.assetid
const response = await machinesApi.create(data)
savedMachine = response.data.data
assetId = savedMachine.assetid
}
// Handle relationship (controlling PC)
@@ -604,10 +604,10 @@ async function saveEquipment() {
}
}
router.push(`/machines/${savedEquipment.equipment?.equipmentid || route.params.id}`)
router.push(`/machines/${savedMachine.machine?.machineid || route.params.id}`)
} catch (err) {
console.error('Error saving equipment:', err)
error.value = apiError(err, 'Failed to save equipment')
console.error('Error saving machine:', err)
error.value = apiError(err, 'Failed to save machine')
} finally {
saving.value = false
}
@@ -632,7 +632,7 @@ async function saveRelationship(assetId) {
await assetsApi.deleteRelationship(existingRelationshipId.value)
}
// Create new relationship (PC controls Equipment, so PC is source, Equipment is target)
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
await assetsApi.createRelationship({
sourceassetid: controllingPcId.value,
targetassetid: assetId,

View File

@@ -35,12 +35,12 @@
</tr>
</thead>
<tbody>
<tr v-for="item in equipment" :key="item.assetid">
<tr v-for="item in machines" :key="item.assetid">
<td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.equipment?.equipmenttypename || '-' }}</td>
<td>{{ item.equipment?.vendorname || '-' }}</td>
<td>{{ item.machine?.machinetypename || '-' }}</td>
<td>{{ item.machine?.vendorname || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
@@ -49,16 +49,16 @@
<td>{{ item.locationname || '-' }}</td>
<td class="actions">
<router-link
:to="`/machines/${item.equipment?.equipmentid || item.assetid}`"
:to="`/machines/${item.machine?.machineid || item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="equipment.length === 0">
<tr v-if="machines.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No equipment found
No machines found
</td>
</tr>
</tbody>
@@ -82,10 +82,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { equipmentApi } from '../../api'
import { machinesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const equipment = ref([])
const machines = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
@@ -95,10 +95,10 @@ const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadEquipment()
loadMachines()
})
async function loadEquipment() {
async function loadMachines() {
loading.value = true
try {
const params = {
@@ -107,11 +107,11 @@ async function loadEquipment() {
}
if (search.value) params.search = search.value
const response = await equipmentApi.list(params)
equipment.value = response.data.data || []
const response = await machinesApi.list(params)
machines.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading equipment:', error)
console.error('Error loading machines:', error)
} finally {
loading.value = false
}
@@ -121,19 +121,19 @@ function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadEquipment()
loadMachines()
}, 300)
}
function goToPage(p) {
page.value = p
loadEquipment()
loadMachines()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadEquipment()
loadMachines()
}
</script>

View File

@@ -131,6 +131,9 @@
<!-- Warranty -->
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
<!-- All relationships (partof, connectedto, ...) -->
<AssetRelationships v-if="tool.assetid" :assetId="tool.assetid" />
<!-- Notes -->
<div class="section-card" v-if="tool.notes">
<h3 class="section-title">Notes</h3>
@@ -159,6 +162,7 @@ import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '../../api'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
const route = useRoute()

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +1,168 @@
<template>
<div>
<button class="print-btn" @click="print" v-if="!loading">Print Badge</button>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="equipment" class="badge-container">
<div class="model-name">{{ modelName }}</div>
<img
v-if="isInspection"
class="machine-image"
:src="geLogo"
alt="GE Logo"
/>
<img
v-else-if="imageUrl"
class="machine-image"
:src="imageUrl"
:alt="modelName"
/>
<div class="barcode-container">
<svg ref="barcodeEl"></svg>
<div class="machine-number">*{{ equipment.assetnumber }}*</div>
</div>
</div>
<div v-else class="error-msg">Equipment not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings'
import JsBarcode from 'jsbarcode'
const route = useRoute()
const loading = ref(true)
const equipment = ref(null)
const barcodeEl = ref(null)
const geLogo = ref('/ge-aerospace-logo.svg')
const isInspection = computed(() => {
if (!equipment.value) return false
return equipment.value.assetnumber?.startsWith('06')
})
const modelName = computed(() => {
if (!equipment.value) return ''
if (isInspection.value) return 'Inspection'
return equipment.value.equipment?.modelname || ''
})
const imageUrl = computed(() => {
if (!equipment.value) return null
const img = equipment.value.equipment?.imageurl
if (img) return img.startsWith('/') ? img : `/images/models/machines/${img}`
return null
})
onMounted(async () => {
getBadgeLogo().then(logo => { geLogo.value = logo })
try {
const response = await equipmentApi.get(route.params.id)
equipment.value = response.data.data
} catch (error) {
console.error('Error loading equipment:', error)
} finally {
loading.value = false
await nextTick()
generateBarcode()
}
})
function generateBarcode() {
if (!barcodeEl.value || !equipment.value) return
try {
JsBarcode(barcodeEl.value, equipment.value.assetnumber, {
format: 'CODE39',
displayValue: false,
width: 2,
height: 70,
margin: 0
})
} catch (e) {
console.error('Barcode generation error:', e)
}
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: 2.13in 3.38in; margin: 0; }
body { font-family: Arial, sans-serif; }
.badge-container {
width: 2.13in;
height: 3.38in;
background: white;
margin: 0 auto;
border: 1px solid #ccc;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.15in;
box-sizing: border-box;
}
.model-name {
font-size: 12pt;
font-weight: bold;
text-align: center;
margin-bottom: 0.1in;
color: #000;
}
.machine-image {
max-width: 1.8in;
max-height: 1.5in;
object-fit: contain;
margin-bottom: 0.1in;
}
.barcode-container {
text-align: center;
margin-top: auto;
}
.barcode-container svg {
width: 1.8in;
height: 0.9in;
}
.machine-number {
font-size: 14pt;
font-weight: bold;
font-family: monospace;
margin-top: -0.1in;
color: #000;
}
.print-btn {
display: block;
margin: 20px auto;
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
}
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: #666;
}
@media print {
.print-btn { display: none; }
.badge-container { border: none; margin: 0; }
}
</style>
<template>
<div>
<button class="print-btn" @click="print" v-if="!loading">Print Badge</button>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="machine" class="badge-container">
<div class="model-name">{{ modelName }}</div>
<img
v-if="isInspection"
class="machine-image"
:src="geLogo"
alt="GE Logo"
/>
<img
v-else-if="imageUrl"
class="machine-image"
:src="imageUrl"
:alt="modelName"
/>
<div class="barcode-container">
<svg ref="barcodeEl"></svg>
<div class="machine-number">*{{ machine.assetnumber }}*</div>
</div>
</div>
<div v-else class="error-msg">Machine not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { machinesApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings'
import JsBarcode from 'jsbarcode'
const route = useRoute()
const loading = ref(true)
const machine = ref(null)
const barcodeEl = ref(null)
const geLogo = ref('/ge-aerospace-logo.svg')
const isInspection = computed(() => {
if (!machine.value) return false
return machine.value.assetnumber?.startsWith('06')
})
const modelName = computed(() => {
if (!machine.value) return ''
if (isInspection.value) return 'Inspection'
return machine.value.machine?.modelname || ''
})
const imageUrl = computed(() => {
if (!machine.value) return null
const img = machine.value.machine?.imageurl
if (img) return img.startsWith('/') ? img : `/images/models/machines/${img}`
return null
})
onMounted(async () => {
getBadgeLogo().then(logo => { geLogo.value = logo })
try {
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
} catch (error) {
console.error('Error loading machine:', error)
} finally {
loading.value = false
await nextTick()
generateBarcode()
}
})
function generateBarcode() {
if (!barcodeEl.value || !machine.value) return
try {
JsBarcode(barcodeEl.value, machine.value.assetnumber, {
format: 'CODE39',
displayValue: false,
width: 2,
height: 70,
margin: 0
})
} catch (e) {
console.error('Barcode generation error:', e)
}
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: 2.13in 3.38in; margin: 0; }
body { font-family: Arial, sans-serif; }
.badge-container {
width: 2.13in;
height: 3.38in;
background: white;
margin: 0 auto;
border: 1px solid #ccc;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.15in;
box-sizing: border-box;
}
.model-name {
font-size: 12pt;
font-weight: bold;
text-align: center;
margin-bottom: 0.1in;
color: #000;
}
.machine-image {
max-width: 1.8in;
max-height: 1.5in;
object-fit: contain;
margin-bottom: 0.1in;
}
.barcode-container {
text-align: center;
margin-top: auto;
}
.barcode-container svg {
width: 1.8in;
height: 0.9in;
}
.machine-number {
font-size: 14pt;
font-weight: bold;
font-family: monospace;
margin-top: -0.1in;
color: #000;
}
.print-btn {
display: block;
margin: 20px auto;
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
}
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: #666;
}
@media print {
.print-btn { display: none; }
.badge-container { border: none; margin: 0; }
}
</style>

View File

@@ -127,6 +127,9 @@
<!-- Warranty -->
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
<!-- All relationships (defaultprinter, connectedto, ...) -->
<AssetRelationships v-if="printer.assetid" :assetId="printer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="printer.notes">
<h3 class="section-title">Notes</h3>
@@ -267,6 +270,7 @@ import { printersApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
import { useIdentifierFlags } from '../../composables/identifierSettings'

View File

@@ -107,8 +107,8 @@
<div v-if="loading" class="loading">Loading report...</div>
<div v-else-if="reportData">
<!-- Equipment by Type -->
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
<!-- Machines by Type -->
<div v-if="currentReport.id === 'machines-by-type'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
@@ -119,8 +119,8 @@
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.equipmenttype">
<td>{{ item.equipmenttype }}</td>
<tr v-for="item in reportData.data" :key="item.machinetype">
<td>{{ item.machinetype }}</td>
<td>{{ item.description }}</td>
<td>{{ item.count }}</td>
</tr>
@@ -235,7 +235,7 @@ const categoryOrder = ['inventory', 'compliance', 'usage']
// server-side filters each inline report accepts (query params on its endpoint)
const reportFilterFields = {
'equipment-by-type': ['businessunit'],
'machines-by-type': ['businessunit'],
'assets-by-status': ['assettype', 'businessunit'],
'asset-inventory': ['businessunit', 'location'],
'kb-popularity': ['limit'],
@@ -388,8 +388,8 @@ async function runReport(report) {
try {
let response
switch (report.id) {
case 'equipment-by-type':
response = await reportsApi.equipmentByType(params)
case 'machines-by-type':
response = await reportsApi.machinesByType(params)
break
case 'assets-by-status':
response = await reportsApi.assetsByStatus(params)

View File

@@ -70,7 +70,7 @@ const bucketOrder = [
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
return (map[a.assettypename] || '/assets/') + a.assetid
}

View File

@@ -1,367 +1,367 @@
<template>
<div class="page-header">
<h1>Audit Logs</h1>
</div>
<div class="filters">
<input
type="text"
v-model="search"
placeholder="Search by name or user..."
@input="debouncedSearch"
>
<select v-model="filterAction" @change="loadLogs">
<option value="">All Actions</option>
<option value="created">Created</option>
<option value="updated">Updated</option>
<option value="deleted">Deleted</option>
</select>
<select v-model="filterEntity" @change="loadLogs">
<option value="">All Entities</option>
<option v-for="e in entityTypes" :key="e" :value="e">{{ e }}</option>
</select>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 160px">Timestamp</th>
<th style="width: 100px">Action</th>
<th style="width: 100px">Entity</th>
<th>Name/ID</th>
<th style="width: 120px">User</th>
<th style="width: 130px">IP Address</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="loading">Loading...</td>
</tr>
<tr v-else-if="!logs.length">
<td colspan="7" class="empty">No audit logs found</td>
</tr>
<tr v-for="log in logs" :key="log.auditlogid">
<td class="timestamp">{{ formatDate(log.timestamp) }}</td>
<td>
<span class="badge" :class="actionClass(log.action)">
{{ log.action }}
</span>
</td>
<td>{{ log.entitytype }}</td>
<td>
<router-link
v-if="getEntityLink(log)"
:to="getEntityLink(log)"
class="entity-link"
>
{{ log.entityname || `#${log.entityid}` }}
</router-link>
<span v-else>{{ log.entityname || `#${log.entityid}` }}</span>
</td>
<td>{{ log.username || '-' }}</td>
<td class="ip">{{ log.ipaddress || '-' }}</td>
<td>
<button
v-if="log.changes && Object.keys(log.changes).length"
class="changes-btn"
@click="showChanges(log)"
>
{{ Object.keys(log.changes).length }} field(s)
</button>
<span v-else class="no-changes">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page <= 1" @click="page--; loadLogs()">Prev</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; loadLogs()">Next</button>
</div>
</div>
<!-- Changes Modal -->
<div v-if="selectedLog" class="modal-overlay" @click.self="selectedLog = null">
<div class="modal">
<div class="modal-header">
<h3>Changes - {{ selectedLog.entitytype }} {{ selectedLog.entityname }}</h3>
<button class="close-btn" @click="selectedLog = null">&times;</button>
</div>
<div class="modal-body">
<table class="changes-table">
<thead>
<tr>
<th>Field</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
</thead>
<tbody>
<tr v-for="(change, field) in selectedLog.changes" :key="field">
<td>{{ field }}</td>
<td class="old-value">{{ formatValue(change.old) }}</td>
<td class="new-value">{{ formatValue(change.new) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { auditLogsApi } from '../../api'
const logs = ref([])
const loading = ref(true)
const page = ref(1)
const perpage = 50
const total = ref(0)
const search = ref('')
const filterAction = ref('')
const filterEntity = ref('')
const selectedLog = ref(null)
const entityTypes = ['Asset', 'Printer', 'Computer', 'Equipment', 'Network', 'Setting', 'User', 'Application', 'KnowledgeBase']
const totalPages = computed(() => Math.ceil(total.value / perpage))
let debounceTimer = null
function debouncedSearch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
page.value = 1
loadLogs()
}, 300)
}
async function loadLogs() {
loading.value = true
try {
const params = {
page: page.value,
perpage
}
if (search.value) params.search = search.value
if (filterAction.value) params.action = filterAction.value
if (filterEntity.value) params.entitytype = filterEntity.value
const { data } = await auditLogsApi.list(params)
logs.value = data.data
total.value = data.meta.total
} catch (e) {
console.error('Failed to load audit logs:', e)
} finally {
loading.value = false
}
}
function formatDate(isoString) {
if (!isoString) return '-'
const d = new Date(isoString)
return d.toLocaleString()
}
function actionClass(action) {
switch (action) {
case 'created': return 'badge-success'
case 'updated': return 'badge-warning'
case 'deleted': return 'badge-danger'
default: return ''
}
}
function getEntityLink(log) {
if (!log.entityid) return null
const type = log.entitytype?.toLowerCase()
switch (type) {
case 'printer': return `/printers/${log.entityid}`
case 'computer': return `/pcs/${log.entityid}`
case 'equipment': return `/equipment/${log.entityid}`
case 'asset': return `/assets/${log.entityid}`
case 'application': return `/applications/${log.entityid}`
case 'knowledgebase': return `/kb/${log.entityid}`
default: return null
}
}
function showChanges(log) {
selectedLog.value = log
}
function formatValue(val) {
if (val === null || val === undefined) return '(empty)'
if (typeof val === 'boolean') return val ? 'Yes' : 'No'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
onMounted(loadLogs)
</script>
<style scoped>
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters input,
.filters select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-card);
color: var(--text);
}
.filters input {
min-width: 250px;
}
.timestamp {
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.ip {
font-family: monospace;
font-size: 0.85rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-transform: capitalize;
}
.badge-success { background: var(--success); color: white; }
.badge-warning { background: var(--warning); color: #000; }
.badge-danger { background: var(--danger); color: white; }
.entity-link {
color: var(--link);
text-decoration: none;
}
.entity-link:hover {
text-decoration: underline;
}
.changes-btn {
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.changes-btn:hover {
background: var(--primary-dark);
}
.no-changes {
color: var(--text-light);
}
.loading, .empty {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 500px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
}
.changes-table {
width: 100%;
border-collapse: collapse;
}
.changes-table th,
.changes-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.changes-table th {
background: var(--bg);
font-weight: 600;
}
.old-value {
color: var(--danger);
text-decoration: line-through;
}
.new-value {
color: var(--success);
}
</style>
<template>
<div class="page-header">
<h1>Audit Logs</h1>
</div>
<div class="filters">
<input
type="text"
v-model="search"
placeholder="Search by name or user..."
@input="debouncedSearch"
>
<select v-model="filterAction" @change="loadLogs">
<option value="">All Actions</option>
<option value="created">Created</option>
<option value="updated">Updated</option>
<option value="deleted">Deleted</option>
</select>
<select v-model="filterEntity" @change="loadLogs">
<option value="">All Entities</option>
<option v-for="e in entityTypes" :key="e" :value="e">{{ e }}</option>
</select>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 160px">Timestamp</th>
<th style="width: 100px">Action</th>
<th style="width: 100px">Entity</th>
<th>Name/ID</th>
<th style="width: 120px">User</th>
<th style="width: 130px">IP Address</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="loading">Loading...</td>
</tr>
<tr v-else-if="!logs.length">
<td colspan="7" class="empty">No audit logs found</td>
</tr>
<tr v-for="log in logs" :key="log.auditlogid">
<td class="timestamp">{{ formatDate(log.timestamp) }}</td>
<td>
<span class="badge" :class="actionClass(log.action)">
{{ log.action }}
</span>
</td>
<td>{{ log.entitytype }}</td>
<td>
<router-link
v-if="getEntityLink(log)"
:to="getEntityLink(log)"
class="entity-link"
>
{{ log.entityname || `#${log.entityid}` }}
</router-link>
<span v-else>{{ log.entityname || `#${log.entityid}` }}</span>
</td>
<td>{{ log.username || '-' }}</td>
<td class="ip">{{ log.ipaddress || '-' }}</td>
<td>
<button
v-if="log.changes && Object.keys(log.changes).length"
class="changes-btn"
@click="showChanges(log)"
>
{{ Object.keys(log.changes).length }} field(s)
</button>
<span v-else class="no-changes">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page <= 1" @click="page--; loadLogs()">Prev</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; loadLogs()">Next</button>
</div>
</div>
<!-- Changes Modal -->
<div v-if="selectedLog" class="modal-overlay" @click.self="selectedLog = null">
<div class="modal">
<div class="modal-header">
<h3>Changes - {{ selectedLog.entitytype }} {{ selectedLog.entityname }}</h3>
<button class="close-btn" @click="selectedLog = null">&times;</button>
</div>
<div class="modal-body">
<table class="changes-table">
<thead>
<tr>
<th>Field</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
</thead>
<tbody>
<tr v-for="(change, field) in selectedLog.changes" :key="field">
<td>{{ field }}</td>
<td class="old-value">{{ formatValue(change.old) }}</td>
<td class="new-value">{{ formatValue(change.new) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { auditLogsApi } from '../../api'
const logs = ref([])
const loading = ref(true)
const page = ref(1)
const perpage = 50
const total = ref(0)
const search = ref('')
const filterAction = ref('')
const filterEntity = ref('')
const selectedLog = ref(null)
const entityTypes = ['Asset', 'Printer', 'Computer', 'Machine', 'Network', 'Setting', 'User', 'Application', 'KnowledgeBase']
const totalPages = computed(() => Math.ceil(total.value / perpage))
let debounceTimer = null
function debouncedSearch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
page.value = 1
loadLogs()
}, 300)
}
async function loadLogs() {
loading.value = true
try {
const params = {
page: page.value,
perpage
}
if (search.value) params.search = search.value
if (filterAction.value) params.action = filterAction.value
if (filterEntity.value) params.entitytype = filterEntity.value
const { data } = await auditLogsApi.list(params)
logs.value = data.data
total.value = data.meta.total
} catch (e) {
console.error('Failed to load audit logs:', e)
} finally {
loading.value = false
}
}
function formatDate(isoString) {
if (!isoString) return '-'
const d = new Date(isoString)
return d.toLocaleString()
}
function actionClass(action) {
switch (action) {
case 'created': return 'badge-success'
case 'updated': return 'badge-warning'
case 'deleted': return 'badge-danger'
default: return ''
}
}
function getEntityLink(log) {
if (!log.entityid) return null
const type = log.entitytype?.toLowerCase()
switch (type) {
case 'printer': return `/printers/${log.entityid}`
case 'computer': return `/pcs/${log.entityid}`
case 'machine': return `/machines/${log.entityid}`
case 'asset': return `/assets/${log.entityid}`
case 'application': return `/applications/${log.entityid}`
case 'knowledgebase': return `/kb/${log.entityid}`
default: return null
}
}
function showChanges(log) {
selectedLog.value = log
}
function formatValue(val) {
if (val === null || val === undefined) return '(empty)'
if (typeof val === 'boolean') return val ? 'Yes' : 'No'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
onMounted(loadLogs)
</script>
<style scoped>
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters input,
.filters select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-card);
color: var(--text);
}
.filters input {
min-width: 250px;
}
.timestamp {
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.ip {
font-family: monospace;
font-size: 0.85rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-transform: capitalize;
}
.badge-success { background: var(--success); color: white; }
.badge-warning { background: var(--warning); color: #000; }
.badge-danger { background: var(--danger); color: white; }
.entity-link {
color: var(--link);
text-decoration: none;
}
.entity-link:hover {
text-decoration: underline;
}
.changes-btn {
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.changes-btn:hover {
background: var(--primary-dark);
}
.no-changes {
color: var(--text-light);
}
.loading, .empty {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 500px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
}
.changes-table {
width: 100%;
border-collapse: collapse;
}
.changes-table th,
.changes-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.changes-table th {
background: var(--bg);
font-weight: 600;
}
.old-value {
color: var(--danger);
text-decoration: line-through;
}
.new-value {
color: var(--success);
}
</style>

View File

@@ -1,145 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Equipment Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Equipment Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.equipmenttypeid">
<td>{{ t.equipmenttype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No equipment types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Equipment Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Equipment Type *</label>
<input v-model="form.equipmenttype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { equipmentApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ equipmenttype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await equipmentApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading equipment types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { equipmenttype: item.equipmenttype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { equipmenttype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await equipmentApi.types.update(editing.value.equipmenttypeid, form.value)
} else {
await equipmentApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete equipment type "${t.equipmenttype}"?`)) return
try {
await equipmentApi.types.remove(t.equipmenttypeid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>

View File

@@ -2,188 +2,101 @@
<div>
<div class="page-header">
<h2>Machine Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Machine Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Machine Type</th>
<th>Category</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="mt in machineTypes" :key="mt.machinetypeid">
<td>{{ mt.machinetype }}</td>
<td>
<span class="badge" :class="getCategoryClass(mt.category)">
{{ mt.category }}
</span>
</td>
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<tr v-for="t in visibleItems" :key="t.machinetypeid">
<td>{{ t.machinetype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(mt)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(mt)"
>
Delete
</button>
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="machineTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No machine types found
</td>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No machine types found</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingType ? 'Edit Machine Type' : 'Add Machine Type' }}</h3>
</div>
<form @submit.prevent="saveType">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Machine Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="machinetype">Type Name *</label>
<input
id="machinetype"
v-model="form.machinetype"
type="text"
class="form-control"
required
/>
<label>Machine Type *</label>
<input v-model="form.machinetype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="category">Category *</label>
<select
id="category"
v-model="form.category"
class="form-control"
required
>
<option value="">Select category...</option>
<option value="Equipment">Equipment</option>
<option value="PC">PC</option>
<option value="Network">Network</option>
<option value="Printer">Printer</option>
</select>
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Machine Type</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ typeToDelete?.machinetype }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
This may affect machines using this type.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteType">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { ref, computed, onMounted } from 'vue'
import { machinesApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const machineTypes = ref([])
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingType = ref(null)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ machinetype: '', description: '', color: '', isactive: true })
const showDeleteModal = ref(false)
const typeToDelete = ref(null)
onMounted(loadData)
const form = ref({
machinetype: '',
category: '',
description: ''
})
onMounted(() => {
loadTypes()
})
async function loadTypes() {
async function loadData() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
const response = await machinetypesApi.list(params)
machineTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
const response = await machinesApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
} finally {
@@ -191,87 +104,42 @@ async function loadTypes() {
}
}
function goToPage(p) {
page.value = p
loadTypes()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadTypes()
}
function openModal(mt = null) {
editingType.value = mt
if (mt) {
form.value = {
machinetype: mt.machinetype || '',
category: mt.category || '',
description: mt.description || ''
}
} else {
form.value = {
machinetype: '',
category: '',
description: ''
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { machinetype: item.machinetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { machinetype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingType.value = null
}
function closeModal() { showModal.value = false; editing.value = null }
async function saveType() {
async function save() {
error.value = ''
saving.value = true
try {
if (editingType.value) {
await machinetypesApi.update(editingType.value.machinetypeid, form.value)
if (editing.value) {
await machinesApi.types.update(editing.value.machinetypeid, form.value)
} else {
await machinetypesApi.create(form.value)
await machinesApi.types.create(form.value)
}
closeModal()
loadTypes()
loadData()
} catch (err) {
console.error('Error saving machine type:', err)
error.value = apiError(err, 'Failed to save machine type')
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
function confirmDelete(mt) {
typeToDelete.value = mt
showDeleteModal.value = true
}
async function deleteType() {
async function deleteType(t) {
if (!confirm(`Delete machine type "${t.machinetype}"?`)) return
try {
await machinetypesApi.delete(typeToDelete.value.machinetypeid)
showDeleteModal.value = false
typeToDelete.value = null
loadTypes()
await machinesApi.types.remove(t.machinetypeid)
loadData()
} catch (err) {
console.error('Error deleting machine type:', err)
toast.error('Failed to delete machine type')
toast.error(apiError(err, 'Failed to delete'))
}
}
function getCategoryClass(category) {
if (!category) return 'badge-info'
const c = category.toLowerCase()
if (c === 'equipment') return 'badge-info'
if (c === 'pc') return 'badge-success'
if (c === 'network') return 'badge-warning'
if (c === 'printer') return 'badge-primary'
return 'badge-info'
}
</script>
<!-- Uses global styles from style.css -->

View File

@@ -0,0 +1,277 @@
<template>
<div>
<div class="page-header">
<h2>Model Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Model Type</th>
<th>Category</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="mt in modelTypes" :key="mt.modeltypeid">
<td>{{ mt.modeltype }}</td>
<td>
<span class="badge" :class="getCategoryClass(mt.category)">
{{ mt.category }}
</span>
</td>
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(mt)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(mt)"
>
Delete
</button>
</td>
</tr>
<tr v-if="modelTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No model types found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingType ? 'Edit Model Type' : 'Add Model Type' }}</h3>
</div>
<form @submit.prevent="saveType">
<div class="modal-body">
<div class="form-group">
<label for="modeltype">Type Name *</label>
<input
id="modeltype"
v-model="form.modeltype"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="category">Category *</label>
<select
id="category"
v-model="form.category"
class="form-control"
required
>
<option value="">Select category...</option>
<option value="Equipment">Equipment</option>
<option value="PC">PC</option>
<option value="Network">Network</option>
<option value="Printer">Printer</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Model Type</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ typeToDelete?.modeltype }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
This may affect models using this type.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteType">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { modeltypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const modelTypes = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingType = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const typeToDelete = ref(null)
const form = ref({
modeltype: '',
category: '',
description: ''
})
onMounted(() => {
loadTypes()
})
async function loadTypes() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
const response = await modeltypesApi.list(params)
modelTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading model types:', err)
} finally {
loading.value = false
}
}
function goToPage(p) {
page.value = p
loadTypes()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadTypes()
}
function openModal(mt = null) {
editingType.value = mt
if (mt) {
form.value = {
modeltype: mt.modeltype || '',
category: mt.category || '',
description: mt.description || ''
}
} else {
form.value = {
modeltype: '',
category: '',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingType.value = null
}
async function saveType() {
error.value = ''
saving.value = true
try {
if (editingType.value) {
await modeltypesApi.update(editingType.value.modeltypeid, form.value)
} else {
await modeltypesApi.create(form.value)
}
closeModal()
loadTypes()
} catch (err) {
console.error('Error saving model type:', err)
error.value = apiError(err, 'Failed to save model type')
} finally {
saving.value = false
}
}
function confirmDelete(mt) {
typeToDelete.value = mt
showDeleteModal.value = true
}
async function deleteType() {
try {
await modeltypesApi.delete(typeToDelete.value.modeltypeid)
showDeleteModal.value = false
typeToDelete.value = null
loadTypes()
} catch (err) {
console.error('Error deleting model type:', err)
toast.error('Failed to delete model type')
}
}
function getCategoryClass(category) {
if (!category) return 'badge-info'
const c = category.toLowerCase()
if (c === 'equipment') return 'badge-info'
if (c === 'pc') return 'badge-success'
if (c === 'network') return 'badge-warning'
if (c === 'printer') return 'badge-primary'
return 'badge-info'
}
</script>
<!-- Uses global styles from style.css -->

View File

@@ -44,7 +44,7 @@
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
</td>
<td>{{ m.vendor || '-' }}</td>
<td>{{ m.machinetype || '-' }}</td>
<td>{{ m.modeltype || '-' }}</td>
<td>
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
View Docs
@@ -108,11 +108,11 @@
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select id="machinetypeid" v-model="form.machinetypeid" class="form-control">
<label for="modeltypeid">Model Type</label>
<select id="modeltypeid" v-model="form.modeltypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="mt in machineTypes" :key="mt.machinetypeid" :value="mt.machinetypeid">
{{ mt.machinetype }}
<option v-for="mt in modelTypes" :key="mt.modeltypeid" :value="mt.modeltypeid">
{{ mt.modeltype }}
</option>
</select>
</div>
@@ -181,7 +181,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
import { modelsApi, vendorsApi, modeltypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
@@ -189,7 +189,7 @@ const toast = useToast()
const models = ref([])
const vendors = ref([])
const machineTypes = ref([])
const modelTypes = ref([])
const loading = ref(true)
const search = ref('')
const vendorFilter = ref('')
@@ -208,7 +208,7 @@ const modelToDelete = ref(null)
const form = ref({
modelnumber: '',
vendorid: '',
machinetypeid: '',
modeltypeid: '',
description: '',
documentationurl: '',
imageurl: '',
@@ -221,7 +221,7 @@ onMounted(async () => {
await Promise.all([
loadModels(),
loadVendors(),
loadMachineTypes()
loadModelTypes()
])
})
@@ -251,12 +251,12 @@ async function loadVendors() {
}
}
async function loadMachineTypes() {
async function loadModelTypes() {
try {
const response = await machinetypesApi.list({ perpage: 100 })
machineTypes.value = response.data.data || []
const response = await modeltypesApi.list({ perpage: 100 })
modelTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
console.error('Error loading model types:', err)
}
}
@@ -285,7 +285,7 @@ function openModal(m = null) {
form.value = {
modelnumber: m.modelnumber || '',
vendorid: m.vendorid || '',
machinetypeid: m.machinetypeid || '',
modeltypeid: m.modeltypeid || '',
description: m.description || '',
documentationurl: m.documentationurl || '',
imageurl: m.imageurl || '',
@@ -295,7 +295,7 @@ function openModal(m = null) {
form.value = {
modelnumber: '',
vendorid: '',
machinetypeid: '',
modeltypeid: '',
description: '',
documentationurl: '',
imageurl: '',
@@ -318,7 +318,7 @@ async function saveModel() {
try {
const data = { ...form.value }
if (!data.vendorid) data.vendorid = null
if (!data.machinetypeid) data.machinetypeid = null
if (!data.modeltypeid) data.modeltypeid = null
if (editingModel.value) {
await modelsApi.update(editingModel.value.modelnumberid, data)

View File

@@ -1037,9 +1037,9 @@ const brandingLogos = [
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
placeholder: '/ge-monogram.svg',
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
{ kind: 'badge', key: 'badge_logo', label: 'Equipment badge logo', accept: 'image/*',
{ kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Logo printed on equipment badges. Upload an image or type a path/URL.' },
hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' },
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
placeholder: '(blank = shipped /favicon.svg)',
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
@@ -1053,7 +1053,7 @@ const identifierRows = [
{ name: 'fqdn', label: 'FQDN / Hostname' }
]
const assetTypeCols = [
{ key: 'equipment', label: 'Equipment' },
{ key: 'machine', label: 'Machine' },
{ key: 'computer', label: 'PC' },
{ key: 'printer', label: 'Printer' },
{ key: 'network_device', label: 'Network' }
@@ -1075,7 +1075,7 @@ const searchDomains = [
{ key: 'application', label: 'Applications' },
{ key: 'knowledgebase', label: 'Knowledge Base' },
{ key: 'employee', label: 'Employees' },
{ key: 'equipment', label: 'Equipment' },
{ key: 'machine', label: 'Machines' },
{ key: 'computer', label: 'PCs' },
{ key: 'printer', label: 'Printers' },
{ key: 'network_device', label: 'Network Devices' },
@@ -1170,7 +1170,7 @@ async function loadSettings() {
for (const setting of data.data) {
if (setting.key in settings) {
settings[setting.key] = setting.value
} else if (/^identifier_.+_(equipment|computer|printer|network_device)_enabled$/.test(setting.key)) {
} else if (/^identifier_.+_(machine|computer|printer|network_device)_enabled$/.test(setting.key)) {
identifierMatrix[setting.key] = setting.value !== false
} else if (/^search_.+_enabled$/.test(setting.key)) {
searchMatrix[setting.key] = setting.value !== false

View File

@@ -17,6 +17,7 @@ export const settingsGroups = [
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
@@ -42,8 +43,7 @@ export const settingsGroups = [
{
title: 'Machines',
cards: [
{ to: '/settings/equipmenttypes', icon: Wrench, title: 'Equipment Types', description: 'Manage machine subtypes + map colors' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
],
},
{

View File

@@ -208,7 +208,7 @@ function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
// Route to the right detail page by asset type.
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
const base = map[a.assettypename] || '/assets/'
return base + a.assetid
}

View File

@@ -0,0 +1,205 @@
"""Equipment -> machines rename, core half
Part 1 of the approved equipment -> machines rename (the plugin half lives in
plugins/machines/migrations/versions/0002_rename_from_equipment.py):
1. machinetypes -> modeltypes (+ machinetypeid -> modeltypeid,
machinetype -> modeltype, models.machinetypeid -> models.modeltypeid).
The table types the vendor MODELS catalog, not machine instances, so
the new name is role-accurate and frees "machinetypes" for the plugin.
2. Data flips: assettypes 'equipment' -> 'machine' (incl pluginname and
tablename), auditlogs.entitytype 'Equipment' -> 'Machine', settings keys
identifier_*_equipment_enabled -> identifier_*_machine_enabled,
search_equipment_enabled -> search_machine_enabled, and permission
rows equipment.* -> machines.* (role links survive).
3. alembic_version_equipment -> alembic_version_machines (if present) so
the renamed plugin's migration chain resumes seamlessly.
Idempotent via inspector guards, following the 7c01 pattern. MySQL is the
only dialect the core chain runs against in practice.
Revision ID: 7d17_machines_rename
Revises: 7d16_directoryemployees
Create Date: 2026-07-11
"""
from alembic import op
import sqlalchemy as sa
revision = '7d17_machines_rename'
down_revision = '7d16_directoryemployees'
branch_labels = None
depends_on = None
def _fk_names(insp, table, referred_table):
# find FK constraint names on table pointing at referred_table
return [fk['name'] for fk in insp.get_foreign_keys(table)
if fk.get('referred_table') == referred_table and fk.get('name')]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
# -- 1. machinetypes -> modeltypes ------------------------------------
if 'machinetypes' in tables and 'modeltypes' not in tables:
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
# drop the models -> machinetypes FK and its index before renaming
for name in _fk_names(insp, 'models', 'machinetypes'):
bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}")
model_indexes = {ix['name'] for ix in insp.get_indexes('models')}
if 'machinetypeid' in model_indexes:
bind.exec_driver_sql("ALTER TABLE models DROP KEY machinetypeid")
bind.exec_driver_sql("RENAME TABLE machinetypes TO modeltypes")
bind.exec_driver_sql(
"ALTER TABLE modeltypes "
"CHANGE machinetypeid modeltypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE machinetype modeltype VARCHAR(100) NOT NULL, "
"DROP KEY machinetype, "
"ADD UNIQUE KEY modeltype (modeltype)"
)
bind.exec_driver_sql(
"ALTER TABLE models "
"CHANGE machinetypeid modeltypeid INT NULL, "
"ADD KEY modeltypeid (modeltypeid), "
"ADD CONSTRAINT fk_models_modeltypeid "
"FOREIGN KEY (modeltypeid) REFERENCES modeltypes (modeltypeid)"
)
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
# -- 2. data flips ------------------------------------------------------
if 'assettypes' in tables:
bind.exec_driver_sql(
"UPDATE assettypes SET assettype='machine', pluginname='machines', "
"tablename='machines' WHERE assettype='equipment'"
)
if 'auditlogs' in tables:
bind.exec_driver_sql(
"UPDATE auditlogs SET entitytype='Machine' "
"WHERE entitytype='Equipment'"
)
if 'settings' in tables:
_rename_setting_keys(bind, old_marker='_equipment_',
new_marker='_machine_')
if 'permissions' in tables:
_rename_permissions(bind, old_prefix='equipment.',
new_prefix='machines.',
old_category='equipment', new_category='machines')
# -- 3. plugin version table --------------------------------------------
if ('alembic_version_equipment' in tables
and 'alembic_version_machines' not in tables):
bind.exec_driver_sql(
"RENAME TABLE alembic_version_equipment TO alembic_version_machines"
)
def _rename_setting_keys(bind, old_marker, new_marker):
"""Rename settings keys embedding the assettype value, collision-safe.
Covers identifier_<name>_equipment_enabled and search_equipment_enabled.
If a row with the new key already exists (fresh seed ran on new code),
the old row is dropped instead of renamed.
"""
# broad fetch; the marker replace below filters non-matches
rows = bind.exec_driver_sql(
"SELECT settingid, `key` FROM settings"
).fetchall()
for settingid, key in rows:
newkey = key.replace(old_marker, new_marker)
if newkey == key:
continue
exists = bind.exec_driver_sql(
"SELECT 1 FROM settings WHERE `key` = %s", (newkey,)
).fetchone()
if exists:
bind.exec_driver_sql(
"DELETE FROM settings WHERE settingid = %s", (settingid,))
else:
bind.exec_driver_sql(
"UPDATE settings SET `key` = %s WHERE settingid = %s",
(newkey, settingid))
def _rename_permissions(bind, old_prefix, new_prefix, old_category,
new_category):
"""Rename permission rows in place so role links survive, collision-safe."""
rows = bind.exec_driver_sql(
"SELECT permissionid, name FROM permissions"
).fetchall()
for permissionid, name in rows:
if not name.startswith(old_prefix):
continue
newname = new_prefix + name[len(old_prefix):]
exists = bind.exec_driver_sql(
"SELECT 1 FROM permissions WHERE name = %s", (newname,)
).fetchone()
if exists:
bind.exec_driver_sql(
"DELETE FROM permissions WHERE permissionid = %s",
(permissionid,))
else:
bind.exec_driver_sql(
"UPDATE permissions SET name = %s, category = %s "
"WHERE permissionid = %s",
(newname, new_category, permissionid))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if ('alembic_version_machines' in tables
and 'alembic_version_equipment' not in tables):
bind.exec_driver_sql(
"RENAME TABLE alembic_version_machines TO alembic_version_equipment"
)
if 'settings' in tables:
_rename_setting_keys(bind, old_marker='_machine_',
new_marker='_equipment_')
if 'permissions' in tables:
_rename_permissions(bind, old_prefix='machines.',
new_prefix='equipment.',
old_category='machines', new_category='equipment')
if 'auditlogs' in tables:
bind.exec_driver_sql(
"UPDATE auditlogs SET entitytype='Equipment' "
"WHERE entitytype='Machine'"
)
if 'assettypes' in tables:
bind.exec_driver_sql(
"UPDATE assettypes SET assettype='equipment', "
"pluginname='equipment', tablename='equipment' "
"WHERE assettype='machine'"
)
if 'modeltypes' in tables and 'machinetypes' not in tables:
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
for name in _fk_names(insp, 'models', 'modeltypes'):
bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}")
model_indexes = {ix['name'] for ix in insp.get_indexes('models')}
if 'modeltypeid' in model_indexes:
bind.exec_driver_sql("ALTER TABLE models DROP KEY modeltypeid")
bind.exec_driver_sql("RENAME TABLE modeltypes TO machinetypes")
bind.exec_driver_sql(
"ALTER TABLE machinetypes "
"CHANGE modeltypeid machinetypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE modeltype machinetype VARCHAR(100) NOT NULL, "
"DROP KEY modeltype, "
"ADD UNIQUE KEY machinetype (machinetype)"
)
bind.exec_driver_sql(
"ALTER TABLE models "
"CHANGE modeltypeid machinetypeid INT NULL, "
"ADD KEY machinetypeid (machinetypeid), "
"ADD CONSTRAINT models_ibfk_1 "
"FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)"
)
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")

View File

@@ -62,7 +62,7 @@ class Computer(BaseModel):
nullable=True
)
# Hardware make/model (PCs carry vendor + model like equipment)
# Hardware make/model (PCs carry vendor + model like machines)
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),

View File

@@ -1,5 +0,0 @@
"""Equipment plugin for ShopDB."""
from .plugin import EquipmentPlugin
__all__ = ['EquipmentPlugin']

View File

@@ -1,5 +0,0 @@
"""Equipment plugin API."""
from .routes import equipment_bp
__all__ = ['equipment_bp']

View File

@@ -1,8 +0,0 @@
"""Equipment plugin models."""
from .equipment import Equipment, EquipmentType
__all__ = [
'Equipment',
'EquipmentType',
]

View File

@@ -0,0 +1,5 @@
"""Machines plugin for ShopDB."""
from .plugin import MachinesPlugin
__all__ = ['MachinesPlugin']

View File

@@ -0,0 +1,5 @@
"""Machines plugin API."""
from .routes import machines_bp
__all__ = ['machines_bp']

View File

@@ -1,36 +1,36 @@
"""Equipment plugin API endpoints."""
"""Machines plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Equipment, EquipmentType
from ..models import Machine, MachineType
from shopdb.api import require_permission, require_role
equipment_bp = Blueprint('equipment', __name__)
machines_bp = Blueprint('machines', __name__)
# =============================================================================
# Equipment Types
# Machine Types
# =============================================================================
@equipment_bp.route('/types', methods=['GET'])
@machines_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_equipment_types():
"""List all equipment types."""
def list_machine_types():
"""List all machine types."""
page, per_page = get_pagination_params(request)
query = EquipmentType.query
query = MachineType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(EquipmentType.isactive == True)
query = query.filter(MachineType.isactive == True)
if search := request.args.get('search'):
query = query.filter(EquipmentType.equipmenttype.ilike(f'%{search}%'))
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
query = query.order_by(EquipmentType.equipmenttype)
query = query.order_by(MachineType.machinetype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
@@ -38,33 +38,33 @@ def list_equipment_types():
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/types/<int:type_id>', methods=['GET'])
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment_type(type_id: int):
"""Get a single equipment type."""
t = db.session.get(EquipmentType, type_id)
def get_machine_type(type_id: int):
"""Get a single machine type."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment type with ID {type_id} not found',
f'Machine type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@equipment_bp.route('/types', methods=['POST'])
@machines_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment_type():
"""Create a new equipment type."""
@require_permission('machines.create')
def create_machine_type():
"""Create a new machine type."""
data = request.get_json()
if not data or not data.get('equipmenttype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'equipmenttype is required')
if not data or not data.get('machinetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
existing = EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first()
existing = MachineType.query.filter_by(machinetype=data['machinetype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
@@ -75,12 +75,12 @@ def create_equipment_type():
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
t = EquipmentType(
equipmenttype=data['equipmenttype'],
t = MachineType(
machinetype=data['machinetype'],
description=data.get('description'),
icon=data.get('icon'), color=data.get('color')
)
@@ -88,20 +88,20 @@ def create_equipment_type():
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Equipment type created', http_code=201)
return success_response(t.to_dict(), message='Machine type created', http_code=201)
@equipment_bp.route('/types/<int:type_id>', methods=['PUT'])
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment_type(type_id: int):
"""Update an equipment type."""
t = db.session.get(EquipmentType, type_id)
@require_permission('machines.edit')
def update_machine_type(type_id: int):
"""Update a machine type."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment type with ID {type_id} not found',
f'Machine type with ID {type_id} not found',
http_code=404
)
@@ -109,62 +109,62 @@ def update_equipment_type(type_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'equipmenttype' in data and data['equipmenttype'] != t.equipmenttype:
if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first():
if 'machinetype' in data and data['machinetype'] != t.machinetype:
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
for key in ['equipmenttype', 'description', 'icon', 'color', 'isactive']:
for key in ['machinetype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Equipment type updated')
return success_response(t.to_dict(), message='Machine type updated')
@equipment_bp.route('/types/<int:type_id>', methods=['DELETE'])
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment_type(type_id: int):
"""Delete an equipment type. Refused if any asset still uses it."""
t = db.session.get(EquipmentType, type_id)
@require_permission('machines.delete')
def delete_machine_type(type_id: int):
"""Delete a machine type. Refused if any asset still uses it."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Equipment type not found', http_code=404)
inuse = Equipment.query.filter_by(equipmenttypeid=type_id).count()
return error_response(ErrorCodes.NOT_FOUND, 'Machine type not found', http_code=404)
inuse = Machine.query.filter_by(machinetypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} asset(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Equipment type deleted')
return success_response(message='Machine type deleted')
# =============================================================================
# Equipment CRUD
# Machine CRUD
# =============================================================================
@equipment_bp.route('', methods=['GET'])
@machines_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_equipment():
def list_machines():
"""
List all equipment with filtering and pagination.
List all machines with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number or name
- type_id: Filter by equipment type ID
- type_id: Filter by machine type ID
- vendor_id: Filter by vendor ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
"""
page, per_page = get_pagination_params(request)
# Join Equipment with Asset
query = db.session.query(Equipment).join(Asset)
# Join Machine with Asset
query = db.session.query(Machine).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
@@ -180,13 +180,13 @@ def list_equipment():
)
)
# Equipment type filter
# Machine type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Equipment.equipmenttypeid == int(type_id))
query = query.filter(Machine.machinetypeid == int(type_id))
# Vendor filter
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Equipment.vendorid == int(vendor_id))
query = query.filter(Machine.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
@@ -211,67 +211,67 @@ def list_equipment():
items, total = paginate_query(query, page, per_page)
# Build response with both asset and equipment data
# Build response with both asset and machine data
data = []
for equip in items:
item = equip.asset.to_dict() if equip.asset else {}
item['equipment'] = equip.to_dict()
for mach in items:
item = mach.asset.to_dict() if mach.asset else {}
item['machine'] = mach.to_dict()
data.append(item)
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/<int:equipment_id>', methods=['GET'])
@machines_bp.route('/<int:machine_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment(equipment_id: int):
"""Get a single equipment item with full details."""
equip = db.session.get(Equipment, equipment_id)
def get_machine(machine_id: int):
"""Get a single machine item with full details."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
return success_response(result)
@equipment_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment_by_asset(asset_id: int):
"""Get equipment data by asset ID."""
equip = Equipment.query.filter_by(assetid=asset_id).first()
def get_machine_by_asset(asset_id: int):
"""Get machine data by asset ID."""
mach = Machine.query.filter_by(assetid=asset_id).first()
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment for asset {asset_id} not found',
f'Machine for asset {asset_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
return success_response(result)
@equipment_bp.route('', methods=['POST'])
@machines_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment():
@require_permission('machines.create')
def create_machine():
"""
Create new equipment (creates both Asset and Equipment records).
Create new machine (creates both Asset and Machine records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- equipmenttypeid, vendorid, modelnumberid
- machinetypeid, vendorid, modelnumberid
- requiresmanualconfig, islocationonly
- mapx, mapy, notes
"""
@@ -291,12 +291,12 @@ def create_equipment():
http_code=409
)
# Get equipment asset type
equipment_type = AssetType.query.filter_by(assettype='equipment').first()
if not equipment_type:
# Get machine asset type
machine_type = AssetType.query.filter_by(assettype='machine').first()
if not machine_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Equipment asset type not found. Plugin may not be properly installed.',
'Machine asset type not found. Plugin may not be properly installed.',
http_code=500
)
@@ -307,7 +307,7 @@ def create_equipment():
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
serialnumber=data.get('serialnumber'),
assettypeid=equipment_type.assettypeid,
assettypeid=machine_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
@@ -319,10 +319,10 @@ def create_equipment():
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the equipment extension
equip = Equipment(
# Create the machine extension
mach = Machine(
assetid=asset.assetid,
equipmenttypeid=data.get('equipmenttypeid'),
machinetypeid=data.get('machinetypeid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
requiresmanualconfig=data.get('requiresmanualconfig', False),
@@ -334,32 +334,32 @@ def create_equipment():
controllermodelid=data.get('controllermodelid')
)
db.session.add(equip)
db.session.add(mach)
db.session.flush()
# Audit log
AuditLog.log('created', 'Equipment', entityid=equip.equipmentid,
AuditLog.log('created', 'Machine', entityid=mach.machineid,
entityname=data['assetnumber'])
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Equipment created', http_code=201)
return success_response(result, message='Machine created', http_code=201)
@equipment_bp.route('/<int:equipment_id>', methods=['PUT'])
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment(equipment_id: int):
"""Update equipment (both Asset and Equipment records)."""
equip = db.session.get(Equipment, equipment_id)
@require_permission('machines.edit')
def update_machine(machine_id: int):
"""Update machine (both Asset and Machine records)."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
@@ -367,7 +367,7 @@ def update_equipment(equipment_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = equip.asset
asset = mach.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
@@ -394,87 +394,87 @@ def update_equipment(equipment_id: int):
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update equipment fields
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controllervendorid', 'controllermodelid']
for key in equipment_fields:
# Update machine fields
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controllervendorid', 'controllermodelid']
for key in machine_fields:
if key in data:
old_val = getattr(equip, key)
old_val = getattr(mach, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(equip, key, data[key])
setattr(mach, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Equipment', entityid=equip.equipmentid,
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
entityname=asset.assetnumber, changes=changes)
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Equipment updated')
return success_response(result, message='Machine updated')
@equipment_bp.route('/<int:equipment_id>', methods=['DELETE'])
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment(equipment_id: int):
"""Delete (soft delete) equipment."""
equip = db.session.get(Equipment, equipment_id)
@require_permission('machines.delete')
def delete_machine(machine_id: int):
"""Delete (soft delete) machine."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
# Soft delete the asset (equipment extension will stay linked)
equip.asset.isactive = False
# Soft delete the asset (machine extension will stay linked)
mach.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'Equipment', entityid=equip.equipmentid,
entityname=equip.asset.assetnumber)
AuditLog.log('deleted', 'Machine', entityid=mach.machineid,
entityname=mach.asset.assetnumber)
db.session.commit()
return success_response(message='Equipment deleted')
return success_response(message='Machine deleted')
# =============================================================================
# Dashboard
# =============================================================================
@equipment_bp.route('/dashboard/summary', methods=['GET'])
@machines_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
def dashboard_summary():
"""Get equipment dashboard summary data."""
# Total active equipment count
total = db.session.query(Equipment).join(Asset).filter(
"""Get machine dashboard summary data."""
# Total active machine count
total = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
).count()
# Count by equipment type
# Count by machine type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
MachineType.machinetype,
db.func.count(Machine.machineid)
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
).join(Asset, Asset.assetid == Machine.assetid
).filter(Asset.isactive == True
).group_by(EquipmentType.equipmenttype
).group_by(MachineType.machinetype
).all()
# Count by status
from shopdb.api import AssetStatus
by_status = db.session.query(
AssetStatus.status,
db.func.count(Equipment.equipmentid)
).join(Asset, Asset.assetid == Equipment.assetid
db.func.count(Machine.machineid)
).join(Asset, Asset.assetid == Machine.assetid
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
).filter(Asset.isactive == True
).group_by(AssetStatus.status

View File

@@ -1,22 +1,22 @@
{
"name": "equipment",
"version": "1.0.0",
"description": "Equipment management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing equipment",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/equipment",
"provides": {
"asset_type": "equipment",
"features": [
"equipment_tracking",
"maintenance_scheduling",
"vendor_management",
"model_catalog"
]
},
"settings": {
"enable_maintenance_alerts": true,
"maintenance_alert_days": 30
}
}
{
"name": "machines",
"version": "1.0.0",
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/machines",
"provides": {
"asset_type": "machine",
"features": [
"machine_tracking",
"maintenance_scheduling",
"vendor_management",
"model_catalog"
]
},
"settings": {
"enable_maintenance_alerts": true,
"maintenance_alert_days": 30
}
}

View File

@@ -1,13 +1,13 @@
"""Alembic environment for the equipment plugin migration chain.
"""Alembic environment for the machines plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_equipment. See ADR-008 for the
per-plugin version table alembic_version_machines. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'equipment'
os.environ['PLUGIN_NAME'] = 'machines'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402

View File

@@ -1,11 +1,15 @@
"""equipment plugin anchor (ownership cutover).
"""machines plugin anchor (ownership cutover; authored as equipment).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_equipment. From this
anchor forward, new equipment schema changes land as 000N revisions in this
`flask plugin upgrade-all` can stamp into alembic_version_machines. From this
anchor forward, new machines schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
The revision id keeps its original 'equipment0001anchor' string: ids are
arbitrary and existing installs already carry it in their version table
(which the core chain renames to alembic_version_machines).
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401

View File

@@ -0,0 +1,168 @@
"""Rename equipment tables to machines
Plugin half of the approved equipment -> machines rename (the core half is
migrations/versions/7d17_machines_rename.py):
equipment -> machines (equipmentid -> machineid,
equipmenttypeid -> machinetypeid)
equipmenttypes -> machinetypes (equipmenttypeid -> machinetypeid,
equipmenttype -> machinetype)
Runs on BOTH upgraded installs and fresh installs: the core baseline creates
these tables under their old names (frozen DDL), so this revision always does
the physical rename. Idempotent: skips when the tables already carry the new
names (e.g. test DBs built by db.create_all() from current models). Refuses
to run while the core machinetypes table still exists - `flask db upgrade`
(which renames it to modeltypes) must land first.
Revision ID: machines0002rename
Revises: equipment0001anchor
"""
from alembic import op
import sqlalchemy as sa
revision = 'machines0002rename'
down_revision = 'equipment0001anchor'
branch_labels = None
depends_on = None
def _fk_names(insp, table, referred_table):
# find FK constraint names on table pointing at referred_table
return [fk['name'] for fk in insp.get_foreign_keys(table)
if fk.get('referred_table') == referred_table and fk.get('name')]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if 'equipment' not in tables and 'equipmenttypes' not in tables:
# already renamed, or fresh schema built straight from current models
return
if 'machinetypes' in tables:
# core chain still owns the machinetypes name (not yet renamed to
# modeltypes) - renaming equipmenttypes onto it would collide
raise RuntimeError(
"Core migration 7d17_machines_rename has not run: the legacy "
"machinetypes table still exists. Run `flask db upgrade` before "
"`flask plugin upgrade-all`."
)
if bind.dialect.name == 'mysql':
_upgrade_mysql(bind, insp)
else:
_upgrade_generic(insp)
def _upgrade_mysql(bind, insp):
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
# snapshot index names before the renames invalidate the table name
machine_indexes = {ix['name'] for ix in insp.get_indexes('equipment')}
# drop the equipment -> equipmenttypes FK before renaming the parent cols
for name in _fk_names(insp, 'equipment', 'equipmenttypes'):
bind.exec_driver_sql(f"ALTER TABLE equipment DROP FOREIGN KEY {name}")
bind.exec_driver_sql("RENAME TABLE equipmenttypes TO machinetypes")
bind.exec_driver_sql("RENAME TABLE equipment TO machines")
bind.exec_driver_sql(
"ALTER TABLE machinetypes "
"CHANGE equipmenttypeid machinetypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE equipmenttype machinetype VARCHAR(100) NOT NULL, "
"DROP KEY equipmenttype, "
"ADD UNIQUE KEY machinetype (machinetype)"
)
parts = [
"CHANGE equipmentid machineid INT NOT NULL AUTO_INCREMENT",
"CHANGE equipmenttypeid machinetypeid INT NULL",
]
# rename the model-declared index names to match the Machine model
if 'idx_equipment_type' in machine_indexes:
parts += ["DROP KEY idx_equipment_type",
"ADD KEY idx_machine_type (machinetypeid)"]
if 'idx_equipment_vendor' in machine_indexes:
parts += ["DROP KEY idx_equipment_vendor",
"ADD KEY idx_machine_vendor (vendorid)"]
if 'ix_equipment_assetid' in machine_indexes:
parts += ["DROP KEY ix_equipment_assetid",
"ADD UNIQUE KEY ix_machines_assetid (assetid)"]
parts += [
"ADD CONSTRAINT fk_machines_machinetypeid "
"FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)"
]
bind.exec_driver_sql("ALTER TABLE machines " + ", ".join(parts))
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
def _upgrade_generic(insp):
# SQLite (and anything else): batch rename via ALTER ... RENAME
op.rename_table('equipmenttypes', 'machinetypes')
op.rename_table('equipment', 'machines')
with op.batch_alter_table('machinetypes') as batch_op:
batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid',
existing_type=sa.Integer(), existing_nullable=False)
batch_op.alter_column('equipmenttype', new_column_name='machinetype',
existing_type=sa.String(100),
existing_nullable=False)
with op.batch_alter_table('machines') as batch_op:
batch_op.alter_column('equipmentid', new_column_name='machineid',
existing_type=sa.Integer(), existing_nullable=False)
batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid',
existing_type=sa.Integer(), existing_nullable=True)
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if 'machines' not in tables and 'machinetypes' not in tables:
return
if bind.dialect.name != 'mysql':
raise NotImplementedError(
"downgrade implemented for MySQL only (dev/prod dialect)")
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
machine_indexes = {ix['name'] for ix in insp.get_indexes('machines')}
for name in _fk_names(insp, 'machines', 'machinetypes'):
bind.exec_driver_sql(f"ALTER TABLE machines DROP FOREIGN KEY {name}")
bind.exec_driver_sql("RENAME TABLE machinetypes TO equipmenttypes")
bind.exec_driver_sql("RENAME TABLE machines TO equipment")
bind.exec_driver_sql(
"ALTER TABLE equipmenttypes "
"CHANGE machinetypeid equipmenttypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE machinetype equipmenttype VARCHAR(100) NOT NULL, "
"DROP KEY machinetype, "
"ADD UNIQUE KEY equipmenttype (equipmenttype)"
)
parts = [
"CHANGE machineid equipmentid INT NOT NULL AUTO_INCREMENT",
"CHANGE machinetypeid equipmenttypeid INT NULL",
]
if 'idx_machine_type' in machine_indexes:
parts += ["DROP KEY idx_machine_type",
"ADD KEY idx_equipment_type (equipmenttypeid)"]
if 'idx_machine_vendor' in machine_indexes:
parts += ["DROP KEY idx_machine_vendor",
"ADD KEY idx_equipment_vendor (vendorid)"]
if 'ix_machines_assetid' in machine_indexes:
parts += ["DROP KEY ix_machines_assetid",
"ADD UNIQUE KEY ix_equipment_assetid (assetid)"]
parts += [
"ADD CONSTRAINT equipment_ibfk_2 "
"FOREIGN KEY (equipmenttypeid) REFERENCES equipmenttypes (equipmenttypeid)"
]
bind.exec_driver_sql("ALTER TABLE equipment " + ", ".join(parts))
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")

View File

@@ -0,0 +1,8 @@
"""Machines plugin models."""
from .machine import Machine, MachineType
__all__ = [
'Machine',
'MachineType',
]

View File

@@ -1,36 +1,36 @@
"""Equipment plugin models."""
"""Machines plugin models."""
from shopdb.api import db, BaseModel
class EquipmentType(BaseModel):
class MachineType(BaseModel):
"""
Equipment type classification.
Machine type classification.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
"""
__tablename__ = 'equipmenttypes'
__tablename__ = 'machinetypes'
equipmenttypeid = db.Column(db.Integer, primary_key=True)
equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
machinetypeid = db.Column(db.Integer, primary_key=True)
machinetype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<EquipmentType {self.equipmenttype}>"
return f"<MachineType {self.machinetype}>"
class Equipment(BaseModel):
class Machine(BaseModel):
"""
Equipment-specific extension data.
Machine-specific extension data.
Links to core Asset table via assetid.
Stores equipment-specific fields like type, model, vendor, etc.
Stores machine-specific fields like type, model, vendor, etc.
"""
__tablename__ = 'equipment'
__tablename__ = 'machines'
equipmentid = db.Column(db.Integer, primary_key=True)
machineid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
@@ -41,10 +41,10 @@ class Equipment(BaseModel):
index=True
)
# Equipment classification
equipmenttypeid = db.Column(
# Machine classification
machinetypeid = db.Column(
db.Integer,
db.ForeignKey('equipmenttypes.equipmenttypeid'),
db.ForeignKey('machinetypes.machinetypeid'),
nullable=True
)
@@ -60,7 +60,7 @@ class Equipment(BaseModel):
nullable=True
)
# Equipment-specific fields
# Machine-specific fields
requiresmanualconfig = db.Column(
db.Boolean,
default=False,
@@ -69,7 +69,7 @@ class Equipment(BaseModel):
islocationonly = db.Column(
db.Boolean,
default=False,
comment='Virtual location marker (not actual equipment)'
comment='Virtual location marker (not actual machine)'
)
# Maintenance tracking
@@ -94,29 +94,29 @@ class Equipment(BaseModel):
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('equipment', uselist=False, lazy='joined')
backref=db.backref('machine', uselist=False, lazy='joined')
)
equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
machinetype = db.relationship('MachineType', backref='machines')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='machine_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='machine_items')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='machine_controllers')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='machine_controller_models')
__table_args__ = (
db.Index('idx_equipment_type', 'equipmenttypeid'),
db.Index('idx_equipment_vendor', 'vendorid'),
db.Index('idx_machine_type', 'machinetypeid'),
db.Index('idx_machine_vendor', 'vendorid'),
)
def __repr__(self):
return f"<Equipment {self.assetid}>"
return f"<Machine {self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.equipmenttype:
result['equipmenttypename'] = self.equipmenttype.equipmenttype
if self.machinetype:
result['machinetypename'] = self.machinetype.machinetype
if self.vendor:
result['vendorname'] = self.vendor.vendor
if self.model:

View File

@@ -1,4 +1,4 @@
"""Equipment plugin main class."""
"""Machines plugin main class."""
import json
import logging
@@ -11,18 +11,18 @@ import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType, AssetStatus
from .models import Equipment, EquipmentType
from .api import equipment_bp
from .models import Machine, MachineType
from .api import machines_bp
logger = logging.getLogger(__name__)
class EquipmentPlugin(BasePlugin):
class MachinesPlugin(BasePlugin):
"""
Equipment plugin - manages manufacturing equipment assets.
Machines plugin - manages manufacturing machine assets.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Equipment extension table.
Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Machine extension table.
"""
def __init__(self):
@@ -40,51 +40,51 @@ class EquipmentPlugin(BasePlugin):
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'equipment'),
name=self._manifest.get('name', 'machines'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Equipment management for manufacturing assets'
'Machine management for manufacturing assets'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/equipment'),
api_prefix=self._manifest.get('api_prefix', '/api/machines'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return equipment_bp
return machines_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Equipment, EquipmentType]
return [Machine, MachineType]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
logger.info(f"Machines plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_asset_statuses()
self._ensure_equipment_types()
logger.info("Equipment plugin installed")
self._ensure_machine_types()
logger.info("Machines plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure equipment asset type exists."""
existing = AssetType.query.filter_by(assettype='equipment').first()
"""Ensure machine asset type exists."""
existing = AssetType.query.filter_by(assettype='machine').first()
if not existing:
at = AssetType(
assettype='equipment',
pluginname='equipment',
tablename='equipment',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
assettype='machine',
pluginname='machines',
tablename='machines',
description='Manufacturing machines (CNCs, CMMs, lathes, etc.)',
icon='cog'
)
db.session.add(at)
logger.debug("Created asset type: equipment")
logger.debug("Created asset type: machine")
db.session.commit()
def _ensure_asset_statuses(self) -> None:
@@ -110,82 +110,82 @@ class EquipmentPlugin(BasePlugin):
db.session.commit()
def _ensure_equipment_types(self) -> None:
"""Ensure basic equipment types exist."""
equipment_types = [
def _ensure_machine_types(self) -> None:
"""Ensure basic machine types exist."""
machine_types = [
('CNC', 'Computer Numerical Control machine', 'cnc'),
('CMM', 'Coordinate Measuring Machine', 'cmm'),
('Lathe', 'Lathe machine', 'lathe'),
('Grinder', 'Grinding machine', 'grinder'),
('EDM', 'Electrical Discharge Machine', 'edm'),
('Part Marker', 'Part marking/engraving equipment', 'marker'),
('Part Marker', 'Part marking/engraving machine', 'marker'),
('Mill', 'Milling machine', 'mill'),
('Press', 'Press machine', 'press'),
('Robot', 'Industrial robot', 'robot'),
('Other', 'Other equipment type', 'cog'),
('Other', 'Other machine type', 'cog'),
]
for name, description, icon in equipment_types:
existing = EquipmentType.query.filter_by(equipmenttype=name).first()
for name, description, icon in machine_types:
existing = MachineType.query.filter_by(machinetype=name).first()
if not existing:
et = EquipmentType(
equipmenttype=name,
mt = MachineType(
machinetype=name,
description=description,
icon=icon
)
db.session.add(et)
logger.debug(f"Created equipment type: {name}")
db.session.add(mt)
logger.debug(f"Created machine type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Equipment plugin uninstalled")
logger.info("Machines plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('equipment')
def equipmentcli():
"""Equipment plugin commands."""
@click.group('machines')
def machinescli():
"""Machines plugin commands."""
pass
@equipmentcli.command('list-types')
@machinescli.command('list-types')
def list_types():
"""List all equipment types."""
"""List all machine types."""
from flask import current_app
with current_app.app_context():
types = EquipmentType.query.filter_by(isactive=True).all()
types = MachineType.query.filter_by(isactive=True).all()
if not types:
click.echo('No equipment types found.')
click.echo('No machine types found.')
return
click.echo('Equipment Types:')
click.echo('Machine Types:')
for t in types:
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
click.echo(f" [{t.machinetypeid}] {t.machinetype}")
@equipmentcli.command('stats')
@machinescli.command('stats')
def stats():
"""Show equipment statistics."""
"""Show machine statistics."""
from flask import current_app
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(Equipment).join(Asset).filter(
total = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active equipment: {total}")
click.echo(f"Total active machines: {total}")
# By type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
MachineType.machinetype,
db.func.count(Machine.machineid)
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
).join(Asset, Asset.assetid == Machine.assetid
).filter(Asset.isactive == True
).group_by(EquipmentType.equipmenttype
).group_by(MachineType.machinetype
).all()
if by_type:
@@ -193,15 +193,15 @@ class EquipmentPlugin(BasePlugin):
for t, c in by_type:
click.echo(f" {t}: {c}")
return [equipmentcli]
return [machinescli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Machine Status',
'component': 'EquipmentStatusWidget',
'endpoint': '/api/equipment/dashboard/summary',
'component': 'MachineStatusWidget',
'endpoint': '/api/machines/dashboard/summary',
'size': 'medium',
'position': 5,
},

View File

@@ -2,7 +2,7 @@
Measuring tools are gage-lab instruments (calipers, micrometers, thread gages,
bore gages, height gages, Genspect heads, ...) that measure parts, as opposed
to equipment that makes parts (see ADR-005). Each tool is a core Asset plus a
to machines that make parts (see ADR-005). Each tool is a core Asset plus a
one-to-one measuringtools extension row.
The lifecycle a measuring tool cares about is CALIBRATION, not maintenance:
@@ -50,7 +50,7 @@ class MeasuringToolType(BaseModel):
"""Measuring-tool classification (Caliper, Micrometer, Thread Gage, ...).
Site-managed lookup with a display color for badges and map markers,
the same shape as the equipment/computer/printer type tables.
the same shape as the machine/computer/printer type tables.
"""
__tablename__ = 'measuringtooltypes'

View File

@@ -317,7 +317,7 @@ def seedsupplies():
targets = _matching_models(family['matchkeys'], vendor.vendorid)
if not targets:
# machinetypeid is a legacy Model column (nullable); printers are
# modeltypeid is a legacy Model column (nullable); printers are
# asset-based now and carry their type via PrinterType, not here.
model = Model(
modelnumber=family['canonical'],

View File

@@ -2,7 +2,7 @@
Asset-general plugin: owns its warranties + warrantyassets tables, an API
surface, and a sidebar entry. Not tied to any one asset type - a warranty can
cover a PC, printer, network device, or equipment.
cover a PC, printer, network device, or machine.
"""
import json

View File

@@ -101,7 +101,7 @@ def create_app(config_name: str = None) -> Flask:
CORE_BLUEPRINT_NAMES = (
'auth',
'assets',
'machinetypes',
'modeltypes',
'plugins',
'vendors',
'models',

View File

@@ -125,31 +125,31 @@ def seed_cli():
@seed_cli.command('reference-data')
@with_appcontext
def seed_reference_data():
"""Seed reference data (machine types, statuses, etc.)."""
"""Seed reference data (model types, statuses, etc.)."""
from shopdb.extensions import db
from shopdb.core.models import MachineType, OperatingSystem, AssetStatus, LocationType
from shopdb.core.models import ModelType, OperatingSystem, AssetStatus, LocationType
from shopdb.core.models.relationship import RelationshipType
# Machine types
machine_types = [
{'machinetype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'},
{'machinetype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'},
{'machinetype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'},
{'machinetype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'},
{'machinetype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'},
{'machinetype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'},
{'machinetype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'},
{'machinetype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'},
{'machinetype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'},
{'machinetype': 'Server', 'category': 'Network', 'description': 'Server'},
{'machinetype': 'Switch', 'category': 'Network', 'description': 'Network Switch'},
{'machinetype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'},
# Model types (type the vendor models catalog)
model_types = [
{'modeltype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'},
{'modeltype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'},
{'modeltype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'},
{'modeltype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'},
{'modeltype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'},
{'modeltype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'},
{'modeltype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'},
{'modeltype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'},
{'modeltype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'},
{'modeltype': 'Server', 'category': 'Network', 'description': 'Server'},
{'modeltype': 'Switch', 'category': 'Network', 'description': 'Network Switch'},
{'modeltype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'},
]
for mt_data in machine_types:
existing = MachineType.query.filter_by(machinetype=mt_data['machinetype']).first()
for mt_data in model_types:
existing = ModelType.query.filter_by(modeltype=mt_data['modeltype']).first()
if not existing:
mt = MachineType(**mt_data)
mt = ModelType(**mt_data)
db.session.add(mt)
# Asset statuses (canonical set - the asset model is the contract)

View File

@@ -2,7 +2,7 @@
from .auth import auth_bp
from .assets import assets_bp
from .machinetypes import machinetypes_bp
from .modeltypes import modeltypes_bp
from .plugins import plugins_bp
from .vendors import vendors_bp
from .models import models_bp
@@ -24,7 +24,7 @@ from .setup import setup_bp
__all__ = [
'auth_bp',
'assets_bp',
'machinetypes_bp',
'modeltypes_bp',
'plugins_bp',
'vendors_bp',
'models_bp',

View File

@@ -332,7 +332,7 @@ def list_assets():
- per_page: Items per page (default: 20, max: 100)
- active: Filter by active status (default: true)
- search: Search by assetnumber or name
- type: Filter by asset type name (e.g., 'equipment', 'computer')
- type: Filter by asset type name (e.g., 'machine', 'computer')
- type_id: Filter by asset type ID
- status_id: Filter by status ID
- location_id: Filter by location ID
@@ -699,8 +699,8 @@ def get_assets_map():
Returns assets with mapx/mapy coordinates, joined with type-specific data.
Query parameters:
- assettype: Filter by asset type name (equipment, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for equipment/computer, networkdevicetype for network, printertype for printer)
- assettype: Filter by asset type name (machine, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for machines, computertype for PCs, networkdevicetype for network, printertype for printer)
- businessunitid: Filter by business unit ID
- statusid: Filter by status ID
- locationid: Filter by location ID
@@ -720,26 +720,26 @@ def get_assets_map():
# Eager-load plugin extension tables AND their relationships
try:
from plugins.equipment.models import Equipment
from plugins.machines.models import Machine
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.equipmenttype)
subqueryload(Asset.machine)
.joinedload(Machine.machinetype)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.vendor)
subqueryload(Asset.machine)
.joinedload(Machine.vendor)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.model)
subqueryload(Asset.machine)
.joinedload(Machine.model)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.controllervendor)
subqueryload(Asset.machine)
.joinedload(Machine.controllervendor)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.controllermodel)
subqueryload(Asset.machine)
.joinedload(Machine.controllermodel)
)
except (ImportError, AttributeError):
pass
@@ -800,11 +800,11 @@ def get_assets_map():
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
if asset_type_lower == 'equipment':
if asset_type_lower == 'machine':
try:
from plugins.equipment.models import Equipment
query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter(
Equipment.equipmenttypeid == subtype_id
from plugins.machines.models import Machine
query = query.join(Machine, Machine.assetid == Asset.assetid).filter(
Machine.machinetypeid == subtype_id
)
except ImportError:
pass
@@ -926,11 +926,11 @@ def get_assets_map():
subtypes = {}
try:
from plugins.equipment.models import EquipmentType
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype, 'color': et.color} for et in equipment_types]
from plugins.machines.models import MachineType
machine_types = MachineType.query.filter(MachineType.isactive == True).order_by(MachineType.machinetype).all()
subtypes['Machine'] = [{'id': mt.machinetypeid, 'name': mt.machinetype, 'color': mt.color} for mt in machine_types]
except ImportError:
subtypes['Equipment'] = []
subtypes['Machine'] = []
try:
from plugins.computers.models import ComputerType

View File

@@ -11,7 +11,7 @@ dashboard_bp = Blueprint('dashboard', __name__)
# Map asset type name -> dashboard category label
_TYPE_CATEGORY = {
'equipment': 'Equipment',
'machine': 'Machine',
'computer': 'PC',
'printer': 'Printer',
'network_device': 'Network',
@@ -30,11 +30,11 @@ def _count_by_type(assettype):
@jwt_required(optional=True)
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
equipment_count = _count_by_type('equipment')
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
printer_count = _count_by_type('printer')
total = equipment_count + pc_count + network_count + printer_count
total = machine_count + pc_count + network_count + printer_count
# Count by status
status_counts = db.session.query(
@@ -52,17 +52,18 @@ def get_dashboard():
).limit(10).all()
return success_response({
# Fields expected by frontend
'totalmachines': total,
'totalequipment': equipment_count,
# Fields expected by frontend (totalmachines now means machines,
# totalassets is the grand total - renamed with the machines plugin)
'totalassets': total,
'totalmachines': machine_count,
'totalpc': pc_count,
'totalnetwork': network_count,
'totalprinter': printer_count,
'activemachines': status_dict.get('In Use', 0),
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# Structured data
'counts': {
'equipment': equipment_count,
'machines': machine_count,
'pcs': pc_count,
'networkdevices': network_count,
'printers': printer_count,

View File

@@ -1,153 +0,0 @@
"""Machine Types API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
from shopdb.core.models import MachineType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
machinetypes_bp = Blueprint('machinetypes', __name__)
@machinetypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_machinetypes():
"""List all machine types with optional filtering."""
page, per_page = get_pagination_params(request)
query = MachineType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(MachineType.isactive == True)
if category := request.args.get('category'):
query = query.filter(MachineType.category == category)
if search := request.args.get('search'):
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
query = query.order_by(MachineType.machinetype)
items, total = paginate_query(query, page, per_page)
data = [mt.to_dict() for mt in items]
return paginated_response(data, page, per_page, total)
@machinetypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_machinetype(type_id: int):
"""Get a single machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
return success_response(mt.to_dict())
@machinetypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_machinetype():
"""Create a new machine type."""
data = request.get_json()
if not data or not data.get('machinetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
mt = MachineType(
machinetype=data['machinetype'],
category=data.get('category', 'Equipment'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(mt)
db.session.commit()
return success_response(mt.to_dict(), message='Machine type created', http_code=201)
@machinetypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_machinetype(type_id: int):
"""Update a machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check duplicate name
if 'machinetype' in data and data['machinetype'] != mt.machinetype:
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
for key in ['machinetype', 'category', 'description', 'icon', 'isactive']:
if key in data:
setattr(mt, key, data[key])
db.session.commit()
return success_response(mt.to_dict(), message='Machine type updated')
@machinetypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_machinetype(type_id: int):
"""Delete (deactivate) a machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
# Check if any model uses this type
from shopdb.core.models import Model
if Model.query.filter_by(machinetypeid=type_id).first():
return error_response(
ErrorCodes.CONFLICT,
'Cannot delete machine type: models are using it',
http_code=409
)
mt.isactive = False
db.session.commit()
return success_response(message='Machine type deleted')

View File

@@ -1,4 +1,4 @@
"""Models (equipment models) API endpoints - Full CRUD."""
"""Models (vendor model catalog) API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -21,7 +21,7 @@ models_bp = Blueprint('models', __name__)
@models_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_models():
"""List all equipment models."""
"""List all vendor catalog models."""
page, per_page = get_pagination_params(request)
query = Model.query
@@ -32,8 +32,8 @@ def list_models():
if vendor_id := request.args.get('vendor', type=int):
query = query.filter(Model.vendorid == vendor_id)
if machinetype_id := request.args.get('machinetype', type=int):
query = query.filter(Model.machinetypeid == machinetype_id)
if modeltype_id := request.args.get('modeltype', type=int):
query = query.filter(Model.modeltypeid == modeltype_id)
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
@@ -46,7 +46,7 @@ def list_models():
for m in items:
d = m.to_dict()
d['vendor'] = m.vendor.vendor if m.vendor else None
d['machinetype'] = m.machinetype.machinetype if m.machinetype else None
d['modeltype'] = m.modeltype.modeltype if m.modeltype else None
data.append(d)
return paginated_response(data, page, per_page, total)
@@ -67,7 +67,7 @@ def get_model(model_id: int):
data = m.to_dict()
data['vendor'] = m.vendor.to_dict() if m.vendor else None
data['machinetype'] = m.machinetype.to_dict() if m.machinetype else None
data['modeltype'] = m.modeltype.to_dict() if m.modeltype else None
return success_response(data)
@@ -97,7 +97,7 @@ def create_model():
m = Model(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'),
machinetypeid=data.get('machinetypeid'),
modeltypeid=data.get('modeltypeid'),
description=data.get('description'),
imageurl=data.get('imageurl'),
documentationurl=data.get('documentationurl'),
@@ -128,7 +128,7 @@ def update_model(model_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['modelnumber', 'vendorid', 'machinetypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data:
setattr(m, key, data[key])

View File

@@ -0,0 +1,157 @@
"""Model Types API endpoints - Full CRUD.
Types the vendor MODELS catalog (models.modeltypeid). Renamed from
machinetypes; the machines plugin now owns the "machinetypes" name.
"""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
from shopdb.core.models import ModelType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
modeltypes_bp = Blueprint('modeltypes', __name__)
@modeltypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_modeltypes():
"""List all model types with optional filtering."""
page, per_page = get_pagination_params(request)
query = ModelType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(ModelType.isactive == True)
if category := request.args.get('category'):
query = query.filter(ModelType.category == category)
if search := request.args.get('search'):
query = query.filter(ModelType.modeltype.ilike(f'%{search}%'))
query = query.order_by(ModelType.modeltype)
items, total = paginate_query(query, page, per_page)
data = [mt.to_dict() for mt in items]
return paginated_response(data, page, per_page, total)
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_modeltype(type_id: int):
"""Get a single model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
return success_response(mt.to_dict())
@modeltypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_modeltype():
"""Create a new model type."""
data = request.get_json()
if not data or not data.get('modeltype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'modeltype is required')
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
mt = ModelType(
modeltype=data['modeltype'],
category=data.get('category', 'Equipment'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(mt)
db.session.commit()
return success_response(mt.to_dict(), message='Model type created', http_code=201)
@modeltypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_modeltype(type_id: int):
"""Update a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check duplicate name
if 'modeltype' in data and data['modeltype'] != mt.modeltype:
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
for key in ['modeltype', 'category', 'description', 'icon', 'isactive']:
if key in data:
setattr(mt, key, data[key])
db.session.commit()
return success_response(mt.to_dict(), message='Model type updated')
@modeltypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_modeltype(type_id: int):
"""Delete (deactivate) a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
# Check if any model uses this type
from shopdb.core.models import Model
if Model.query.filter_by(modeltypeid=type_id).first():
return error_response(
ErrorCodes.CONFLICT,
'Cannot delete model type: models are using it',
http_code=409
)
mt.isactive = False
db.session.commit()
return success_response(message='Model type deleted')

File diff suppressed because it is too large Load Diff

View File

@@ -110,8 +110,8 @@ def _get_asset_result(asset, query, relevance=None):
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
plugin_id = asset.assetid
if asset_type_name == 'equipment' and hasattr(asset, 'equipment') and asset.equipment:
plugin_id = asset.equipment.equipmentid
if asset_type_name == 'machine' and hasattr(asset, 'machine') and asset.machine:
plugin_id = asset.machine.machineid
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
plugin_id = asset.computer.computerid
elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device:
@@ -120,7 +120,7 @@ def _get_asset_result(asset, query, relevance=None):
plugin_id = asset.printer.printerid
url_map = {
'equipment': f"/machines/{plugin_id}",
'machine': f"/machines/{plugin_id}",
'computer': f"/pcs/{plugin_id}",
'network_device': f"/network/{plugin_id}",
'printer': f"/printers/{plugin_id}",
@@ -478,21 +478,21 @@ def _search_notifications(query, search_term):
def _search_vendor_model_type(query, search_term):
"""Search assets by vendor name, model name, or equipment/device type name."""
"""Search assets by vendor name, model name, or machine/device type name."""
results = []
# Equipment: vendor, model, equipmenttype
# Machines: vendor, model, machinetype
try:
_require_enabled('equipment')
from plugins.equipment.models import Equipment, EquipmentType
equipment_assets = db.session.query(Asset).join(
Equipment, Equipment.assetid == Asset.assetid
_require_enabled('machines')
from plugins.machines.models import Machine, MachineType
machine_assets = db.session.query(Asset).join(
Machine, Machine.assetid == Asset.assetid
).outerjoin(
Vendor, Equipment.vendorid == Vendor.vendorid
Vendor, Machine.vendorid == Vendor.vendorid
).outerjoin(
Model, Equipment.modelnumberid == Model.modelnumberid
Model, Machine.modelnumberid == Model.modelnumberid
).outerjoin(
EquipmentType, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
MachineType, Machine.machinetypeid == MachineType.machinetypeid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
@@ -501,16 +501,16 @@ def _search_vendor_model_type(query, search_term):
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
EquipmentType.equipmenttype.ilike(search_term)
MachineType.machinetype.ilike(search_term)
)
).limit(10).all()
for asset in equipment_assets:
for asset in machine_assets:
results.append(_get_asset_result(asset, query, 30))
except ImportError:
pass
except Exception as e:
logger.error(f"Equipment vendor/model/type search failed: {e}")
logger.error(f"Machine vendor/model/type search failed: {e}")
# Printers: vendor, model, printertype
try:

View File

@@ -53,7 +53,7 @@ IDENTIFIER_LABELS = {
'maintenancereference': 'Maintenance Reference',
'fqdn': 'FQDN / hostname',
}
IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device']
IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device']
# Global-search result types that can be toggled on/off independently of whether
# the owning plugin is enabled. Keys match the `type` field on search results;
@@ -63,7 +63,7 @@ SEARCH_DOMAINS = {
'application': 'Applications',
'knowledgebase': 'Knowledge Base',
'employee': 'Employees',
'equipment': 'Equipment',
'machine': 'Machines',
'computer': 'PCs',
'printer': 'Printers',
'network_device': 'Network Devices',
@@ -450,7 +450,7 @@ def build_default_settings():
'value': '/ge-aerospace-logo.svg',
'valuetype': 'string',
'category': 'branding',
'description': 'Logo shown on the equipment badge print page'
'description': 'Logo shown on the machine badge print page'
},
{
'key': 'site_favicon',

View File

@@ -2,7 +2,7 @@
from .base import BaseModel, SoftDeleteMixin, AuditMixin
from .asset import Asset, AssetType, AssetStatus
from .machine import MachineType
from .modeltype import ModelType
from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
@@ -26,8 +26,8 @@ __all__ = [
'Asset',
'AssetType',
'AssetStatus',
# Legacy machine type lookup (still referenced by models.machinetypeid)
'MachineType',
# Model-type lookup (referenced by models.modeltypeid)
'ModelType',
# Reference
'Vendor',
'Model',

View File

@@ -1,274 +1,274 @@
"""Polymorphic Asset models - core of the new asset architecture."""
from shopdb.extensions import db
from .base import BaseModel, SoftDeleteMixin, AuditMixin
class AssetType(BaseModel):
"""
Registry of asset categories.
Each type maps to a plugin-owned extension table.
Examples: equipment, computer, network_device, printer
"""
__tablename__ = 'assettypes'
assettypeid = db.Column(db.Integer, primary_key=True)
assettype = db.Column(
db.String(50),
unique=True,
nullable=False,
comment='Category name: equipment, computer, network_device, printer'
)
pluginname = db.Column(
db.String(100),
nullable=True,
comment='Plugin that owns this type'
)
tablename = db.Column(
db.String(100),
nullable=True,
comment='Extension table name for this type'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<AssetType {self.assettype}>"
class AssetStatus(BaseModel):
"""Asset status options."""
__tablename__ = 'assetstatuses'
statusid = db.Column(db.Integer, primary_key=True)
status = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for UI')
def __repr__(self):
return f"<AssetStatus {self.status}>"
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
"""
Core asset model - minimal shared fields.
Category-specific data lives in plugin extension tables
(equipment, computers, network_devices, printers).
The assetid matches original machineid for migration compatibility.
"""
__tablename__ = 'assets'
assetid = db.Column(db.Integer, primary_key=True)
# Identification
assetnumber = db.Column(
db.String(50),
unique=True,
nullable=False,
index=True,
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
)
name = db.Column(
db.String(100),
comment='Display name/alias'
)
gaugelabreference = db.Column(
db.String(50),
index=True,
comment='Gauge lab asset reference (authoritative tag the gauge lab '
'assigns to equipment); distinct from assetnumber'
)
maintenancereference = db.Column(
db.String(50),
index=True,
comment='Maintenance system asset reference; distinct from assetnumber'
)
serialnumber = db.Column(
db.String(100),
index=True,
comment='Hardware serial number'
)
# Classification
assettypeid = db.Column(
db.Integer,
db.ForeignKey('assettypes.assettypeid'),
nullable=False
)
statusid = db.Column(
db.Integer,
db.ForeignKey('assetstatuses.statusid'),
default=1,
comment='In Use, Spare, Retired, etc.'
)
# Location and organization
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
businessunitid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
nullable=True
)
# Floor map position (ADR-001: asset-specific override; nullable)
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
# Notes
notes = db.Column(db.Text, nullable=True)
# Relationships
assettype = db.relationship('AssetType', backref='assets')
status = db.relationship('AssetStatus', backref='assets')
location = db.relationship('Location', backref='assets')
businessunit = db.relationship('BusinessUnit', backref='assets')
# Communications (one-to-many) - will be migrated to use assetid
communications = db.relationship(
'Communication',
foreign_keys='Communication.assetid',
backref='asset',
cascade='all, delete-orphan',
lazy='dynamic'
)
# Indexes
__table_args__ = (
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
db.Index('idx_asset_location', 'locationid'),
db.Index('idx_asset_active', 'isactive'),
db.Index('idx_asset_status', 'statusid'),
)
def __repr__(self):
return f"<Asset {self.assetnumber}>"
@property
def display_name(self):
"""Get display name (name if set, otherwise assetnumber)."""
return self.name or self.assetnumber
@property
def primary_ip(self):
"""Get primary IP address from communications."""
comm = self.communications.filter_by(
isprimary=True,
comtypeid=1 # IP type
).first()
if comm:
return comm.ipaddress
# Fall back to any IP
comm = self.communications.filter_by(comtypeid=1).first()
return comm.ipaddress if comm else None
def get_inherited_location(self):
"""
Get location data from a related asset if this asset has none.
Returns dict with locationid, location_name, mapx, mapy, and
inherited_from (assetnumber of source asset) if location was inherited.
Returns None if no location data available.
"""
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
return None
related_assets = []
if hasattr(self, 'incoming_relationships'):
for rel in self.incoming_relationships:
if rel.sourceasset and rel.isactive:
related_assets.append(rel.sourceasset)
if hasattr(self, 'outgoing_relationships'):
for rel in self.outgoing_relationships:
if rel.targetasset and rel.isactive:
related_assets.append(rel.targetasset)
for related in related_assets:
if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
return {
'locationid': related.locationid,
'locationname': related.location.locationname if related.location else None,
'mapx': related.mapx,
'mapy': related.mapy,
'inheritedfrom': related.assetnumber
}
return None
def to_dict(self, include_type_data=False, include_inherited_location=True):
"""
Convert model to dictionary.
Args:
include_type_data: If True, include category-specific data from extension table
include_inherited_location: If True, include location from related assets when missing
"""
result = super().to_dict()
# Add related object names for convenience
if self.assettype:
result['assettypename'] = self.assettype.assettype
result['assettypecolor'] = getattr(self.assettype, 'color', None)
if self.status:
result['statusname'] = self.status.status
result['statuscolor'] = self.status.color
if self.location:
result['locationname'] = self.location.locationname
if self.businessunit:
result['businessunitname'] = self.businessunit.businessunit
# Add plugin-specific ID for navigation purposes
if hasattr(self, 'equipment') and self.equipment:
result['pluginid'] = self.equipment.equipmentid
elif hasattr(self, 'computer') and self.computer:
result['pluginid'] = self.computer.computerid
elif hasattr(self, 'network_device') and self.network_device:
result['pluginid'] = self.network_device.networkdeviceid
elif hasattr(self, 'printer') and self.printer:
result['pluginid'] = self.printer.printerid
# Include inherited location if this asset has no location data
if include_inherited_location:
inherited = self.get_inherited_location()
if inherited:
result['inheritedlocation'] = inherited
# Also set the location fields if they're missing
if result.get('locationid') is None:
result['locationid'] = inherited['locationid']
result['locationname'] = inherited['locationname']
if result.get('mapx') is None:
result['mapx'] = inherited['mapx']
if result.get('mapy') is None:
result['mapy'] = inherited['mapy']
# Include extension data if requested
if include_type_data:
ext_data = self._get_extension_data()
if ext_data:
result['typedata'] = ext_data
return result
def _get_extension_data(self):
"""Get category-specific data from extension table."""
# Check for equipment extension
if hasattr(self, 'equipment') and self.equipment:
return self.equipment.to_dict()
# Check for computer extension
if hasattr(self, 'computer') and self.computer:
return self.computer.to_dict()
# Check for network_device extension
if hasattr(self, 'network_device') and self.network_device:
return self.network_device.to_dict()
# Check for printer extension
if hasattr(self, 'printer') and self.printer:
return self.printer.to_dict()
return None
"""Polymorphic Asset models - core of the new asset architecture."""
from shopdb.extensions import db
from .base import BaseModel, SoftDeleteMixin, AuditMixin
class AssetType(BaseModel):
"""
Registry of asset categories.
Each type maps to a plugin-owned extension table.
Examples: machine, computer, network_device, printer
"""
__tablename__ = 'assettypes'
assettypeid = db.Column(db.Integer, primary_key=True)
assettype = db.Column(
db.String(50),
unique=True,
nullable=False,
comment='Category name: machine, computer, network_device, printer'
)
pluginname = db.Column(
db.String(100),
nullable=True,
comment='Plugin that owns this type'
)
tablename = db.Column(
db.String(100),
nullable=True,
comment='Extension table name for this type'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<AssetType {self.assettype}>"
class AssetStatus(BaseModel):
"""Asset status options."""
__tablename__ = 'assetstatuses'
statusid = db.Column(db.Integer, primary_key=True)
status = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for UI')
def __repr__(self):
return f"<AssetStatus {self.status}>"
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
"""
Core asset model - minimal shared fields.
Category-specific data lives in plugin extension tables
(machines, computers, network_devices, printers).
The assetid matches original machineid for migration compatibility.
"""
__tablename__ = 'assets'
assetid = db.Column(db.Integer, primary_key=True)
# Identification
assetnumber = db.Column(
db.String(50),
unique=True,
nullable=False,
index=True,
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
)
name = db.Column(
db.String(100),
comment='Display name/alias'
)
gaugelabreference = db.Column(
db.String(50),
index=True,
comment='Gauge lab asset reference (authoritative tag the gauge lab '
'assigns to machines); distinct from assetnumber'
)
maintenancereference = db.Column(
db.String(50),
index=True,
comment='Maintenance system asset reference; distinct from assetnumber'
)
serialnumber = db.Column(
db.String(100),
index=True,
comment='Hardware serial number'
)
# Classification
assettypeid = db.Column(
db.Integer,
db.ForeignKey('assettypes.assettypeid'),
nullable=False
)
statusid = db.Column(
db.Integer,
db.ForeignKey('assetstatuses.statusid'),
default=1,
comment='In Use, Spare, Retired, etc.'
)
# Location and organization
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
businessunitid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
nullable=True
)
# Floor map position (ADR-001: asset-specific override; nullable)
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
# Notes
notes = db.Column(db.Text, nullable=True)
# Relationships
assettype = db.relationship('AssetType', backref='assets')
status = db.relationship('AssetStatus', backref='assets')
location = db.relationship('Location', backref='assets')
businessunit = db.relationship('BusinessUnit', backref='assets')
# Communications (one-to-many) - will be migrated to use assetid
communications = db.relationship(
'Communication',
foreign_keys='Communication.assetid',
backref='asset',
cascade='all, delete-orphan',
lazy='dynamic'
)
# Indexes
__table_args__ = (
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
db.Index('idx_asset_location', 'locationid'),
db.Index('idx_asset_active', 'isactive'),
db.Index('idx_asset_status', 'statusid'),
)
def __repr__(self):
return f"<Asset {self.assetnumber}>"
@property
def display_name(self):
"""Get display name (name if set, otherwise assetnumber)."""
return self.name or self.assetnumber
@property
def primary_ip(self):
"""Get primary IP address from communications."""
comm = self.communications.filter_by(
isprimary=True,
comtypeid=1 # IP type
).first()
if comm:
return comm.ipaddress
# Fall back to any IP
comm = self.communications.filter_by(comtypeid=1).first()
return comm.ipaddress if comm else None
def get_inherited_location(self):
"""
Get location data from a related asset if this asset has none.
Returns dict with locationid, location_name, mapx, mapy, and
inherited_from (assetnumber of source asset) if location was inherited.
Returns None if no location data available.
"""
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
return None
related_assets = []
if hasattr(self, 'incoming_relationships'):
for rel in self.incoming_relationships:
if rel.sourceasset and rel.isactive:
related_assets.append(rel.sourceasset)
if hasattr(self, 'outgoing_relationships'):
for rel in self.outgoing_relationships:
if rel.targetasset and rel.isactive:
related_assets.append(rel.targetasset)
for related in related_assets:
if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
return {
'locationid': related.locationid,
'locationname': related.location.locationname if related.location else None,
'mapx': related.mapx,
'mapy': related.mapy,
'inheritedfrom': related.assetnumber
}
return None
def to_dict(self, include_type_data=False, include_inherited_location=True):
"""
Convert model to dictionary.
Args:
include_type_data: If True, include category-specific data from extension table
include_inherited_location: If True, include location from related assets when missing
"""
result = super().to_dict()
# Add related object names for convenience
if self.assettype:
result['assettypename'] = self.assettype.assettype
result['assettypecolor'] = getattr(self.assettype, 'color', None)
if self.status:
result['statusname'] = self.status.status
result['statuscolor'] = self.status.color
if self.location:
result['locationname'] = self.location.locationname
if self.businessunit:
result['businessunitname'] = self.businessunit.businessunit
# Add plugin-specific ID for navigation purposes
if hasattr(self, 'machine') and self.machine:
result['pluginid'] = self.machine.machineid
elif hasattr(self, 'computer') and self.computer:
result['pluginid'] = self.computer.computerid
elif hasattr(self, 'network_device') and self.network_device:
result['pluginid'] = self.network_device.networkdeviceid
elif hasattr(self, 'printer') and self.printer:
result['pluginid'] = self.printer.printerid
# Include inherited location if this asset has no location data
if include_inherited_location:
inherited = self.get_inherited_location()
if inherited:
result['inheritedlocation'] = inherited
# Also set the location fields if they're missing
if result.get('locationid') is None:
result['locationid'] = inherited['locationid']
result['locationname'] = inherited['locationname']
if result.get('mapx') is None:
result['mapx'] = inherited['mapx']
if result.get('mapy') is None:
result['mapy'] = inherited['mapy']
# Include extension data if requested
if include_type_data:
ext_data = self._get_extension_data()
if ext_data:
result['typedata'] = ext_data
return result
def _get_extension_data(self):
"""Get category-specific data from extension table."""
# Check for machine extension
if hasattr(self, 'machine') and self.machine:
return self.machine.to_dict()
# Check for computer extension
if hasattr(self, 'computer') and self.computer:
return self.computer.to_dict()
# Check for network_device extension
if hasattr(self, 'network_device') and self.network_device:
return self.network_device.to_dict()
# Check for printer extension
if hasattr(self, 'printer') and self.printer:
return self.printer.to_dict()
return None

View File

@@ -1,6 +1,6 @@
"""Custom fields: site-defined extra attributes per asset type.
A CustomField is a definition scoped to one asset type (equipment, computer,
A CustomField is a definition scoped to one asset type (machine, computer,
printer, network_device). A CustomFieldValue holds one asset's value for one
field. This is the generic form of the built-in identifier columns - sites add
their own attributes without a schema change.

View File

@@ -1,31 +0,0 @@
"""Legacy machine type lookup.
The Machine instance model and its PC/status lookups were retired (ADR-001);
assets are the platform contract. MachineType is kept only because the shared
`models` table still references it via models.machinetypeid.
"""
from shopdb.extensions import db
from .base import BaseModel
class MachineType(BaseModel):
"""
Machine type classification.
Categories: Equipment, PC, Network, Printer
"""
__tablename__ = 'machinetypes'
machinetypeid = db.Column(db.Integer, primary_key=True)
machinetype = db.Column(db.String(100), unique=True, nullable=False)
category = db.Column(
db.String(50),
nullable=False,
default='Equipment',
comment='Equipment, PC, Network, or Printer'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<MachineType {self.machinetype}>"

View File

@@ -1,20 +1,20 @@
"""Model (equipment model number) model."""
"""Model (vendor catalog model number) model."""
from shopdb.extensions import db
from .base import BaseModel
class Model(BaseModel):
"""Equipment/device model information."""
"""Vendor catalog model information (machines, PCs, printers, network)."""
__tablename__ = 'models'
modelnumberid = db.Column(db.Integer, primary_key=True)
modelnumber = db.Column(db.String(100), nullable=False)
# Link to machine type (what kind of equipment this model is for)
machinetypeid = db.Column(
# Link to model type (what kind of thing this catalog model is for)
modeltypeid = db.Column(
db.Integer,
db.ForeignKey('machinetypes.machinetypeid'),
db.ForeignKey('modeltypes.modeltypeid'),
nullable=True
)
@@ -31,7 +31,7 @@ class Model(BaseModel):
notes = db.Column(db.Text)
# Relationships
machinetype = db.relationship('MachineType', backref='models')
modeltype = db.relationship('ModelType', backref='models')
vendor = db.relationship('Vendor', backref='models')
# Unique constraint on modelnumber + vendor

View File

@@ -0,0 +1,33 @@
"""Model-type lookup (types the vendor MODELS catalog).
The Machine instance model and its PC/status lookups were retired (ADR-001);
assets are the platform contract. ModelType is kept because the shared `models`
table references it via models.modeltypeid: it types vendor models (Lathe,
Switch, Laser Printer), not asset instances. Renamed from MachineType to be
role-accurate and to free the "machinetype" name for the machines plugin.
"""
from shopdb.extensions import db
from .base import BaseModel
class ModelType(BaseModel):
"""
Model-type classification (what kind of thing a catalog model is for).
Categories: Equipment, PC, Network, Printer
"""
__tablename__ = 'modeltypes'
modeltypeid = db.Column(db.Integer, primary_key=True)
modeltype = db.Column(db.String(100), unique=True, nullable=False)
category = db.Column(
db.String(50),
nullable=False,
default='Equipment',
comment='Equipment, PC, Network, or Printer'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<ModelType {self.modeltype}>"

View File

@@ -50,9 +50,9 @@ class AssetRelationship(BaseModel):
Relationships between assets.
Examples:
- Computer controls Equipment
- Computer controls Machine
- Two machines are dualpath partners
- Network device connects to equipment
- Network device connects to machine
"""
__tablename__ = 'assetrelationships'

View File

@@ -40,11 +40,11 @@ class Permission(db.Model):
('assets.create', 'Create assets', 'assets'),
('assets.edit', 'Edit assets', 'assets'),
('assets.delete', 'Delete assets', 'assets'),
# Equipment
('equipment.view', 'View equipment', 'equipment'),
('equipment.create', 'Create equipment', 'equipment'),
('equipment.edit', 'Edit equipment', 'equipment'),
('equipment.delete', 'Delete equipment', 'equipment'),
# Machines
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
# Computers
('computers.view', 'View computers', 'computers'),
('computers.create', 'Create computers', 'computers'),

View File

@@ -1,7 +1,7 @@
"""Shared Alembic env.py logic for bundled plugins.
Every bundled plugin that owns tables (computers, employees, equipment,
knowledgebase, network, notifications, printers, slides, usb, warranty) has a
Every bundled plugin that owns tables (computers, employees, knowledgebase,
machines, network, notifications, printers, slides, usb, warranty) has a
`migrations/env.py` that does the minimum:
import os
@@ -42,8 +42,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'computers': ('computertypes', 'computers', 'computerinstalledapps',
'accessprotocols', 'computeraccess'),
'employees': ('directoryemployees',),
'equipment': ('equipmenttypes', 'equipment'),
'knowledgebase': ('knowledgebase',),
'machines': ('machinetypes', 'machines'),
'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'),

View File

@@ -41,6 +41,24 @@ class PluginRegistry:
except (json.JSONDecodeError, TypeError):
# Corrupted file, start fresh
self._plugins = {}
self._migrate_renamed_plugins()
def _migrate_renamed_plugins(self) -> None:
"""Upgrade path for the equipment -> machines plugin rename.
Existing installs carry an 'equipment' entry in plugins.json. When the
renamed plugins/machines directory exists and no 'machines' entry does,
carry the state over under the new name and persist.
"""
if 'equipment' not in self._plugins or 'machines' in self._plugins:
return
machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines'
if not machines_dir.exists():
return
state = self._plugins.pop('equipment')
state.name = 'machines'
self._plugins['machines'] = state
self._save()
def _save(self) -> None:
"""Save registry to file."""

View File

@@ -20,7 +20,7 @@ def _report_ids(client, headers):
def test_reports_include_core_reports(client, auth_headers):
"""The static core reports are always listed."""
_, ids = _report_ids(client, auth_headers)
assert 'equipment-by-type' in ids
assert 'machines-by-type' in ids
assert 'pc-relationships' in ids

View File

@@ -17,7 +17,7 @@ from shopdb.plugins import plugin_manager
from shopdb.plugins.base import BasePlugin, PluginMeta
BUNDLED_PLUGINS = ('computers', 'employees', 'equipment', 'knowledgebase', 'network', 'notifications', 'printers', 'slides', 'usb')
BUNDLED_PLUGINS = ('computers', 'employees', 'knowledgebase', 'machines', 'network', 'notifications', 'printers', 'slides', 'usb')
@pytest.fixture

View File

@@ -37,7 +37,7 @@ TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS))
# no-op assertion must not apply to them. This is a frozen list on purpose - a
# newly discovered plugin does not silently get treated as a cutover no-op.
CUTOVER_PLUGINS = (
'computers', 'employees', 'equipment', 'knowledgebase', 'network',
'computers', 'employees', 'knowledgebase', 'machines', 'network',
'notifications', 'printers', 'slides', 'usb', 'warranty',
)
@@ -46,6 +46,9 @@ CUTOVER_PLUGINS = (
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# machines (renamed from equipment) keeps its original anchor id and adds the
# rename revision on top, so its head is not the f-string default.
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -95,8 +95,8 @@ def test_plugin_loader_discovers_bundled_plugins(app):
expected_plugins = {
'computers',
'employees',
'equipment',
'knowledgebase',
'machines',
'network',
'notifications',
'printers',