Rename the equipment domain to machines; retype the models catalog (ADR-011)
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:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
466
README.md
@@ -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.
|
||||
|
||||
@@ -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%. |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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">×</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">×</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 -->
|
||||
|
||||
277
frontend/src/views/settings/ModelTypesList.vue
Normal file
277
frontend/src/views/settings/ModelTypesList.vue
Normal 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 -->
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
205
migrations/versions/7d17_machines_rename.py
Normal file
205
migrations/versions/7d17_machines_rename.py
Normal 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")
|
||||
@@ -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'),
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Equipment plugin for ShopDB."""
|
||||
|
||||
from .plugin import EquipmentPlugin
|
||||
|
||||
__all__ = ['EquipmentPlugin']
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Equipment plugin API."""
|
||||
|
||||
from .routes import equipment_bp
|
||||
|
||||
__all__ = ['equipment_bp']
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Equipment plugin models."""
|
||||
|
||||
from .equipment import Equipment, EquipmentType
|
||||
|
||||
__all__ = [
|
||||
'Equipment',
|
||||
'EquipmentType',
|
||||
]
|
||||
5
plugins/machines/__init__.py
Normal file
5
plugins/machines/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Machines plugin for ShopDB."""
|
||||
|
||||
from .plugin import MachinesPlugin
|
||||
|
||||
__all__ = ['MachinesPlugin']
|
||||
5
plugins/machines/api/__init__.py
Normal file
5
plugins/machines/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Machines plugin API."""
|
||||
|
||||
from .routes import machines_bp
|
||||
|
||||
__all__ = ['machines_bp']
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
8
plugins/machines/models/__init__.py
Normal file
8
plugins/machines/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Machines plugin models."""
|
||||
|
||||
from .machine import Machine, MachineType
|
||||
|
||||
__all__ = [
|
||||
'Machine',
|
||||
'MachineType',
|
||||
]
|
||||
@@ -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:
|
||||
@@ -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,
|
||||
},
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,7 +101,7 @@ def create_app(config_name: str = None) -> Flask:
|
||||
CORE_BLUEPRINT_NAMES = (
|
||||
'auth',
|
||||
'assets',
|
||||
'machinetypes',
|
||||
'modeltypes',
|
||||
'plugins',
|
||||
'vendors',
|
||||
'models',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
@@ -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])
|
||||
|
||||
|
||||
157
shopdb/core/api/modeltypes.py
Normal file
157
shopdb/core/api/modeltypes.py
Normal 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
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
|
||||
33
shopdb/core/models/modeltype.py
Normal file
33
shopdb/core/models/modeltype.py
Normal 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}>"
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -95,8 +95,8 @@ def test_plugin_loader_discovers_bundled_plugins(app):
|
||||
expected_plugins = {
|
||||
'computers',
|
||||
'employees',
|
||||
'equipment',
|
||||
'knowledgebase',
|
||||
'machines',
|
||||
'network',
|
||||
'notifications',
|
||||
'printers',
|
||||
|
||||
Reference in New Issue
Block a user