Initial commit: Shop Database Flask Application
Flask backend with Vue 3 frontend for shop floor machine management. Includes database schema export for MySQL shopdb_flask database. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Flask configuration
|
||||||
|
FLASK_APP=wsgi.py
|
||||||
|
FLASK_ENV=development
|
||||||
|
SECRET_KEY=change-this-to-a-secure-random-string
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=mysql+pymysql://user:password@localhost:3306/shopdb_flask
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=change-this-to-another-secure-random-string
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES=3600
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES=2592000
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
59
.gitignore
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node/Frontend
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.local
|
||||||
106
CONTRIBUTING.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Contributing to ShopDB Flask
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### Database Naming Convention
|
||||||
|
|
||||||
|
**IMPORTANT: No underscores in database identifiers**
|
||||||
|
|
||||||
|
All database table names and column names must use lowercase concatenated words (no underscores).
|
||||||
|
|
||||||
|
| Pattern | Good | Bad |
|
||||||
|
|---------|------|-----|
|
||||||
|
| Table names | `printerdata` | `printer_data` |
|
||||||
|
| Column names | `passwordhash` | `password_hash` |
|
||||||
|
| Foreign keys | `machinetypeid` | `machine_type_id` |
|
||||||
|
| Index names | `idx_printer_zabbix` | Allowed for indexes |
|
||||||
|
|
||||||
|
### Intuitive Naming for Non-Technical Users
|
||||||
|
|
||||||
|
Database tables and columns should use **simple, intuitive names** that non-technical users can understand when viewing data or reports.
|
||||||
|
|
||||||
|
| Avoid | Prefer | Why |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| `printerextensions` | `printerdata` | "data" is clearer than "extensions" |
|
||||||
|
| `machinerelationships` | `machinelinks` | "links" is simpler (consider) |
|
||||||
|
| `comtypeid` | `connectiontypeid` | Spell it out when unclear |
|
||||||
|
|
||||||
|
**Guiding principle:** If someone unfamiliar with the system looked at a table name, would they understand what's in it?
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good
|
||||||
|
class PrinterExtension(db.Model):
|
||||||
|
__tablename__ = 'printerextensions'
|
||||||
|
|
||||||
|
machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'))
|
||||||
|
lastzabbixsync = db.Column(db.DateTime)
|
||||||
|
isnetworkprinter = db.Column(db.Boolean)
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
class PrinterExtension(db.Model):
|
||||||
|
__tablename__ = 'printer_extensions'
|
||||||
|
|
||||||
|
machine_id = db.Column(db.Integer, db.ForeignKey('machines.machine_id'))
|
||||||
|
last_zabbix_sync = db.Column(db.DateTime)
|
||||||
|
is_network_printer = db.Column(db.Boolean)
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Response Keys
|
||||||
|
|
||||||
|
API JSON responses should also use lowercase concatenated keys to match database columns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machineid": 1,
|
||||||
|
"machinenumber": "M001",
|
||||||
|
"lastzabbixsync": "2026-01-12T10:00:00Z",
|
||||||
|
"isnetworkprinter": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python Code
|
||||||
|
|
||||||
|
Python variable and function names follow standard Python conventions (snake_case for variables/functions, PascalCase for classes):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Variables and functions use snake_case
|
||||||
|
machine_type = get_machine_type()
|
||||||
|
is_valid = validate_input(data)
|
||||||
|
|
||||||
|
# Classes use PascalCase
|
||||||
|
class PrinterExtension:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
- Models: `shopdb/core/models/` or `plugins/<plugin>/models/`
|
||||||
|
- API routes: `shopdb/core/api/` or `plugins/<plugin>/api/`
|
||||||
|
- Services: `shopdb/core/services/` or `plugins/<plugin>/services/`
|
||||||
|
|
||||||
|
### Plugin Development
|
||||||
|
|
||||||
|
When creating a new plugin:
|
||||||
|
|
||||||
|
1. Create directory structure in `plugins/<name>/`
|
||||||
|
2. Include `manifest.json` with metadata
|
||||||
|
3. Extend `BasePlugin` class
|
||||||
|
4. Follow naming conventions above
|
||||||
|
5. Run `flask plugin install <name>` to install
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Review Checklist
|
||||||
|
|
||||||
|
- [ ] No underscores in table/column names
|
||||||
|
- [ ] API responses use consistent key naming
|
||||||
|
- [ ] Plugin follows BasePlugin interface
|
||||||
|
- [ ] Tests included for new functionality
|
||||||
608
database/schema.sql
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
-- MySQL dump 10.13 Distrib 5.6.51, for Linux (x86_64)
|
||||||
|
--
|
||||||
|
-- Host: localhost Database: shopdb_flask
|
||||||
|
-- ------------------------------------------------------
|
||||||
|
-- Server version 5.6.51
|
||||||
|
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||||
|
/*!40101 SET NAMES utf8 */;
|
||||||
|
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||||
|
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||||
|
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||||
|
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||||
|
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||||
|
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `applications`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `applications`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `applications` (
|
||||||
|
`appid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`appname` varchar(100) NOT NULL,
|
||||||
|
`appdescription` varchar(255) DEFAULT NULL,
|
||||||
|
`supportteamid` int(11) DEFAULT NULL,
|
||||||
|
`isinstallable` tinyint(1) DEFAULT NULL,
|
||||||
|
`applicationnotes` text,
|
||||||
|
`installpath` varchar(255) DEFAULT NULL,
|
||||||
|
`applicationlink` varchar(512) DEFAULT NULL,
|
||||||
|
`documentationpath` varchar(512) DEFAULT NULL,
|
||||||
|
`ishidden` tinyint(1) DEFAULT NULL,
|
||||||
|
`isprinter` tinyint(1) DEFAULT NULL,
|
||||||
|
`islicenced` tinyint(1) DEFAULT NULL,
|
||||||
|
`image` varchar(255) DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`appid`),
|
||||||
|
UNIQUE KEY `appname` (`appname`),
|
||||||
|
KEY `supportteamid` (`supportteamid`),
|
||||||
|
CONSTRAINT `applications_ibfk_1` FOREIGN KEY (`supportteamid`) REFERENCES `supportteams` (`supportteamid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=82 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `appowners`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `appowners`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `appowners` (
|
||||||
|
`appownerid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`appowner` varchar(100) NOT NULL,
|
||||||
|
`sso` varchar(50) DEFAULT NULL,
|
||||||
|
`email` varchar(100) DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`appownerid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `appversions`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `appversions`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `appversions` (
|
||||||
|
`appversionid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`appid` int(11) NOT NULL,
|
||||||
|
`version` varchar(50) NOT NULL,
|
||||||
|
`releasedate` date DEFAULT NULL,
|
||||||
|
`notes` varchar(255) DEFAULT NULL,
|
||||||
|
`dateadded` datetime DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`appversionid`),
|
||||||
|
UNIQUE KEY `uq_app_version` (`appid`,`version`),
|
||||||
|
CONSTRAINT `appversions_ibfk_1` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `businessunits`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `businessunits`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `businessunits` (
|
||||||
|
`businessunitid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`businessunit` varchar(100) NOT NULL,
|
||||||
|
`code` varchar(20) DEFAULT NULL COMMENT 'Short code',
|
||||||
|
`description` text,
|
||||||
|
`parentid` int(11) DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`businessunitid`),
|
||||||
|
UNIQUE KEY `businessunit` (`businessunit`),
|
||||||
|
UNIQUE KEY `code` (`code`),
|
||||||
|
KEY `parentid` (`parentid`),
|
||||||
|
CONSTRAINT `businessunits_ibfk_1` FOREIGN KEY (`parentid`) REFERENCES `businessunits` (`businessunitid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `communications`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `communications`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `communications` (
|
||||||
|
`communicationid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`machineid` int(11) NOT NULL,
|
||||||
|
`comtypeid` int(11) NOT NULL,
|
||||||
|
`ipaddress` varchar(50) DEFAULT NULL,
|
||||||
|
`subnetmask` varchar(50) DEFAULT NULL,
|
||||||
|
`gateway` varchar(50) DEFAULT NULL,
|
||||||
|
`dns1` varchar(50) DEFAULT NULL,
|
||||||
|
`dns2` varchar(50) DEFAULT NULL,
|
||||||
|
`macaddress` varchar(50) DEFAULT NULL,
|
||||||
|
`isdhcp` tinyint(1) DEFAULT NULL,
|
||||||
|
`comport` varchar(20) DEFAULT NULL,
|
||||||
|
`baudrate` int(11) DEFAULT NULL,
|
||||||
|
`databits` int(11) DEFAULT NULL,
|
||||||
|
`stopbits` varchar(10) DEFAULT NULL,
|
||||||
|
`parity` varchar(20) DEFAULT NULL,
|
||||||
|
`flowcontrol` varchar(20) DEFAULT NULL,
|
||||||
|
`port` int(11) DEFAULT NULL,
|
||||||
|
`username` varchar(100) DEFAULT NULL,
|
||||||
|
`pathname` varchar(255) DEFAULT NULL,
|
||||||
|
`pathname2` varchar(255) DEFAULT NULL COMMENT 'Secondary path for dualpath',
|
||||||
|
`isprimary` tinyint(1) DEFAULT NULL COMMENT 'Primary communication method',
|
||||||
|
`ismachinenetwork` tinyint(1) DEFAULT NULL COMMENT 'On machine network vs office network',
|
||||||
|
`notes` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`communicationid`),
|
||||||
|
KEY `comtypeid` (`comtypeid`),
|
||||||
|
KEY `idx_comm_ip` (`ipaddress`),
|
||||||
|
KEY `idx_comm_machine` (`machineid`),
|
||||||
|
CONSTRAINT `communications_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`),
|
||||||
|
CONSTRAINT `communications_ibfk_2` FOREIGN KEY (`comtypeid`) REFERENCES `communicationtypes` (`comtypeid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=117 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `communicationtypes`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `communicationtypes`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `communicationtypes` (
|
||||||
|
`comtypeid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`comtype` varchar(50) NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`comtypeid`),
|
||||||
|
UNIQUE KEY `comtype` (`comtype`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `installedapps`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `installedapps`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `installedapps` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`machineid` int(11) NOT NULL,
|
||||||
|
`appid` int(11) NOT NULL,
|
||||||
|
`appversionid` int(11) DEFAULT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
`installeddate` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_machine_app` (`machineid`,`appid`),
|
||||||
|
KEY `appid` (`appid`),
|
||||||
|
KEY `appversionid` (`appversionid`),
|
||||||
|
CONSTRAINT `installedapps_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`),
|
||||||
|
CONSTRAINT `installedapps_ibfk_2` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`),
|
||||||
|
CONSTRAINT `installedapps_ibfk_3` FOREIGN KEY (`appversionid`) REFERENCES `appversions` (`appversionid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2392 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `knowledgebase`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `knowledgebase`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `knowledgebase` (
|
||||||
|
`linkid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`appid` int(11) DEFAULT NULL,
|
||||||
|
`shortdescription` varchar(500) NOT NULL,
|
||||||
|
`linkurl` varchar(2000) DEFAULT NULL,
|
||||||
|
`keywords` varchar(500) DEFAULT NULL,
|
||||||
|
`clicks` int(11) DEFAULT NULL,
|
||||||
|
`lastupdated` datetime DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`linkid`),
|
||||||
|
KEY `appid` (`appid`),
|
||||||
|
CONSTRAINT `knowledgebase_ibfk_1` FOREIGN KEY (`appid`) REFERENCES `applications` (`appid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=254 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `locations`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `locations`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `locations` (
|
||||||
|
`locationid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`locationname` varchar(100) NOT NULL,
|
||||||
|
`building` varchar(100) DEFAULT NULL,
|
||||||
|
`floor` varchar(50) DEFAULT NULL,
|
||||||
|
`room` varchar(50) DEFAULT NULL,
|
||||||
|
`description` text,
|
||||||
|
`mapimage` varchar(500) DEFAULT NULL COMMENT 'Path to floor map image',
|
||||||
|
`mapwidth` int(11) DEFAULT NULL,
|
||||||
|
`mapheight` int(11) DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`locationid`),
|
||||||
|
UNIQUE KEY `locationname` (`locationname`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `machinerelationships`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `machinerelationships`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `machinerelationships` (
|
||||||
|
`relationshipid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`parentmachineid` int(11) NOT NULL,
|
||||||
|
`childmachineid` int(11) NOT NULL,
|
||||||
|
`relationshiptypeid` int(11) NOT NULL,
|
||||||
|
`notes` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`relationshipid`),
|
||||||
|
UNIQUE KEY `uq_machine_relationship` (`parentmachineid`,`childmachineid`,`relationshiptypeid`),
|
||||||
|
KEY `childmachineid` (`childmachineid`),
|
||||||
|
KEY `relationshiptypeid` (`relationshiptypeid`),
|
||||||
|
CONSTRAINT `machinerelationships_ibfk_1` FOREIGN KEY (`parentmachineid`) REFERENCES `machines` (`machineid`),
|
||||||
|
CONSTRAINT `machinerelationships_ibfk_2` FOREIGN KEY (`childmachineid`) REFERENCES `machines` (`machineid`),
|
||||||
|
CONSTRAINT `machinerelationships_ibfk_3` FOREIGN KEY (`relationshiptypeid`) REFERENCES `relationshiptypes` (`relationshiptypeid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `machines`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `machines`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `machines` (
|
||||||
|
`machineid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`machinenumber` varchar(50) NOT NULL COMMENT 'Business identifier (e.g., CMM01, G5QX1GT3ESF)',
|
||||||
|
`alias` varchar(100) DEFAULT NULL COMMENT 'Friendly name',
|
||||||
|
`hostname` varchar(100) DEFAULT NULL COMMENT 'Network hostname (for PCs)',
|
||||||
|
`serialnumber` varchar(100) DEFAULT NULL COMMENT 'Hardware serial number',
|
||||||
|
`machinetypeid` int(11) NOT NULL,
|
||||||
|
`pctypeid` int(11) DEFAULT NULL COMMENT 'Set for PCs, NULL for equipment',
|
||||||
|
`businessunitid` int(11) DEFAULT NULL,
|
||||||
|
`modelnumberid` int(11) DEFAULT NULL,
|
||||||
|
`vendorid` int(11) DEFAULT NULL,
|
||||||
|
`statusid` int(11) DEFAULT NULL COMMENT 'In Use, Spare, Retired, etc.',
|
||||||
|
`locationid` int(11) DEFAULT NULL,
|
||||||
|
`mapleft` int(11) DEFAULT NULL COMMENT 'X coordinate on floor map',
|
||||||
|
`maptop` int(11) DEFAULT NULL COMMENT 'Y coordinate on floor map',
|
||||||
|
`islocationonly` tinyint(1) DEFAULT NULL COMMENT 'Virtual location marker (not actual machine)',
|
||||||
|
`osid` int(11) DEFAULT NULL,
|
||||||
|
`loggedinuser` varchar(100) DEFAULT NULL,
|
||||||
|
`lastreporteddate` datetime DEFAULT NULL,
|
||||||
|
`lastboottime` datetime DEFAULT NULL,
|
||||||
|
`isvnc` tinyint(1) DEFAULT NULL COMMENT 'VNC remote access enabled',
|
||||||
|
`iswinrm` tinyint(1) DEFAULT NULL COMMENT 'WinRM enabled',
|
||||||
|
`isshopfloor` tinyint(1) DEFAULT NULL COMMENT 'Shopfloor PC',
|
||||||
|
`requiresmanualconfig` tinyint(1) DEFAULT NULL COMMENT 'Multi-PC machine needs manual configuration',
|
||||||
|
`notes` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
`deleteddate` datetime DEFAULT NULL,
|
||||||
|
`deletedby` varchar(100) DEFAULT NULL,
|
||||||
|
`createdby` varchar(100) DEFAULT NULL,
|
||||||
|
`modifiedby` varchar(100) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`machineid`),
|
||||||
|
UNIQUE KEY `ix_machines_machinenumber` (`machinenumber`),
|
||||||
|
KEY `pctypeid` (`pctypeid`),
|
||||||
|
KEY `businessunitid` (`businessunitid`),
|
||||||
|
KEY `modelnumberid` (`modelnumberid`),
|
||||||
|
KEY `vendorid` (`vendorid`),
|
||||||
|
KEY `statusid` (`statusid`),
|
||||||
|
KEY `osid` (`osid`),
|
||||||
|
KEY `idx_machine_active` (`isactive`),
|
||||||
|
KEY `idx_machine_hostname` (`hostname`),
|
||||||
|
KEY `idx_machine_type_bu` (`machinetypeid`,`businessunitid`),
|
||||||
|
KEY `ix_machines_serialnumber` (`serialnumber`),
|
||||||
|
KEY `ix_machines_hostname` (`hostname`),
|
||||||
|
KEY `idx_machine_location` (`locationid`),
|
||||||
|
CONSTRAINT `machines_ibfk_1` FOREIGN KEY (`machinetypeid`) REFERENCES `machinetypes` (`machinetypeid`),
|
||||||
|
CONSTRAINT `machines_ibfk_2` FOREIGN KEY (`pctypeid`) REFERENCES `pctypes` (`pctypeid`),
|
||||||
|
CONSTRAINT `machines_ibfk_3` FOREIGN KEY (`businessunitid`) REFERENCES `businessunits` (`businessunitid`),
|
||||||
|
CONSTRAINT `machines_ibfk_4` FOREIGN KEY (`modelnumberid`) REFERENCES `models` (`modelnumberid`),
|
||||||
|
CONSTRAINT `machines_ibfk_5` FOREIGN KEY (`vendorid`) REFERENCES `vendors` (`vendorid`),
|
||||||
|
CONSTRAINT `machines_ibfk_6` FOREIGN KEY (`statusid`) REFERENCES `machinestatuses` (`statusid`),
|
||||||
|
CONSTRAINT `machines_ibfk_7` FOREIGN KEY (`locationid`) REFERENCES `locations` (`locationid`),
|
||||||
|
CONSTRAINT `machines_ibfk_8` FOREIGN KEY (`osid`) REFERENCES `operatingsystems` (`osid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=639 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `machinestatuses`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `machinestatuses`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `machinestatuses` (
|
||||||
|
`statusid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`status` varchar(50) NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`color` varchar(20) DEFAULT NULL COMMENT 'CSS color for UI',
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`statusid`),
|
||||||
|
UNIQUE KEY `status` (`status`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `machinetypes`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `machinetypes`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `machinetypes` (
|
||||||
|
`machinetypeid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`machinetype` varchar(100) NOT NULL,
|
||||||
|
`category` varchar(50) NOT NULL COMMENT 'Equipment, PC, Network, or Printer',
|
||||||
|
`description` text,
|
||||||
|
`icon` varchar(50) DEFAULT NULL COMMENT 'Icon name for UI',
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`machinetypeid`),
|
||||||
|
UNIQUE KEY `machinetype` (`machinetype`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=29 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `models`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `models`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `models` (
|
||||||
|
`modelnumberid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`modelnumber` varchar(100) NOT NULL,
|
||||||
|
`machinetypeid` int(11) DEFAULT NULL,
|
||||||
|
`vendorid` int(11) DEFAULT NULL,
|
||||||
|
`description` text,
|
||||||
|
`imageurl` varchar(500) DEFAULT NULL COMMENT 'URL to product image',
|
||||||
|
`documentationurl` varchar(500) DEFAULT NULL COMMENT 'URL to documentation',
|
||||||
|
`notes` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`modelnumberid`),
|
||||||
|
UNIQUE KEY `uq_model_vendor` (`modelnumber`,`vendorid`),
|
||||||
|
KEY `machinetypeid` (`machinetypeid`),
|
||||||
|
KEY `vendorid` (`vendorid`),
|
||||||
|
CONSTRAINT `models_ibfk_1` FOREIGN KEY (`machinetypeid`) REFERENCES `machinetypes` (`machinetypeid`),
|
||||||
|
CONSTRAINT `models_ibfk_2` FOREIGN KEY (`vendorid`) REFERENCES `vendors` (`vendorid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=105 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `operatingsystems`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `operatingsystems`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `operatingsystems` (
|
||||||
|
`osid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`osname` varchar(100) NOT NULL,
|
||||||
|
`osversion` varchar(50) DEFAULT NULL,
|
||||||
|
`architecture` varchar(20) DEFAULT NULL COMMENT 'x86, x64, ARM',
|
||||||
|
`endoflife` date DEFAULT NULL COMMENT 'End of support date',
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`osid`),
|
||||||
|
UNIQUE KEY `uq_os_name_version` (`osname`,`osversion`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `pctypes`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `pctypes`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `pctypes` (
|
||||||
|
`pctypeid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`pctype` varchar(100) NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`pctypeid`),
|
||||||
|
UNIQUE KEY `pctype` (`pctype`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `printerdata`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `printerdata`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `printerdata` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`machineid` int(11) NOT NULL,
|
||||||
|
`windowsname` varchar(255) DEFAULT NULL COMMENT 'Windows printer name (e.g., \\\\server\\printer)',
|
||||||
|
`sharename` varchar(100) DEFAULT NULL COMMENT 'CSF/share name',
|
||||||
|
`iscsf` tinyint(1) DEFAULT NULL COMMENT 'Is CSF printer',
|
||||||
|
`installpath` varchar(255) DEFAULT NULL COMMENT 'Driver install path',
|
||||||
|
`pin` varchar(20) DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `ix_printerdata_machineid` (`machineid`),
|
||||||
|
KEY `idx_printer_windowsname` (`windowsname`),
|
||||||
|
CONSTRAINT `printerdata_ibfk_1` FOREIGN KEY (`machineid`) REFERENCES `machines` (`machineid`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `relationshiptypes`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `relationshiptypes`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `relationshiptypes` (
|
||||||
|
`relationshiptypeid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`relationshiptype` varchar(50) NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`relationshiptypeid`),
|
||||||
|
UNIQUE KEY `relationshiptype` (`relationshiptype`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `roles`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `roles`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `roles` (
|
||||||
|
`roleid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`rolename` varchar(50) NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`roleid`),
|
||||||
|
UNIQUE KEY `rolename` (`rolename`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `supportteams`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `supportteams`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `supportteams` (
|
||||||
|
`supportteamid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`teamname` varchar(100) NOT NULL,
|
||||||
|
`teamurl` varchar(255) DEFAULT NULL,
|
||||||
|
`appownerid` int(11) DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`supportteamid`),
|
||||||
|
KEY `appownerid` (`appownerid`),
|
||||||
|
CONSTRAINT `supportteams_ibfk_1` FOREIGN KEY (`appownerid`) REFERENCES `appowners` (`appownerid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `userroles`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `userroles`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `userroles` (
|
||||||
|
`userid` int(11) NOT NULL,
|
||||||
|
`roleid` int(11) NOT NULL,
|
||||||
|
PRIMARY KEY (`userid`,`roleid`),
|
||||||
|
KEY `roleid` (`roleid`),
|
||||||
|
CONSTRAINT `userroles_ibfk_1` FOREIGN KEY (`userid`) REFERENCES `users` (`userid`),
|
||||||
|
CONSTRAINT `userroles_ibfk_2` FOREIGN KEY (`roleid`) REFERENCES `roles` (`roleid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `users`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `users`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`userid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`email` varchar(255) NOT NULL,
|
||||||
|
`passwordhash` varchar(255) NOT NULL,
|
||||||
|
`firstname` varchar(100) DEFAULT NULL,
|
||||||
|
`lastname` varchar(100) DEFAULT NULL,
|
||||||
|
`lastlogindate` datetime DEFAULT NULL,
|
||||||
|
`failedlogins` int(11) DEFAULT NULL,
|
||||||
|
`lockeduntil` datetime DEFAULT NULL,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`userid`),
|
||||||
|
UNIQUE KEY `email` (`email`),
|
||||||
|
UNIQUE KEY `ix_users_username` (`username`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `vendors`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `vendors`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!40101 SET character_set_client = utf8 */;
|
||||||
|
CREATE TABLE `vendors` (
|
||||||
|
`vendorid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`vendor` varchar(100) NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`website` varchar(255) DEFAULT NULL,
|
||||||
|
`supportphone` varchar(50) DEFAULT NULL,
|
||||||
|
`supportemail` varchar(100) DEFAULT NULL,
|
||||||
|
`notes` text,
|
||||||
|
`createddate` datetime NOT NULL,
|
||||||
|
`modifieddate` datetime NOT NULL,
|
||||||
|
`isactive` tinyint(1) NOT NULL,
|
||||||
|
PRIMARY KEY (`vendorid`),
|
||||||
|
UNIQUE KEY `vendor` (`vendor`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=44 DEFAULT CHARSET=latin1;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||||
|
|
||||||
|
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||||
|
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||||
|
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||||
|
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||||
|
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||||
|
|
||||||
|
-- Dump completed on 2026-01-13 21:07:15
|
||||||
104
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Frontend Development Standards
|
||||||
|
|
||||||
|
## CSS Styling Standards
|
||||||
|
|
||||||
|
### Use CSS Variables for ALL Colors
|
||||||
|
|
||||||
|
**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`.
|
||||||
|
|
||||||
|
Available variables:
|
||||||
|
```css
|
||||||
|
--primary /* Primary brand color */
|
||||||
|
--primary-dark /* Darker primary for hover states */
|
||||||
|
--secondary /* Secondary/muted color */
|
||||||
|
--success /* Success states (green) */
|
||||||
|
--warning /* Warning states (orange) */
|
||||||
|
--danger /* Error/danger states (red) */
|
||||||
|
--bg /* Page background */
|
||||||
|
--bg-card /* Card/panel background */
|
||||||
|
--text /* Primary text color */
|
||||||
|
--text-light /* Secondary/muted text */
|
||||||
|
--border /* Border color */
|
||||||
|
--link /* Link color (bright blue in dark mode) */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bad:**
|
||||||
|
```css
|
||||||
|
.my-card {
|
||||||
|
background: white;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Good:**
|
||||||
|
```css
|
||||||
|
.my-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detail Pages - Use Global Styles
|
||||||
|
|
||||||
|
All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`:
|
||||||
|
|
||||||
|
- `.detail-page` - Container wrapper
|
||||||
|
- `.hero-card` - Main hero section with image and info
|
||||||
|
- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details`
|
||||||
|
- `.section-card` - Info sections
|
||||||
|
- `.section-title` - Section headers
|
||||||
|
- `.info-list`, `.info-row`, `.info-label`, `.info-value`
|
||||||
|
- `.content-grid`, `.content-column` - Two-column layout
|
||||||
|
- `.audit-footer` - Created/modified timestamps
|
||||||
|
|
||||||
|
**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications).
|
||||||
|
|
||||||
|
### PrinterDetail.vue is the Master Template for Detail Pages
|
||||||
|
|
||||||
|
Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns.
|
||||||
|
|
||||||
|
### List Pages - Use Global Styles
|
||||||
|
|
||||||
|
All list pages should use the **unified global styles** from `style.css`:
|
||||||
|
|
||||||
|
- `.page-header` - Header with title and action button
|
||||||
|
- `.filters` - Search and filter controls
|
||||||
|
- `.card` - Main content container
|
||||||
|
- `.table-container` - Scrollable table wrapper
|
||||||
|
- `table`, `th`, `td` - Table styling
|
||||||
|
- `.pagination` - Page navigation
|
||||||
|
- `.badge`, `.badge-success`, etc. - Status badges
|
||||||
|
- `.actions` - Action button column
|
||||||
|
|
||||||
|
**PrintersList.vue is the Master Template for List Pages**
|
||||||
|
|
||||||
|
Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS.
|
||||||
|
|
||||||
|
**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base).
|
||||||
|
|
||||||
|
### Dark Mode Support
|
||||||
|
|
||||||
|
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
|
||||||
|
|
||||||
|
## Component Organization
|
||||||
|
|
||||||
|
- **Global styles**: `src/assets/style.css`
|
||||||
|
- **Page-specific styles**: Scoped `<style scoped>` block, only for unique elements
|
||||||
|
- **Font sizes**: Use `rem` units, base is 18px for readability on 1080p
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
assets/
|
||||||
|
style.css # Global styles, CSS variables, detail page styles
|
||||||
|
views/
|
||||||
|
machines/
|
||||||
|
MachineDetail.vue # Uses global styles only
|
||||||
|
pcs/
|
||||||
|
PCDetail.vue # Global + PC-specific (app-list, etc.)
|
||||||
|
printers/
|
||||||
|
PrinterDetail.vue # Global + printer-specific (supplies-grid)
|
||||||
|
applications/
|
||||||
|
ApplicationDetail.vue # Global + app-specific (version-list, pc-list)
|
||||||
|
```
|
||||||
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ShopDB</title>
|
||||||
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1565
frontend/package-lock.json
generated
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "shopdb-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/public/ge-aerospace-logo.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
frontend/public/images/models/computers/Latitude-5450.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/models/computers/Optiplex-5050.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
frontend/public/images/models/computers/Optiplex-5060.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend/public/images/models/computers/Optiplex-7000-Plus.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/images/models/computers/Optiplex-7000.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
frontend/public/images/models/computers/Optiplex-7080.jpg
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/public/images/models/machines/1000C1000.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
frontend/public/images/models/machines/2SP-V80.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
frontend/public/images/models/machines/7107sf.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/models/machines/Cisco9120.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/images/models/machines/IDF.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/images/models/machines/M710uc.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
frontend/public/images/models/machines/M719uc.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
frontend/public/images/models/machines/OptiPlex-7070.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/images/models/machines/Optiplex-7000.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
frontend/public/images/models/machines/a81nx.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
BIN
frontend/public/images/models/machines/abtech-eas1000.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
frontend/public/images/models/machines/abtech-eas1000.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/images/models/machines/c4500.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/images/models/machines/cmm.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/images/models/machines/d218.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
frontend/public/images/models/machines/eddy.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
frontend/public/images/models/machines/ezeddy.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/public/images/models/machines/furnace.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
frontend/public/images/models/machines/g750.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/models/machines/hbroach.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
frontend/public/images/models/machines/keyence-vr3100.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
frontend/public/images/models/machines/latitude5440.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/images/models/machines/leandrum.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/machines/loc650.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/images/models/machines/mx3100.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/images/models/machines/nt4300.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/machines/p600s.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
frontend/public/images/models/machines/phoenixbroach.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
frontend/public/images/models/machines/powerturn.png
Normal file
|
After Width: | Height: | Size: 510 KiB |
BIN
frontend/public/images/models/machines/precision5560.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/images/models/machines/rb2.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/images/models/machines/shotpeen.png
Normal file
|
After Width: | Height: | Size: 690 KiB |
BIN
frontend/public/images/models/machines/turnburn.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
frontend/public/images/models/machines/vp9000.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/images/models/machines/vt5502sp.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
frontend/public/images/models/machines/vtm100.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
frontend/public/images/models/machines/zoller600.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
frontend/public/images/models/printers/AltaLink-C8130.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
frontend/public/images/models/printers/AltaLink-C8130.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
frontend/public/images/models/printers/DTC4500e.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
frontend/public/images/models/printers/Epson-C3500.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/images/models/printers/HP-DesignJet-T1700dr.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/images/models/printers/LaserJet -CP2025.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
frontend/public/images/models/printers/LaserJet-4001n.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/images/models/printers/LaserJet-4250.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M254dw.jpg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M254dw.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M255dw.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M404.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M406.jpg
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M406.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M454dn.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M506.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M602.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
frontend/public/images/models/printers/LaserJet-M607.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/images/models/printers/LaserJet-P3015dn.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
frontend/public/images/models/printers/LaserJet-Pro-M252dw.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/printers/Laserjet-Pro-M251nw.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/images/models/printers/Versalink-B405.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/printers/Versalink-B405.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
frontend/public/images/models/printers/Versalink-B7125.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
frontend/public/images/models/printers/Versalink-C405.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
frontend/public/images/models/printers/Versalink-C7125.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/images/models/printers/Versalink-C7125.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
frontend/public/images/models/printers/Xerox-EC8036.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/images/models/printers/Xerox-EC8036.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
frontend/public/images/models/printers/zt411.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/models/printers/zt411.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
6
frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
392
frontend/src/api/index.js
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add auth token to requests
|
||||||
|
api.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle 401 errors (token expired) - only redirect if user was logged in
|
||||||
|
api.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
error => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const hadToken = localStorage.getItem('token')
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
// Only redirect if user was previously logged in (session expired)
|
||||||
|
if (hadToken) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authApi = {
|
||||||
|
login(username, password) {
|
||||||
|
return api.post('/auth/login', { username, password })
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
return api.post('/auth/logout')
|
||||||
|
},
|
||||||
|
me() {
|
||||||
|
return api.get('/auth/me')
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken')
|
||||||
|
return api.post('/auth/refresh', {}, {
|
||||||
|
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machines API
|
||||||
|
export const machinesApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/machines', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/machines/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/machines', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/machines/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/machines/${id}`)
|
||||||
|
},
|
||||||
|
updateCommunication(id, data) {
|
||||||
|
return api.put(`/machines/${id}/communication`, data)
|
||||||
|
},
|
||||||
|
// Relationships
|
||||||
|
getRelationships(id) {
|
||||||
|
return api.get(`/machines/${id}/relationships`)
|
||||||
|
},
|
||||||
|
createRelationship(id, data) {
|
||||||
|
return api.post(`/machines/${id}/relationships`, data)
|
||||||
|
},
|
||||||
|
deleteRelationship(relationshipId) {
|
||||||
|
return api.delete(`/machines/relationships/${relationshipId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship Types API
|
||||||
|
export const relationshipTypesApi = {
|
||||||
|
list() {
|
||||||
|
return api.get('/machines/relationshiptypes')
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/machines/relationshiptypes', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machine Types API
|
||||||
|
export const machinetypesApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/machinetypes', { params })
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/machinetypes', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/machinetypes/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/machinetypes/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statuses API
|
||||||
|
export const statusesApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/statuses', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/statuses/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/statuses', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/statuses/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/statuses/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendors API
|
||||||
|
export const vendorsApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/vendors', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/vendors/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/vendors', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/vendors/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/vendors/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locations API
|
||||||
|
export const locationsApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/locations', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/locations/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/locations', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/locations/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/locations/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printers API
|
||||||
|
export const printersApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/printers/', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/printers/${id}`)
|
||||||
|
},
|
||||||
|
updateExtension(id, data) {
|
||||||
|
return api.put(`/printers/${id}/printerdata`, data)
|
||||||
|
},
|
||||||
|
updateCommunication(id, data) {
|
||||||
|
return api.put(`/printers/${id}/communication`, data)
|
||||||
|
},
|
||||||
|
getSupplies(id) {
|
||||||
|
return api.get(`/printers/${id}/supplies`)
|
||||||
|
},
|
||||||
|
getDrivers(id) {
|
||||||
|
return api.get(`/printers/${id}/drivers`)
|
||||||
|
},
|
||||||
|
lowSupplies() {
|
||||||
|
return api.get('/printers/lowsupplies')
|
||||||
|
},
|
||||||
|
dashboardSummary() {
|
||||||
|
return api.get('/printers/dashboard/summary')
|
||||||
|
},
|
||||||
|
drivers: {
|
||||||
|
list() {
|
||||||
|
return api.get('/printers/drivers')
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/printers/drivers', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/printers/drivers/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/printers/drivers/${id}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supplyTypes: {
|
||||||
|
list() {
|
||||||
|
return api.get('/printers/supplytypes')
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/printers/supplytypes', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard API
|
||||||
|
export const dashboardApi = {
|
||||||
|
summary() {
|
||||||
|
return api.get('/dashboard/summary')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models API
|
||||||
|
export const modelsApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/models', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/models/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/models', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/models/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/models/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PC Types API
|
||||||
|
export const pctypesApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/pctypes', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/pctypes/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/pctypes', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/pctypes/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/pctypes/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operating Systems API
|
||||||
|
export const operatingsystemsApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/operatingsystems', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/operatingsystems/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/operatingsystems', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/operatingsystems/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/operatingsystems/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business Units API
|
||||||
|
export const businessunitsApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/businessunits', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/businessunits/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/businessunits', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/businessunits/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/businessunits/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applications API
|
||||||
|
export const applicationsApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/applications', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/applications/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/applications', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/applications/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/applications/${id}`)
|
||||||
|
},
|
||||||
|
// Versions
|
||||||
|
getVersions(appId) {
|
||||||
|
return api.get(`/applications/${appId}/versions`)
|
||||||
|
},
|
||||||
|
createVersion(appId, data) {
|
||||||
|
return api.post(`/applications/${appId}/versions`, data)
|
||||||
|
},
|
||||||
|
// Get PCs that have this app installed
|
||||||
|
getInstalledOn(appId) {
|
||||||
|
return api.get(`/applications/${appId}/installed`)
|
||||||
|
},
|
||||||
|
// Machine applications (installed apps)
|
||||||
|
getMachineApps(machineId) {
|
||||||
|
return api.get(`/applications/machines/${machineId}`)
|
||||||
|
},
|
||||||
|
installApp(machineId, data) {
|
||||||
|
return api.post(`/applications/machines/${machineId}`, data)
|
||||||
|
},
|
||||||
|
uninstallApp(machineId, appId) {
|
||||||
|
return api.delete(`/applications/machines/${machineId}/${appId}`)
|
||||||
|
},
|
||||||
|
updateInstalledApp(machineId, appId, data) {
|
||||||
|
return api.put(`/applications/machines/${machineId}/${appId}`, data)
|
||||||
|
},
|
||||||
|
// Support teams
|
||||||
|
getSupportTeams() {
|
||||||
|
return api.get('/applications/supportteams')
|
||||||
|
},
|
||||||
|
createSupportTeam(data) {
|
||||||
|
return api.post('/applications/supportteams', data)
|
||||||
|
},
|
||||||
|
// App owners
|
||||||
|
getAppOwners() {
|
||||||
|
return api.get('/applications/appowners')
|
||||||
|
},
|
||||||
|
createAppOwner(data) {
|
||||||
|
return api.post('/applications/appowners', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search API
|
||||||
|
export const searchApi = {
|
||||||
|
search(query) {
|
||||||
|
return api.get('/search', { params: { q: query } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knowledge Base API
|
||||||
|
export const knowledgebaseApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/knowledgebase', { params })
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return api.get(`/knowledgebase/${id}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/knowledgebase', data)
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return api.put(`/knowledgebase/${id}`, data)
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return api.delete(`/knowledgebase/${id}`)
|
||||||
|
},
|
||||||
|
trackClick(id) {
|
||||||
|
return api.post(`/knowledgebase/${id}/click`)
|
||||||
|
},
|
||||||
|
getStats() {
|
||||||
|
return api.get('/knowledgebase/stats')
|
||||||
|
}
|
||||||
|
}
|
||||||
1054
frontend/src/assets/style.css
Normal file
263
frontend/src/components/LocationMapTooltip.vue
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
|
||||||
|
<slot></slot>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible && hasPosition"
|
||||||
|
class="map-tooltip"
|
||||||
|
:style="tooltipStyle"
|
||||||
|
ref="tooltipRef"
|
||||||
|
@mouseenter="onTooltipEnter"
|
||||||
|
@mouseleave="onTooltipLeave"
|
||||||
|
@wheel.prevent="onWheel"
|
||||||
|
>
|
||||||
|
<div class="map-tooltip-content">
|
||||||
|
<div class="map-preview" ref="mapPreview">
|
||||||
|
<div
|
||||||
|
class="map-transform"
|
||||||
|
:style="transformStyle"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="blueprintUrl"
|
||||||
|
alt="Shop Floor Map"
|
||||||
|
class="map-image"
|
||||||
|
@load="onImageLoad"
|
||||||
|
/>
|
||||||
|
<!-- Marker dot -->
|
||||||
|
<div
|
||||||
|
class="marker-dot"
|
||||||
|
:style="markerStyle"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="map-tooltip-footer">
|
||||||
|
<span class="coordinates">{{ left }}, {{ top }}</span>
|
||||||
|
<span class="zoom-hint">Scroll to zoom</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
left: { type: Number, default: null },
|
||||||
|
top: { type: Number, default: null },
|
||||||
|
machineName: { type: String, default: '' },
|
||||||
|
theme: { type: String, default: 'dark' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const tooltipRef = ref(null)
|
||||||
|
const mapPreview = ref(null)
|
||||||
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const isOverTooltip = ref(false)
|
||||||
|
const zoom = ref(1)
|
||||||
|
const imageLoaded = ref(false)
|
||||||
|
|
||||||
|
// Map dimensions
|
||||||
|
const MAP_WIDTH = 3300
|
||||||
|
const MAP_HEIGHT = 2550
|
||||||
|
|
||||||
|
const hasPosition = computed(() => {
|
||||||
|
return props.left !== null && props.top !== null
|
||||||
|
})
|
||||||
|
|
||||||
|
const blueprintUrl = computed(() => {
|
||||||
|
return props.theme === 'light'
|
||||||
|
? '/static/images/sitemap2025-light.png'
|
||||||
|
: '/static/images/sitemap2025-dark.png'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate marker position as percentage
|
||||||
|
const markerX = computed(() => {
|
||||||
|
return (props.left / MAP_WIDTH) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const markerY = computed(() => {
|
||||||
|
return (props.top / MAP_HEIGHT) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
// Marker style with counter-scale to maintain constant size
|
||||||
|
const markerStyle = computed(() => ({
|
||||||
|
left: markerX.value + '%',
|
||||||
|
top: markerY.value + '%',
|
||||||
|
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Transform style that centers on the marker and zooms toward it
|
||||||
|
const transformStyle = computed(() => {
|
||||||
|
// Calculate translation to center the marker in the preview
|
||||||
|
const translateX = 50 - markerX.value
|
||||||
|
const translateY = 50 - markerY.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
|
||||||
|
transformOrigin: `${markerX.value}% ${markerY.value}%`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipStyle = computed(() => ({
|
||||||
|
left: `${tooltipPosition.value.x}px`,
|
||||||
|
top: `${tooltipPosition.value.y}px`
|
||||||
|
}))
|
||||||
|
|
||||||
|
function onImageLoad() {
|
||||||
|
imageLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTooltip(event) {
|
||||||
|
if (!hasPosition.value) return
|
||||||
|
|
||||||
|
visible.value = true
|
||||||
|
zoom.value = 1
|
||||||
|
|
||||||
|
const rect = event.target.getBoundingClientRect()
|
||||||
|
tooltipPosition.value = {
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.bottom + 10
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
adjustPosition()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWrapperLeave() {
|
||||||
|
// Small delay to allow moving to tooltip
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isOverTooltip.value) {
|
||||||
|
hideTooltip()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTooltipEnter() {
|
||||||
|
isOverTooltip.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTooltipLeave() {
|
||||||
|
isOverTooltip.value = false
|
||||||
|
hideTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTooltip() {
|
||||||
|
visible.value = false
|
||||||
|
zoom.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event) {
|
||||||
|
const delta = event.deltaY > 0 ? -0.3 : 0.3
|
||||||
|
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
|
||||||
|
zoom.value = newZoom
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustPosition() {
|
||||||
|
if (!tooltipRef.value) return
|
||||||
|
|
||||||
|
const tooltip = tooltipRef.value
|
||||||
|
const rect = tooltip.getBoundingClientRect()
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
|
if (rect.right > viewportWidth - 20) {
|
||||||
|
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
|
||||||
|
}
|
||||||
|
if (rect.left < 20) {
|
||||||
|
tooltipPosition.value.x += (20 - rect.left)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.bottom > viewportHeight - 20) {
|
||||||
|
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset zoom when tooltip becomes visible
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
zoom.value = 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.location-tooltip-wrapper {
|
||||||
|
display: inline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-tooltip-wrapper:hover {
|
||||||
|
color: var(--primary, #1976d2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.map-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 500px;
|
||||||
|
height: 385px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-transform {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #ff0000;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip-footer {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
144
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="modelValue" class="modal-overlay" @click.self="closeOnOverlay && close()">
|
||||||
|
<div class="modal-container" :class="sizeClass">
|
||||||
|
<div class="modal-header" v-if="title || $slots.header">
|
||||||
|
<slot name="header">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
</slot>
|
||||||
|
<button class="modal-close" @click="close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer" v-if="$slots.footer">
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
size: { type: String, default: 'medium' }, // small, medium, large, fullscreen
|
||||||
|
closeOnOverlay: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'close'])
|
||||||
|
|
||||||
|
const sizeClass = computed(() => `modal-${props.size}`)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
watch(() => props.modelValue, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleEscape(e) {
|
||||||
|
if (e.key === 'Escape') close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-small {
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-medium {
|
||||||
|
width: 600px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
width: 900px;
|
||||||
|
max-width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-fullscreen {
|
||||||
|
width: 95vw;
|
||||||
|
height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
589
frontend/src/components/ShopFloorMap.vue
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shopfloor-map">
|
||||||
|
<div class="map-controls" v-if="!pickerMode">
|
||||||
|
<div class="filters">
|
||||||
|
<select v-model="filters.machinetype" @change="applyFilters">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option v-for="t in machinetypes" :key="t.machinetypeid" :value="t.machinetypeid">
|
||||||
|
{{ t.machinetype }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select v-model="filters.businessunit" @change="applyFilters">
|
||||||
|
<option value="">All Business Units</option>
|
||||||
|
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||||
|
{{ bu.businessunit }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select v-model="filters.status" @change="applyFilters">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
|
||||||
|
{{ s.status }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="filters.search"
|
||||||
|
placeholder="Search..."
|
||||||
|
@input="debounceSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<span
|
||||||
|
v-for="t in visibleTypes"
|
||||||
|
:key="t.machinetypeid"
|
||||||
|
class="legend-item"
|
||||||
|
>
|
||||||
|
<span class="legend-dot" :style="{ background: getTypeColor(t.machinetype) }"></span>
|
||||||
|
{{ t.machinetype }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="picker-controls" v-if="pickerMode">
|
||||||
|
<span class="picker-message">Click on the map to set location</span>
|
||||||
|
<span v-if="pickedPosition" class="picker-coords">
|
||||||
|
Position: {{ pickedPosition.left }}, {{ pickedPosition.top }}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="clearPosition">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="mapContainer" class="map-container" :class="{ 'picker-active': pickerMode }"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
machines: { type: Array, default: () => [] },
|
||||||
|
machinetypes: { type: Array, default: () => [] },
|
||||||
|
businessunits: { type: Array, default: () => [] },
|
||||||
|
statuses: { type: Array, default: () => [] },
|
||||||
|
theme: { type: String, default: 'dark' },
|
||||||
|
pickerMode: { type: Boolean, default: false },
|
||||||
|
initialPosition: { type: Object, default: null } // { left, top }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['markerClick', 'positionPicked'])
|
||||||
|
|
||||||
|
const mapContainer = ref(null)
|
||||||
|
let map = null
|
||||||
|
let imageOverlay = null
|
||||||
|
const markers = ref([])
|
||||||
|
const pickedPosition = ref(null)
|
||||||
|
let pickerMarker = null
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
machinetype: '',
|
||||||
|
businessunit: '',
|
||||||
|
status: '',
|
||||||
|
search: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map dimensions (matching old system)
|
||||||
|
const MAP_WIDTH = 3300
|
||||||
|
const MAP_HEIGHT = 2550
|
||||||
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
|
|
||||||
|
// Type colors - distinct colors for each machine type
|
||||||
|
const typeColors = {
|
||||||
|
// Machining
|
||||||
|
'Mill': '#F44336', // Red
|
||||||
|
'Lathe': '#E91E63', // Pink
|
||||||
|
'Grinder': '#2196F3', // Blue
|
||||||
|
'Broach': '#00BCD4', // Cyan
|
||||||
|
'Hobbing': '#009688', // Teal
|
||||||
|
'Turn': '#FF5722', // Deep Orange (Mill Turn, Vertical Turn)
|
||||||
|
|
||||||
|
// Inspection & Measurement
|
||||||
|
'CMM': '#9C27B0', // Purple
|
||||||
|
'Measuring': '#7B1FA2', // Dark Purple
|
||||||
|
'Eddy': '#673AB7', // Deep Purple
|
||||||
|
'Inspection': '#8BC34A', // Light Green
|
||||||
|
|
||||||
|
// Heat Treatment & Processing
|
||||||
|
'Furnace': '#FF9800', // Orange
|
||||||
|
'Wash': '#4CAF50', // Green
|
||||||
|
'Wax': '#FFEB3B', // Yellow
|
||||||
|
|
||||||
|
// Automation
|
||||||
|
'Robot': '#3F51B5', // Indigo
|
||||||
|
'Deburr': '#5C6BC0', // Indigo Light
|
||||||
|
|
||||||
|
// Welding
|
||||||
|
'Welder': '#795548', // Brown
|
||||||
|
|
||||||
|
// IT/Network
|
||||||
|
'PC': '#607D8B', // Blue Grey
|
||||||
|
'Printer': '#78909C', // Blue Grey Light
|
||||||
|
'Switch': '#546E7A', // Blue Grey Dark
|
||||||
|
'Access Point': '#455A64', // Blue Grey Darker
|
||||||
|
'IDF': '#37474F', // Blue Grey Very Dark
|
||||||
|
|
||||||
|
// Other
|
||||||
|
'Saw': '#8D6E63', // Brown Light
|
||||||
|
'Press': '#A1887F', // Brown Lighter
|
||||||
|
'EDM': '#00ACC1', // Cyan Dark
|
||||||
|
'Drill': '#26A69A', // Teal Light
|
||||||
|
'CNC': '#66BB6A', // Green Light
|
||||||
|
'Assembly': '#CDDC39', // Lime
|
||||||
|
'Other': '#BDBDBD' // Grey
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeColor(typeName) {
|
||||||
|
if (!typeName) return '#BDBDBD'
|
||||||
|
for (const [key, color] of Object.entries(typeColors)) {
|
||||||
|
if (typeName.toLowerCase().includes(key.toLowerCase())) {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '#BDBDBD' // Grey for unknown types
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique visible types from machines
|
||||||
|
const visibleTypes = computed(() => {
|
||||||
|
const typeIds = new Set(props.machines.map(m => m.machinetypeid))
|
||||||
|
return props.machinetypes.filter(t => typeIds.has(t.machinetypeid))
|
||||||
|
})
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
if (!mapContainer.value) return
|
||||||
|
|
||||||
|
map = L.map(mapContainer.value, {
|
||||||
|
crs: L.CRS.Simple,
|
||||||
|
minZoom: -4,
|
||||||
|
maxZoom: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
const blueprintUrl = props.theme === 'light'
|
||||||
|
? '/static/images/sitemap2025-light.png'
|
||||||
|
: '/static/images/sitemap2025-dark.png'
|
||||||
|
|
||||||
|
imageOverlay = L.imageOverlay(blueprintUrl, bounds)
|
||||||
|
imageOverlay.addTo(map)
|
||||||
|
|
||||||
|
// Set initial view
|
||||||
|
const initialZoom = -1
|
||||||
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
||||||
|
map.setMaxBounds(bounds)
|
||||||
|
|
||||||
|
// Picker mode: click to set position
|
||||||
|
if (props.pickerMode) {
|
||||||
|
map.on('click', handleMapClick)
|
||||||
|
|
||||||
|
// Show initial position if provided
|
||||||
|
if (props.initialPosition) {
|
||||||
|
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
renderMarkers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapClick(e) {
|
||||||
|
if (!props.pickerMode) return
|
||||||
|
|
||||||
|
const leafletY = e.latlng.lat
|
||||||
|
const leafletX = e.latlng.lng
|
||||||
|
|
||||||
|
// Convert back to database coordinates
|
||||||
|
const dbLeft = Math.round(leafletX)
|
||||||
|
const dbTop = Math.round(MAP_HEIGHT - leafletY)
|
||||||
|
|
||||||
|
setPickerPosition(dbLeft, dbTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerPosition(left, top) {
|
||||||
|
// Remove old picker marker
|
||||||
|
if (pickerMarker) {
|
||||||
|
pickerMarker.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
pickedPosition.value = { left, top }
|
||||||
|
|
||||||
|
// Convert to Leaflet coordinates
|
||||||
|
const leafletY = MAP_HEIGHT - top
|
||||||
|
const leafletX = left
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: `<div class="picker-marker-dot"></div>`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8],
|
||||||
|
className: 'picker-marker'
|
||||||
|
})
|
||||||
|
|
||||||
|
pickerMarker = L.marker([leafletY, leafletX], { icon, draggable: true })
|
||||||
|
pickerMarker.addTo(map)
|
||||||
|
|
||||||
|
// Allow dragging to fine-tune position
|
||||||
|
pickerMarker.on('dragend', () => {
|
||||||
|
const pos = pickerMarker.getLatLng()
|
||||||
|
const newLeft = Math.round(pos.lng)
|
||||||
|
const newTop = Math.round(MAP_HEIGHT - pos.lat)
|
||||||
|
pickedPosition.value = { left: newLeft, top: newTop }
|
||||||
|
emit('positionPicked', pickedPosition.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('positionPicked', pickedPosition.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPosition() {
|
||||||
|
if (pickerMarker) {
|
||||||
|
pickerMarker.remove()
|
||||||
|
pickerMarker = null
|
||||||
|
}
|
||||||
|
pickedPosition.value = null
|
||||||
|
emit('positionPicked', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the detail page route based on machine category
|
||||||
|
// Extensible for future addon types (network, cameras, etc.)
|
||||||
|
function getDetailRoute(machine) {
|
||||||
|
const category = machine.category?.toLowerCase() || ''
|
||||||
|
const routeMap = {
|
||||||
|
'equipment': '/machines',
|
||||||
|
'pc': '/pcs',
|
||||||
|
'printer': '/printers',
|
||||||
|
// Future addon routes can be added here:
|
||||||
|
// 'network': '/network',
|
||||||
|
// 'camera': '/cameras',
|
||||||
|
}
|
||||||
|
const basePath = routeMap[category] || '/machines'
|
||||||
|
return `${basePath}/${machine.machineid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkers() {
|
||||||
|
// Clear existing markers
|
||||||
|
markers.value.forEach(m => m.marker.remove())
|
||||||
|
markers.value = []
|
||||||
|
|
||||||
|
props.machines.forEach(machine => {
|
||||||
|
if (machine.mapleft == null || machine.maptop == null) return
|
||||||
|
|
||||||
|
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
||||||
|
const leafletY = MAP_HEIGHT - machine.maptop
|
||||||
|
const leafletX = machine.mapleft
|
||||||
|
|
||||||
|
const typeName = machine.machinetype || ''
|
||||||
|
const color = getTypeColor(typeName)
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12],
|
||||||
|
popupAnchor: [0, -12],
|
||||||
|
className: 'machine-marker'
|
||||||
|
})
|
||||||
|
|
||||||
|
const marker = L.marker([leafletY, leafletX], { icon })
|
||||||
|
|
||||||
|
// Display name for tooltips and popups
|
||||||
|
const displayName = machine.alias || machine.machinenumber || 'Unknown'
|
||||||
|
const detailRoute = getDetailRoute(machine)
|
||||||
|
const category = machine.category?.toLowerCase() || ''
|
||||||
|
|
||||||
|
// Build tooltip content based on category
|
||||||
|
let tooltipLines = [`<strong>${displayName}</strong>`]
|
||||||
|
|
||||||
|
// Don't show "LocationOnly" as machine type
|
||||||
|
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
||||||
|
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add vendor and model if available
|
||||||
|
if (machine.vendor) {
|
||||||
|
tooltipLines.push(`<span style="color: #aaa;">${machine.vendor}${machine.model ? ' ' + machine.model : ''}</span>`)
|
||||||
|
} else if (machine.model) {
|
||||||
|
tooltipLines.push(`<span style="color: #aaa;">${machine.model}</span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category-specific info
|
||||||
|
if (category === 'printer') {
|
||||||
|
// Printers: show IP and hostname
|
||||||
|
if (machine.ipaddress) {
|
||||||
|
tooltipLines.push(`<span style="color: #8cf;">IP: ${machine.ipaddress}</span>`)
|
||||||
|
}
|
||||||
|
if (machine.hostname) {
|
||||||
|
tooltipLines.push(`<span style="color: #8cf;">${machine.hostname}</span>`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Equipment/PC: show connected PC if available
|
||||||
|
if (machine.connected_pc) {
|
||||||
|
tooltipLines.push(`<span style="color: #fc8;">PC: ${machine.connected_pc}</span>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business unit
|
||||||
|
if (machine.businessunit) {
|
||||||
|
tooltipLines.push(`<span style="color: #ccc;">${machine.businessunit}</span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipContent = tooltipLines.join('<br>')
|
||||||
|
marker.bindTooltip(tooltipContent, {
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -12],
|
||||||
|
className: 'marker-tooltip'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click popup (detailed info)
|
||||||
|
const popupContent = `
|
||||||
|
<div class="marker-popup">
|
||||||
|
<strong>${displayName}</strong>
|
||||||
|
<div class="popup-details">
|
||||||
|
<div><span class="label">Number:</span> ${machine.machinenumber || '-'}</div>
|
||||||
|
<div><span class="label">Type:</span> ${typeName || '-'}</div>
|
||||||
|
<div><span class="label">Category:</span> ${machine.category || '-'}</div>
|
||||||
|
<div><span class="label">Status:</span> ${machine.status || '-'}</div>
|
||||||
|
<div><span class="label">Vendor:</span> ${machine.vendor || '-'}</div>
|
||||||
|
<div><span class="label">Model:</span> ${machine.model || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
marker.bindPopup(popupContent)
|
||||||
|
marker.on('click', () => emit('markerClick', machine))
|
||||||
|
|
||||||
|
marker.addTo(map)
|
||||||
|
|
||||||
|
markers.value.push({
|
||||||
|
marker,
|
||||||
|
machine,
|
||||||
|
searchData: `${machine.machinenumber} ${machine.alias} ${typeName} ${machine.vendor} ${machine.model} ${machine.serialnumber} ${machine.businessunit}`.toLowerCase()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const searchTerm = filters.value.search.toLowerCase()
|
||||||
|
|
||||||
|
markers.value.forEach(({ marker, machine, searchData }) => {
|
||||||
|
let visible = true
|
||||||
|
|
||||||
|
if (filters.value.machinetype && machine.machinetypeid !== filters.value.machinetype) {
|
||||||
|
visible = false
|
||||||
|
}
|
||||||
|
if (filters.value.businessunit && machine.businessunitid !== filters.value.businessunit) {
|
||||||
|
visible = false
|
||||||
|
}
|
||||||
|
if (filters.value.status && machine.statusid !== filters.value.status) {
|
||||||
|
visible = false
|
||||||
|
}
|
||||||
|
if (searchTerm && !searchData.includes(searchTerm)) {
|
||||||
|
visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
marker.setOpacity(visible ? 1 : 0.15)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchTimeout = null
|
||||||
|
function debounceSearch() {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
searchTimeout = setTimeout(applyFilters, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.machines, () => {
|
||||||
|
if (map) renderMarkers()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(() => props.theme, (newTheme) => {
|
||||||
|
if (imageOverlay && map) {
|
||||||
|
const blueprintUrl = newTheme === 'light'
|
||||||
|
? '/static/images/sitemap2025-light.png'
|
||||||
|
: '/static/images/sitemap2025-dark.png'
|
||||||
|
imageOverlay.setUrl(blueprintUrl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initMap()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shopfloor-map {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select,
|
||||||
|
.filters input {
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters input {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters input::placeholder {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg-card);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 600px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container.picker-active {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--warning);
|
||||||
|
border-bottom: 1px solid var(--warning);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-message {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-coords {
|
||||||
|
font-family: monospace;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.marker-popup) {
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.marker-popup strong) {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.popup-details) {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.popup-details .label) {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.popup-link) {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #1976d2;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.popup-link:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.machine-marker) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.machine-marker-dot) {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.picker-marker-dot) {
|
||||||
|
background: #ff0000;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.marker-tooltip) {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.marker-tooltip::before) {
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.92);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
frontend/src/main.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
249
frontend/src/router/index.js
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
// Views
|
||||||
|
import Login from '../views/Login.vue'
|
||||||
|
import AppLayout from '../views/AppLayout.vue'
|
||||||
|
import Dashboard from '../views/Dashboard.vue'
|
||||||
|
import MachinesList from '../views/machines/MachinesList.vue'
|
||||||
|
import MachineDetail from '../views/machines/MachineDetail.vue'
|
||||||
|
import MachineForm from '../views/machines/MachineForm.vue'
|
||||||
|
import PrintersList from '../views/printers/PrintersList.vue'
|
||||||
|
import PrinterDetail from '../views/printers/PrinterDetail.vue'
|
||||||
|
import PrinterForm from '../views/printers/PrinterForm.vue'
|
||||||
|
import PCsList from '../views/pcs/PCsList.vue'
|
||||||
|
import PCDetail from '../views/pcs/PCDetail.vue'
|
||||||
|
import PCForm from '../views/pcs/PCForm.vue'
|
||||||
|
import VendorsList from '../views/vendors/VendorsList.vue'
|
||||||
|
import ApplicationsList from '../views/applications/ApplicationsList.vue'
|
||||||
|
import ApplicationDetail from '../views/applications/ApplicationDetail.vue'
|
||||||
|
import ApplicationForm from '../views/applications/ApplicationForm.vue'
|
||||||
|
import KnowledgeBaseList from '../views/knowledgebase/KnowledgeBaseList.vue'
|
||||||
|
import KnowledgeBaseDetail from '../views/knowledgebase/KnowledgeBaseDetail.vue'
|
||||||
|
import KnowledgeBaseForm from '../views/knowledgebase/KnowledgeBaseForm.vue'
|
||||||
|
import SearchResults from '../views/SearchResults.vue'
|
||||||
|
import SettingsIndex from '../views/settings/SettingsIndex.vue'
|
||||||
|
import MachineTypesList from '../views/settings/MachineTypesList.vue'
|
||||||
|
import LocationsList from '../views/settings/LocationsList.vue'
|
||||||
|
import StatusesList from '../views/settings/StatusesList.vue'
|
||||||
|
import ModelsList from '../views/settings/ModelsList.vue'
|
||||||
|
import PCTypesList from '../views/settings/PCTypesList.vue'
|
||||||
|
import OperatingSystemsList from '../views/settings/OperatingSystemsList.vue'
|
||||||
|
import BusinessUnitsList from '../views/settings/BusinessUnitsList.vue'
|
||||||
|
import MapView from '../views/MapView.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: Login,
|
||||||
|
meta: { guest: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AppLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: Dashboard
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
name: 'search',
|
||||||
|
component: SearchResults
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'machines',
|
||||||
|
name: 'machines',
|
||||||
|
component: MachinesList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'machines/new',
|
||||||
|
name: 'machine-new',
|
||||||
|
component: MachineForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'machines/:id',
|
||||||
|
name: 'machine-detail',
|
||||||
|
component: MachineDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'machines/:id/edit',
|
||||||
|
name: 'machine-edit',
|
||||||
|
component: MachineForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'printers',
|
||||||
|
name: 'printers',
|
||||||
|
component: PrintersList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'printers/new',
|
||||||
|
name: 'printer-new',
|
||||||
|
component: PrinterForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'printers/:id',
|
||||||
|
name: 'printer-detail',
|
||||||
|
component: PrinterDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'printers/:id/edit',
|
||||||
|
name: 'printer-edit',
|
||||||
|
component: PrinterForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pcs',
|
||||||
|
name: 'pcs',
|
||||||
|
component: PCsList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pcs/new',
|
||||||
|
name: 'pc-new',
|
||||||
|
component: PCForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pcs/:id',
|
||||||
|
name: 'pc-detail',
|
||||||
|
component: PCDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pcs/:id/edit',
|
||||||
|
name: 'pc-edit',
|
||||||
|
component: PCForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'map',
|
||||||
|
name: 'map',
|
||||||
|
component: MapView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'applications',
|
||||||
|
name: 'applications',
|
||||||
|
component: ApplicationsList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'applications/new',
|
||||||
|
name: 'application-new',
|
||||||
|
component: ApplicationForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'applications/:id',
|
||||||
|
name: 'application-detail',
|
||||||
|
component: ApplicationDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'applications/:id/edit',
|
||||||
|
name: 'application-edit',
|
||||||
|
component: ApplicationForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'knowledgebase',
|
||||||
|
name: 'knowledgebase',
|
||||||
|
component: KnowledgeBaseList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'knowledgebase/new',
|
||||||
|
name: 'knowledgebase-new',
|
||||||
|
component: KnowledgeBaseForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'knowledgebase/:id',
|
||||||
|
name: 'knowledgebase-detail',
|
||||||
|
component: KnowledgeBaseDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'knowledgebase/:id/edit',
|
||||||
|
name: 'knowledgebase-edit',
|
||||||
|
component: KnowledgeBaseForm,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: SettingsIndex,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/vendors',
|
||||||
|
name: 'vendors',
|
||||||
|
component: VendorsList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/machinetypes',
|
||||||
|
name: 'machinetypes',
|
||||||
|
component: MachineTypesList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/locations',
|
||||||
|
name: 'locations',
|
||||||
|
component: LocationsList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/statuses',
|
||||||
|
name: 'statuses',
|
||||||
|
component: StatusesList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/models',
|
||||||
|
name: 'models',
|
||||||
|
component: ModelsList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/pctypes',
|
||||||
|
name: 'pctypes',
|
||||||
|
component: PCTypesList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/operatingsystems',
|
||||||
|
name: 'operatingsystems',
|
||||||
|
component: OperatingSystemsList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/businessunits',
|
||||||
|
name: 'businessunits',
|
||||||
|
component: BusinessUnitsList,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation guard
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
|
next('/')
|
||||||
|
} else if (to.meta.guest && authStore.isAuthenticated) {
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
63
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { authApi } from '../api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: () => ({
|
||||||
|
user: JSON.parse(localStorage.getItem('user') || 'null'),
|
||||||
|
token: localStorage.getItem('token') || null
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isAuthenticated: (state) => !!state.token,
|
||||||
|
username: (state) => state.user?.username || '',
|
||||||
|
roles: (state) => state.user?.roles || [],
|
||||||
|
hasRole: (state) => (role) => state.user?.roles?.includes(role) || false,
|
||||||
|
isAdmin: (state) => state.user?.roles?.includes('admin') || false
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async login(username, password) {
|
||||||
|
try {
|
||||||
|
const response = await authApi.login(username, password)
|
||||||
|
const { access_token, refresh_token, user } = response.data.data
|
||||||
|
|
||||||
|
this.token = access_token
|
||||||
|
this.user = user
|
||||||
|
|
||||||
|
localStorage.setItem('token', access_token)
|
||||||
|
localStorage.setItem('refreshToken', refresh_token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(user))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.message || 'Login failed'
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await authApi.logout()
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore logout errors
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = null
|
||||||
|
this.user = null
|
||||||
|
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('refreshToken')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchUser() {
|
||||||
|
try {
|
||||||
|
const response = await authApi.me()
|
||||||
|
this.user = response.data.data
|
||||||
|
localStorage.setItem('user', JSON.stringify(this.user))
|
||||||
|
} catch (error) {
|
||||||
|
this.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
65
frontend/src/views/AppLayout.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="sidebar-logo" />
|
||||||
|
<h1>West Jefferson</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-search">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
@keyup.enter="performSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<router-link to="/">Dashboard</router-link>
|
||||||
|
<router-link to="/map">Map</router-link>
|
||||||
|
<router-link to="/machines">Equipment</router-link>
|
||||||
|
<router-link to="/pcs">PCs</router-link>
|
||||||
|
<router-link to="/printers">Printers</router-link>
|
||||||
|
<router-link to="/applications">Applications</router-link>
|
||||||
|
<router-link to="/knowledgebase">Knowledge Base</router-link>
|
||||||
|
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="user-menu">
|
||||||
|
<template v-if="authStore.isAuthenticated">
|
||||||
|
<div class="username">{{ authStore.username }}</div>
|
||||||
|
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
|
||||||
|
</template>
|
||||||
|
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
function performSearch() {
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
router.push({ path: '/search', query: { q: searchQuery.value.trim() } })
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
133
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Main Stats -->
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Total Machines</div>
|
||||||
|
<div class="value">{{ stats.totalmachines || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card success">
|
||||||
|
<div class="label">Active</div>
|
||||||
|
<div class="value">{{ stats.activemachines || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warning">
|
||||||
|
<div class="label">In Repair</div>
|
||||||
|
<div class="value">{{ stats.inrepair || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">PCs</div>
|
||||||
|
<div class="value">{{ stats.totalpc || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Printer Stats -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Printers</h3>
|
||||||
|
<router-link to="/printers" class="btn btn-secondary btn-sm">View All</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Total Printers</div>
|
||||||
|
<div class="value">{{ printerStats.totalprinters || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card success">
|
||||||
|
<div class="label">Online</div>
|
||||||
|
<div class="value">{{ printerStats.online || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warning">
|
||||||
|
<div class="label">Low Supplies</div>
|
||||||
|
<div class="value">{{ printerStats.lowsupplies || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card danger">
|
||||||
|
<div class="label">Critical</div>
|
||||||
|
<div class="value">{{ printerStats.criticalsupplies || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Machines -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Recent Machines</h3>
|
||||||
|
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Machine #</th>
|
||||||
|
<th>Alias</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="machine in recentMachines" :key="machine.machineid">
|
||||||
|
<td>{{ machine.machinenumber }}</td>
|
||||||
|
<td>{{ machine.alias || '-' }}</td>
|
||||||
|
<td>{{ machine.machinetype }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="getStatusClass(machine.status)">
|
||||||
|
{{ machine.status || 'Unknown' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="recentMachines.length === 0">
|
||||||
|
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||||
|
No machines found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { dashboardApi, machinesApi, printersApi } from '../api'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const stats = ref({})
|
||||||
|
const printerStats = ref({})
|
||||||
|
const recentMachines = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [dashRes, machinesRes, printersRes] = await Promise.all([
|
||||||
|
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
|
||||||
|
machinesApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
|
||||||
|
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
|
||||||
|
])
|
||||||
|
|
||||||
|
stats.value = dashRes.data.data || {}
|
||||||
|
recentMachines.value = machinesRes.data.data || []
|
||||||
|
printerStats.value = printersRes.data.data || {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard load error:', 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') return 'badge-warning'
|
||||||
|
if (s === 'retired') return 'badge-danger'
|
||||||
|
return 'badge-info'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
69
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>ShopDB</h1>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? 'Logging in...' : 'Login' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
const result = await authStore.login(username.value, password.value)
|
||||||
|
|
||||||
|
loading.value = false
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error.value = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
79
frontend/src/views/MapView.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="map-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Shop Floor Map</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
|
<ShopFloorMap
|
||||||
|
v-else
|
||||||
|
:machines="machines"
|
||||||
|
:machinetypes="machinetypes"
|
||||||
|
:businessunits="businessunits"
|
||||||
|
:statuses="statuses"
|
||||||
|
@markerClick="handleMarkerClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||||
|
import { machinesApi, machinetypesApi, businessunitsApi, statusesApi } from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
const machines = ref([])
|
||||||
|
const machinetypes = ref([])
|
||||||
|
const businessunits = ref([])
|
||||||
|
const statuses = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [machinesRes, typesRes, busRes, statusRes] = await Promise.all([
|
||||||
|
machinesApi.list({ hasmap: true, all: true }),
|
||||||
|
machinetypesApi.list(),
|
||||||
|
businessunitsApi.list(),
|
||||||
|
statusesApi.list()
|
||||||
|
])
|
||||||
|
|
||||||
|
machines.value = machinesRes.data.data || []
|
||||||
|
machinetypes.value = typesRes.data.data || []
|
||||||
|
businessunits.value = busRes.data.data || []
|
||||||
|
statuses.value = statusRes.data.data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load map data:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleMarkerClick(machine) {
|
||||||
|
const category = machine.category?.toLowerCase() || ''
|
||||||
|
const routeMap = {
|
||||||
|
'equipment': '/machines',
|
||||||
|
'pc': '/pcs',
|
||||||
|
'printer': '/printers'
|
||||||
|
}
|
||||||
|
const basePath = routeMap[category] || '/machines'
|
||||||
|
router.push(`${basePath}/${machine.machineid}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-page .page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-page :deep(.shopfloor-map) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
267
frontend/src/views/SearchResults.vue
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Search Results</h2>
|
||||||
|
<span v-if="results.length" class="results-count">
|
||||||
|
{{ results.length }} result{{ results.length !== 1 ? 's' : '' }} for "{{ query }}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
v-model="searchInput"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Search machines, applications, knowledge base..."
|
||||||
|
@keyup.enter="performSearch"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" @click="performSearch">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div v-if="loading" class="loading">Searching...</div>
|
||||||
|
|
||||||
|
<template v-else-if="query">
|
||||||
|
<div v-if="results.length === 0" class="no-results">
|
||||||
|
No results found for "{{ query }}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="results-list">
|
||||||
|
<div
|
||||||
|
v-for="result in results"
|
||||||
|
:key="`${result.type}-${result.id}`"
|
||||||
|
class="result-item"
|
||||||
|
>
|
||||||
|
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
|
||||||
|
<div class="result-content">
|
||||||
|
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
|
||||||
|
{{ result.title }}
|
||||||
|
</router-link>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
href="#"
|
||||||
|
class="result-title"
|
||||||
|
@click.prevent="openKBArticle(result)"
|
||||||
|
>
|
||||||
|
{{ result.title }}
|
||||||
|
</a>
|
||||||
|
<div class="result-meta">
|
||||||
|
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
|
||||||
|
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="no-results">
|
||||||
|
Enter a search term to find machines, applications, printers, and knowledge base articles.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { searchApi, knowledgebaseApi } from '../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const results = ref([])
|
||||||
|
const query = ref('')
|
||||||
|
const searchInput = ref('')
|
||||||
|
|
||||||
|
const typeLabels = {
|
||||||
|
machine: 'Equipment',
|
||||||
|
pc: 'PC',
|
||||||
|
application: 'App',
|
||||||
|
knowledgebase: 'KB',
|
||||||
|
printer: 'Printer'
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type) {
|
||||||
|
return typeLabels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(q) {
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
results.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await searchApi.search(q)
|
||||||
|
results.value = response.data.data?.results || []
|
||||||
|
query.value = q
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error)
|
||||||
|
results.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performSearch() {
|
||||||
|
if (searchInput.value.trim()) {
|
||||||
|
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openKBArticle(result) {
|
||||||
|
// Track the click before opening
|
||||||
|
try {
|
||||||
|
await knowledgebaseApi.trackClick(result.id)
|
||||||
|
if (result.linkurl) {
|
||||||
|
window.open(result.linkurl, '_blank')
|
||||||
|
} else {
|
||||||
|
router.push(result.url)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking click:', error)
|
||||||
|
if (result.linkurl) {
|
||||||
|
window.open(result.linkurl, '_blank')
|
||||||
|
} else {
|
||||||
|
router.push(result.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const q = route.query.q
|
||||||
|
if (q) {
|
||||||
|
searchInput.value = q
|
||||||
|
search(q)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.q, (newQ) => {
|
||||||
|
if (newQ) {
|
||||||
|
searchInput.value = newQ
|
||||||
|
search(newQ)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-count {
|
||||||
|
color: var(--text-light, #666);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-light, #666);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 70px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.machine {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.pc {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.application {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.knowledgebase {
|
||||||
|
background: #f3e5f5;
|
||||||
|
color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type.printer {
|
||||||
|
background: #fce4ec;
|
||||||
|
color: #c2185b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
color: var(--primary, #1976d2);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-light, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-subtitle {
|
||||||
|
color: var(--text-light, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-location {
|
||||||
|
color: var(--text-light, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-location::before {
|
||||||
|
content: '\1F4CD ';
|
||||||
|
}
|
||||||
|
</style>
|
||||||
338
frontend/src/views/applications/ApplicationDetail.vue
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Application Details</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||||
|
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
|
<template v-else-if="app">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="hero-card">
|
||||||
|
<div class="hero-image" v-if="app.image">
|
||||||
|
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
|
||||||
|
</div>
|
||||||
|
<div class="hero-image placeholder" v-else>
|
||||||
|
<span class="placeholder-icon">📦</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-title">
|
||||||
|
<h1>{{ app.appname }}</h1>
|
||||||
|
</div>
|
||||||
|
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||||
|
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||||
|
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
|
||||||
|
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
|
||||||
|
<a v-if="app.applicationlink" :href="app.applicationlink" target="_blank" class="hero-link">
|
||||||
|
<span class="link-icon">🔗</span> Launch Application
|
||||||
|
</a>
|
||||||
|
<a v-if="app.installpath" :href="app.installpath" target="_blank" class="hero-link">
|
||||||
|
<span class="link-icon">⬇</span> Download Files
|
||||||
|
</a>
|
||||||
|
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" class="hero-link">
|
||||||
|
<span class="link-icon">📄</span> Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="content-grid">
|
||||||
|
<!-- Left Column -->
|
||||||
|
<div class="content-column">
|
||||||
|
<!-- Support Info -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h3 class="section-title">Support Information</h3>
|
||||||
|
<div class="info-list">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Support Team</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<a v-if="app.supportteam?.teamurl" :href="app.supportteam.teamurl" target="_blank">
|
||||||
|
{{ app.supportteam?.teamname || '-' }}
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ app.supportteam?.teamname || '-' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">App Owner</span>
|
||||||
|
<span class="info-value">{{ app.supportteam?.owner?.appowner || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row" v-if="app.supportteam?.owner?.sso">
|
||||||
|
<span class="info-label">SSO</span>
|
||||||
|
<span class="info-value mono">{{ app.supportteam.owner.sso }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Application Notes -->
|
||||||
|
<div class="section-card" v-if="app.applicationnotes">
|
||||||
|
<h3 class="section-title">Application Notes</h3>
|
||||||
|
<div class="notes-text" v-html="app.applicationnotes"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Versions -->
|
||||||
|
<div class="section-card" v-if="versions.length > 0">
|
||||||
|
<h3 class="section-title">Available Versions</h3>
|
||||||
|
<div class="version-list">
|
||||||
|
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||||
|
<span class="version-number">v{{ ver.version }}</span>
|
||||||
|
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||||
|
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column -->
|
||||||
|
<div class="content-column">
|
||||||
|
<!-- Installed On PCs -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||||
|
<div v-if="installedOn.length > 0" class="pc-list">
|
||||||
|
<router-link
|
||||||
|
v-for="install in installedOn"
|
||||||
|
:key="install.id"
|
||||||
|
:to="`/pcs/${install.machineid}`"
|
||||||
|
class="pc-item"
|
||||||
|
>
|
||||||
|
<div class="pc-info">
|
||||||
|
<span class="pc-name">{{ install.machine?.machinenumber || `PC #${install.machineid}` }}</span>
|
||||||
|
<span class="pc-alias" v-if="install.machine?.alias">{{ install.machine.alias }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pc-version" v-if="install.version">
|
||||||
|
v{{ install.version }}
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>Not installed on any PCs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit Footer -->
|
||||||
|
<div class="audit-footer">
|
||||||
|
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||||
|
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="card">
|
||||||
|
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { applicationsApi } from '../../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const app = ref(null)
|
||||||
|
const versions = ref([])
|
||||||
|
const installedOn = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// Load application details
|
||||||
|
const response = await applicationsApi.get(route.params.id)
|
||||||
|
app.value = response.data.data
|
||||||
|
|
||||||
|
// Load versions
|
||||||
|
try {
|
||||||
|
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||||
|
versions.value = versionsRes.data.data || []
|
||||||
|
} catch (e) {
|
||||||
|
console.log('No versions data')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load installed on which PCs
|
||||||
|
try {
|
||||||
|
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||||
|
installedOn.value = installedRes.data.data || []
|
||||||
|
} catch (e) {
|
||||||
|
console.log('No installed data')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading application:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
return new Date(dateStr).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageError(e) {
|
||||||
|
e.target.style.display = 'none'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Application-specific styles - shared styles are in global style.css */
|
||||||
|
|
||||||
|
/* Hero description (app-specific) */
|
||||||
|
.hero-description {
|
||||||
|
color: var(--text-light);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero links (app-specific) */
|
||||||
|
.hero-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-link:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder image */
|
||||||
|
.hero-image.placeholder {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 5rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version List */
|
||||||
|
.version-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-number {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-date {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-notes {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PC List */
|
||||||
|
.pc-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-item:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-alias {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-version {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes styling */
|
||||||
|
.notes-text :deep(a) {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-text :deep(a:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
271
frontend/src/views/applications/ApplicationForm.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ isEdit ? 'Edit Application' : 'New Application' }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="saveApplication">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="appname">Application Name *</label>
|
||||||
|
<input
|
||||||
|
id="appname"
|
||||||
|
v-model="form.appname"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="supportteamid">Support Team</label>
|
||||||
|
<select
|
||||||
|
id="supportteamid"
|
||||||
|
v-model="form.supportteamid"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<option value="">Select team...</option>
|
||||||
|
<option
|
||||||
|
v-for="team in supportTeams"
|
||||||
|
:key="team.supportteamid"
|
||||||
|
:value="team.supportteamid"
|
||||||
|
>
|
||||||
|
{{ team.teamname }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="appdescription">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="appdescription"
|
||||||
|
v-model="form.appdescription"
|
||||||
|
class="form-control"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Application Flags</h4>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="form.isinstallable" />
|
||||||
|
Installable
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="form.islicenced" />
|
||||||
|
Licensed
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="form.isprinter" />
|
||||||
|
Printer App
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="form.ishidden" />
|
||||||
|
Hidden
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Links & Paths</h4>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="applicationlink">Application Link</label>
|
||||||
|
<input
|
||||||
|
id="applicationlink"
|
||||||
|
v-model="form.applicationlink"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="documentationpath">Documentation Path</label>
|
||||||
|
<input
|
||||||
|
id="documentationpath"
|
||||||
|
v-model="form.documentationpath"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="URL or file path"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="installpath">Install Path</label>
|
||||||
|
<input
|
||||||
|
id="installpath"
|
||||||
|
v-model="form.installpath"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Network path or URL to install files"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="image">Image Filename</label>
|
||||||
|
<input
|
||||||
|
id="image"
|
||||||
|
v-model="form.image"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="e.g., myapp.png"
|
||||||
|
/>
|
||||||
|
<small class="form-hint">Image should be placed in /images/applications/</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="applicationnotes">Application Notes (HTML supported)</label>
|
||||||
|
<textarea
|
||||||
|
id="applicationnotes"
|
||||||
|
v-model="form.applicationnotes"
|
||||||
|
class="form-control"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Enter notes... HTML tags like <BR>, <a>, <strong> are supported"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||||
|
{{ saving ? 'Saving...' : 'Save Application' }}
|
||||||
|
</button>
|
||||||
|
<router-link to="/applications" class="btn btn-secondary">Cancel</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { applicationsApi } from '../../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
appname: '',
|
||||||
|
appdescription: '',
|
||||||
|
supportteamid: '',
|
||||||
|
isinstallable: false,
|
||||||
|
islicenced: false,
|
||||||
|
isprinter: false,
|
||||||
|
ishidden: false,
|
||||||
|
applicationlink: '',
|
||||||
|
documentationpath: '',
|
||||||
|
installpath: '',
|
||||||
|
image: '',
|
||||||
|
applicationnotes: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportTeams = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// Load support teams
|
||||||
|
const teamsRes = await applicationsApi.getSupportTeams()
|
||||||
|
supportTeams.value = teamsRes.data.data || []
|
||||||
|
|
||||||
|
// Load application if editing
|
||||||
|
if (isEdit.value) {
|
||||||
|
const response = await applicationsApi.get(route.params.id)
|
||||||
|
const app = response.data.data
|
||||||
|
|
||||||
|
form.value = {
|
||||||
|
appname: app.appname || '',
|
||||||
|
appdescription: app.appdescription || '',
|
||||||
|
supportteamid: app.supportteam?.supportteamid || '',
|
||||||
|
isinstallable: app.isinstallable || false,
|
||||||
|
islicenced: app.islicenced || false,
|
||||||
|
isprinter: app.isprinter || false,
|
||||||
|
ishidden: app.ishidden || false,
|
||||||
|
applicationlink: app.applicationlink || '',
|
||||||
|
documentationpath: app.documentationpath || '',
|
||||||
|
installpath: app.installpath || '',
|
||||||
|
image: app.image || '',
|
||||||
|
applicationnotes: app.applicationnotes || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading data:', err)
|
||||||
|
error.value = 'Failed to load data'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveApplication() {
|
||||||
|
error.value = ''
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appData = {
|
||||||
|
appname: form.value.appname,
|
||||||
|
appdescription: form.value.appdescription || null,
|
||||||
|
supportteamid: form.value.supportteamid || null,
|
||||||
|
isinstallable: form.value.isinstallable,
|
||||||
|
islicenced: form.value.islicenced,
|
||||||
|
isprinter: form.value.isprinter,
|
||||||
|
ishidden: form.value.ishidden,
|
||||||
|
applicationlink: form.value.applicationlink || null,
|
||||||
|
documentationpath: form.value.documentationpath || null,
|
||||||
|
installpath: form.value.installpath || null,
|
||||||
|
image: form.value.image || null,
|
||||||
|
applicationnotes: form.value.applicationnotes || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await applicationsApi.update(route.params.id, appData)
|
||||||
|
} else {
|
||||||
|
await applicationsApi.create(appData)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/applications')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving application:', err)
|
||||||
|
error.value = err.response?.data?.message || 'Failed to save application'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-light, #666);
|
||||||
|
}
|
||||||
|
</style>
|
||||||