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>
This commit is contained in:
cproudlock
2026-01-13 16:07:34 -05:00
commit 1196de6e88
188 changed files with 19921 additions and 0 deletions

15
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View 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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

6
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>

392
frontend/src/api/index.js Normal file
View 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')
}
}

File diff suppressed because it is too large Load Diff

View 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>

View 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">&times;</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>

View 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
View 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')

View 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

View 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()
}
}
}
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">&#x1F4E6;</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">&#x1F517;</span> Launch Application
</a>
<a v-if="app.installpath" :href="app.installpath" target="_blank" class="hero-link">
<span class="link-icon">&#x2B07;</span> Download Files
</a>
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" class="hero-link">
<span class="link-icon">&#x1F4C4;</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>

View 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>

Some files were not shown because too many files have changed in this diff Show More