9995 lines
308 KiB
Plaintext
9995 lines
308 KiB
Plaintext
--- Folder Structure ---
|
|
tp_servicedesk/
|
|
LICENSE
|
|
README.md
|
|
_codebase_output.txt
|
|
_codebase_schemafile.sql
|
|
composer.json
|
|
package.json
|
|
app/
|
|
config/
|
|
routes.ini
|
|
controllers/
|
|
AttachmentController.php
|
|
AuthController.php
|
|
BaseController.php
|
|
CommentController.php
|
|
DashboardController.php
|
|
HomeController.php
|
|
KBController.php
|
|
ParsedownPreview.php
|
|
ProjectController.php
|
|
TagController.php
|
|
ThemeController.php
|
|
TicketController.php
|
|
UserController.php
|
|
Admin/
|
|
HomeController.php
|
|
TicketOptionsController.php
|
|
UserController.php
|
|
extensions/
|
|
BulmaFormHelper.php
|
|
IconsHelper.php
|
|
ParsedownHelper.php
|
|
ParsedownTableExtension.php
|
|
interfaces/
|
|
CRUD.php
|
|
models/
|
|
Attachment.php
|
|
Comment.php
|
|
Tag.php
|
|
Ticket.php
|
|
TicketPriority.php
|
|
TicketStatus.php
|
|
traits/
|
|
RequiresAuth.php
|
|
downloads/
|
|
lib/
|
|
public/
|
|
index.php
|
|
logo.svg
|
|
style.css
|
|
test.md.php
|
|
css/
|
|
js/
|
|
kb_edit.js
|
|
markdown_preview.js
|
|
ticket_view.js
|
|
tp_md_editor.js
|
|
tmp/
|
|
scss/
|
|
main.scss
|
|
components/
|
|
_ticket-item.scss
|
|
vendor/
|
|
_bulma-tools.scss
|
|
_bulma.scss
|
|
storage/
|
|
ui/
|
|
modal/
|
|
partials/
|
|
ticket_item.html
|
|
parts/
|
|
clipboard.html
|
|
session/
|
|
error.html
|
|
templates/
|
|
layout.html
|
|
views/
|
|
dashboard.html
|
|
home.html
|
|
login.html
|
|
admin/
|
|
index.html
|
|
priorities/
|
|
create.html
|
|
index.html
|
|
attachment/
|
|
index.html
|
|
comments/
|
|
view.html
|
|
kb/
|
|
create.html
|
|
edit.html
|
|
index.html
|
|
view.html
|
|
project/
|
|
create.html
|
|
edit.html
|
|
index.html
|
|
view.html
|
|
tag/
|
|
create.html
|
|
index.html
|
|
ticket/
|
|
create.html
|
|
edit.html
|
|
edit.html.v1
|
|
index.html
|
|
index_row.html
|
|
view.html
|
|
user/
|
|
edit.html
|
|
index.html
|
|
|
|
============================================================
|
|
|
|
--- File Contents ---
|
|
|
|
|
|
--- File: LICENSE ---
|
|
MIT License
|
|
|
|
Copyright (c) 2025 tp_dhu
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
|
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
--- End File: LICENSE ---
|
|
|
|
|
|
--- File: README.md ---
|
|
# Coding Approach
|
|
- Classes
|
|
- Class names should be in `CapitalCamelCase`
|
|
- Class functions will be in `camelCase`
|
|
- Functions
|
|
- Function variables will be in `snake_case`
|
|
- Arrays
|
|
- Array keys will be in `snake_case`
|
|
- SQL
|
|
- table names, and columns names will be in `snake_case`
|
|
- Don't repeat yourself (DRY)
|
|
- Each fucntion should have a single purpose
|
|
|
|
# tp_servicedesk
|
|
|
|
A { service desk, ticket, knowledge base } web application written in PHP using fat free framework.
|
|
|
|
Used to keep track of ongoing projects/tasks, allow to view and search historic projects which may have already answered a previous question. Knowledge Base built from applications using markdown.
|
|
|
|
## Plesk - quest notes
|
|
|
|
- plesk ext composer --application -register -domain desk.tinylink.uk -path desk.tinylink.uk/tp_servicedesk
|
|
~ https://www.plesk.com/kb/support/how-to-change-in-the-php-composer-extension-the-path-of-the-composer-json-file/
|
|
|
|
## Milestones
|
|
- Database created locally
|
|
- .gitignore added
|
|
- added AuthController - login and logout process
|
|
--- End File: README.md ---
|
|
|
|
|
|
--- File: _codebase_output.txt ---
|
|
--- Folder Structure ---
|
|
tp_servicedesk/
|
|
LICENSE
|
|
README.md
|
|
_codebase_output.txt
|
|
_codebase_schemafile.sql
|
|
composer.json
|
|
package.json
|
|
app/
|
|
config/
|
|
routes.ini
|
|
controllers/
|
|
AttachmentController.php
|
|
AuthController.php
|
|
BaseController.php
|
|
CommentController.php
|
|
DashboardController.php
|
|
HomeController.php
|
|
KBController.php
|
|
ParsedownPreview.php
|
|
ProjectController.php
|
|
TagController.php
|
|
ThemeController.php
|
|
TicketController.php
|
|
UserController.php
|
|
Admin/
|
|
HomeController.php
|
|
TicketOptionsController.php
|
|
UserController.php
|
|
extensions/
|
|
BulmaFormHelper.php
|
|
IconsHelper.php
|
|
ParsedownHelper.php
|
|
ParsedownTableExtension.php
|
|
interfaces/
|
|
CRUD.php
|
|
models/
|
|
Attachment.php
|
|
Comment.php
|
|
Tag.php
|
|
Ticket.php
|
|
TicketPriority.php
|
|
TicketStatus.php
|
|
traits/
|
|
RequiresAuth.php
|
|
downloads/
|
|
lib/
|
|
public/
|
|
index.php
|
|
logo.svg
|
|
style.css
|
|
test.md.php
|
|
css/
|
|
js/
|
|
kb_edit.js
|
|
markdown_preview.js
|
|
ticket_view.js
|
|
tp_md_editor.js
|
|
tmp/
|
|
scss/
|
|
main.scss
|
|
components/
|
|
_ticket-item.scss
|
|
vendor/
|
|
_bulma-tools.scss
|
|
_bulma.scss
|
|
storage/
|
|
ui/
|
|
modal/
|
|
partials/
|
|
ticket_item.html
|
|
parts/
|
|
clipboard.html
|
|
session/
|
|
error.html
|
|
templates/
|
|
layout.html
|
|
views/
|
|
dashboard.html
|
|
home.html
|
|
login.html
|
|
admin/
|
|
index.html
|
|
priorities/
|
|
create.html
|
|
index.html
|
|
attachment/
|
|
index.html
|
|
comments/
|
|
view.html
|
|
kb/
|
|
create.html
|
|
edit.html
|
|
index.html
|
|
view.html
|
|
project/
|
|
create.html
|
|
edit.html
|
|
index.html
|
|
view.html
|
|
tag/
|
|
create.html
|
|
index.html
|
|
ticket/
|
|
create.html
|
|
edit.html
|
|
edit.html.v1
|
|
index.html
|
|
index_row.html
|
|
view.html
|
|
user/
|
|
edit.html
|
|
index.html
|
|
|
|
============================================================
|
|
|
|
--- File Contents ---
|
|
|
|
|
|
--- File: LICENSE ---
|
|
MIT License
|
|
|
|
Copyright (c) 2025 tp_dhu
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
|
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
--- End File: LICENSE ---
|
|
|
|
|
|
--- File: README.md ---
|
|
# Coding Approach
|
|
- Classes
|
|
- Class names should be in `CapitalCamelCase`
|
|
- Class functions will be in `camelCase`
|
|
- Functions
|
|
- Function variables will be in `snake_case`
|
|
- Arrays
|
|
- Array keys will be in `snake_case`
|
|
- SQL
|
|
- table names, and columns names will be in `snake_case`
|
|
- Don't repeat yourself (DRY)
|
|
- Each fucntion should have a single purpose
|
|
|
|
# tp_servicedesk
|
|
|
|
A { service desk, ticket, knowledge base } web application written in PHP using fat free framework.
|
|
|
|
Used to keep track of ongoing projects/tasks, allow to view and search historic projects which may have already answered a previous question. Knowledge Base built from applications using markdown.
|
|
|
|
## Plesk - quest notes
|
|
|
|
- plesk ext composer --application -register -domain desk.tinylink.uk -path desk.tinylink.uk/tp_servicedesk
|
|
~ https://www.plesk.com/kb/support/how-to-change-in-the-php-composer-extension-the-path-of-the-composer-json-file/
|
|
|
|
## Milestones
|
|
- Database created locally
|
|
- .gitignore added
|
|
- added AuthController - login and logout process
|
|
--- End File: README.md ---
|
|
|
|
|
|
--- File: _codebase_output.txt ---
|
|
|
|
--- End File: _codebase_output.txt ---
|
|
|
|
|
|
--- File: _codebase_schemafile.sql ---
|
|
-- MariaDB dump 10.19 Distrib 10.4.32-MariaDB, for Win64 (AMD64)
|
|
--
|
|
-- Host: localhost Database: tp_servicedesk
|
|
-- ------------------------------------------------------
|
|
-- Server version 10.4.32-MariaDB
|
|
|
|
/*!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 utf8mb4 */;
|
|
/*!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 `attachments`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `attachments`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `attachments` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`path` text NOT NULL,
|
|
`file_name` text NOT NULL,
|
|
`ticket_id` int(11) DEFAULT NULL,
|
|
`kb_id` int(11) DEFAULT NULL,
|
|
`version_number` int(11) NOT NULL,
|
|
`uploaded_by` int(11) NOT NULL,
|
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `kb`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `kb`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `kb` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`title` text NOT NULL,
|
|
`content` text NOT NULL,
|
|
`created_by` int(11) NOT NULL,
|
|
`updated_by` int(11) DEFAULT NULL,
|
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
|
`updated_at` timestamp NULL DEFAULT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `kb_tags`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `kb_tags`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `kb_tags` (
|
|
`kb_id` int(11) NOT NULL,
|
|
`tag_id` int(11) NOT NULL,
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `project_events`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `project_events`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `project_events` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`project_id` int(11) NOT NULL,
|
|
`event_date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
|
`description` text NOT NULL,
|
|
`created_by` int(11) NOT NULL,
|
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `project_links`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `project_links`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `project_links` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`project_id` int(11) NOT NULL,
|
|
`url` text NOT NULL,
|
|
`description` text NOT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `project_tasks`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `project_tasks`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `project_tasks` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`project_id` int(11) NOT NULL,
|
|
`title` varchar(255) NOT NULL,
|
|
`description` text NOT NULL,
|
|
`status` enum('pending','in_progress','done','') NOT NULL,
|
|
`start_date` timestamp NULL DEFAULT NULL,
|
|
`end_date` timestamp NULL DEFAULT NULL,
|
|
`created_by` int(11) NOT NULL,
|
|
`created_at` int(11) NOT NULL DEFAULT current_timestamp(),
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `projects`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `projects`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `projects` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`title` varchar(255) NOT NULL,
|
|
`description` text NOT NULL,
|
|
`requester` varchar(255) NOT NULL,
|
|
`created_by` int(11) NOT NULL,
|
|
`start_date` timestamp NULL DEFAULT NULL,
|
|
`end_date` timestamp NULL DEFAULT NULL,
|
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
|
`updated_at` timestamp NULL DEFAULT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!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` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`role` varchar(50) NOT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `sessions`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `sessions`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `sessions` (
|
|
`session_id` varchar(255) NOT NULL,
|
|
`data` text DEFAULT NULL,
|
|
`ip` varchar(45) DEFAULT NULL,
|
|
`agent` varchar(300) DEFAULT NULL,
|
|
`stamp` int(11) DEFAULT NULL,
|
|
PRIMARY KEY (`session_id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `tags`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `tags`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `tags` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`name` text NOT NULL,
|
|
`color` text NOT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `ticket_comments`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `ticket_comments`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `ticket_comments` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`ticket_id` int(11) NOT NULL,
|
|
`comment` text NOT NULL,
|
|
`created_by` int(11) DEFAULT NULL,
|
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
|
`deleted` int(11) NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (`id`),
|
|
KEY `ticket_id` (`ticket_id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `ticket_meta`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `ticket_meta`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `ticket_meta` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`ticket_id` int(11) NOT NULL,
|
|
`meta_key` text NOT NULL,
|
|
`meta_value` text NOT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `ticket_priorities`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `ticket_priorities`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `ticket_priorities` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`name` varchar(100) NOT NULL,
|
|
`sort_order` int(11) NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `ticket_relations`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `ticket_relations`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `ticket_relations` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`parent_ticket_id` int(11) NOT NULL,
|
|
`child_ticket_id` int(11) NOT NULL,
|
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `ticket_statuses`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `ticket_statuses`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `ticket_statuses` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`name` varchar(100) NOT NULL,
|
|
`sort_order` int(11) NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `ticket_tags`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `ticket_tags`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `ticket_tags` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`ticket_id` int(11) NOT NULL,
|
|
`tag_id` int(11) NOT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `ticket_updates`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `ticket_updates`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `ticket_updates` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`ticket_id` int(11) NOT NULL,
|
|
`comment` text NOT NULL,
|
|
`updated_by` int(11) NOT NULL,
|
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
|
|
--
|
|
-- Table structure for table `tickets`
|
|
--
|
|
|
|
DROP TABLE IF EXISTS `tickets`;
|
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
/*!40101 SET character_set_client = utf8 */;
|
|
CREATE TABLE `tickets` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`title` text NOT NULL,
|
|
`description` text NOT NULL,
|
|
`status_id` int(11) NOT NULL,
|
|
`priority_id` int(11) NOT NULL,
|
|
`created_by` int(11) NOT NULL,
|
|
`assigned_to` int(11) DEFAULT NULL,
|
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
|
`updated_at` timestamp NULL DEFAULT NULL,
|
|
`updated_by` int(11) DEFAULT NULL,
|
|
`project_id` int(11) DEFAULT NULL,
|
|
`recycled` tinyint(1) NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB AUTO_INCREMENT=57 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!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` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`username` text NOT NULL,
|
|
`password` text NOT NULL,
|
|
`email` text NOT NULL,
|
|
`display_name` text NOT NULL,
|
|
`role` int(11) NOT NULL,
|
|
`created_at` timestamp NULL DEFAULT NULL,
|
|
`updated_at` timestamp NULL DEFAULT NULL,
|
|
`is_admin` tinyint(1) NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `username` (`username`) USING HASH
|
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
|
/*!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 2025-04-06 12:18:20
|
|
|
|
--- End File: _codebase_schemafile.sql ---
|
|
|
|
|
|
--- File: composer.json ---
|
|
{
|
|
"name": "tp/tp_servicedesk",
|
|
"description": "",
|
|
"config": {
|
|
"vendor-dir": "lib"
|
|
},
|
|
"require": {
|
|
"bcosca/fatfree-core": "^3.9",
|
|
"erusev/parsedown": "^1.7",
|
|
"ezyang/htmlpurifier": "^4.18",
|
|
"erusev/parsedown-extra": "^0.8.1",
|
|
"singular-it/parsedown-checkbox": "^0.3.5"
|
|
}
|
|
}
|
|
|
|
--- End File: composer.json ---
|
|
|
|
|
|
--- File: package.json ---
|
|
{
|
|
"dependencies": {
|
|
"bulma": "^1.0.3"
|
|
},
|
|
"scripts": {
|
|
"sass": "sass scss/main.scss public/css/main.css",
|
|
"sass:min": "sass scss/main.scss public/css/main.min.css --style compressed",
|
|
"sass:watch": "sass --watch scss/main.scss:public/css/main.css"
|
|
}
|
|
}
|
|
|
|
--- End File: package.json ---
|
|
|
|
|
|
--- File: app/config/routes.ini ---
|
|
[routes]
|
|
|
|
; home
|
|
GET /=HomeController->display
|
|
|
|
; auth
|
|
GET /login=AuthController->showLoginForm
|
|
POST /login=AuthController->login
|
|
GET /logout=AuthController->logout
|
|
|
|
; tickets - CRUD (CREATE, READ, UPDATE, DELETE)
|
|
GET /tickets=TicketController->index
|
|
GET /ticket/@id=TicketController->view
|
|
GET /ticket/create=TicketController->createForm
|
|
POST /ticket/create=TicketController->create
|
|
GET /ticket/@id/edit=TicketController->editForm
|
|
POST /ticket/@id/update=TicketController->update
|
|
GET /ticket/@id/delete=TicketController->delete
|
|
; additional routes - comments
|
|
POST /ticket/@id/comment=CommentController->create
|
|
GET /ticket/@id/comment/@comment_id/delete=CommentController->delete
|
|
GET /ticket/@id/comments=CommentController->index
|
|
; route for linking a child to a parent
|
|
POST /ticket/@id/add-subtask=TicketController->addSubtask
|
|
|
|
; attachments
|
|
GET /ticket/@id/attachments=AttachmentController->index
|
|
POST /ticket/@id/attachments/upload=AttachmentController->upload
|
|
GET /attachment/@id/download=AttachmentController->download
|
|
GET /attachment/@id/delete=AttachmentController->delete
|
|
GET /attachment/@id/view=AttachmentController->view
|
|
|
|
; knowledgebase
|
|
GET /kb=KBController->index
|
|
GET /kb/@id=KBController->view
|
|
GET /kb/create=KBController->createForm
|
|
POST /kb/create=KBController->create
|
|
GET /kb/@id/edit=KBController->editForm
|
|
POST /kb/@id/update=KBController->update
|
|
|
|
; tags
|
|
GET /tags=TagController->index
|
|
GET /tag/create=TagController->createForm
|
|
POST /tag/create=TagController->create
|
|
|
|
; parsedown preview
|
|
POST /parsedown/preview=ParsedownPreview->view
|
|
|
|
; toggle-theme
|
|
POST /toggle-theme = ThemeController->toggle
|
|
|
|
; dashboard
|
|
GET /dashboard=DashboardController->index
|
|
|
|
; projects
|
|
GET /projects=ProjectController->index
|
|
GET /project/@id=ProjectController->view
|
|
GET /project/create=ProjectController->createForm
|
|
POST /project/create=ProjectController->create
|
|
GET /project/@id/edit=ProjectController->editForm
|
|
POST /project/@id/update=ProjectController->update
|
|
|
|
; additional routes - user
|
|
GET /users=UserController->index
|
|
GET /user/@id/edit=UserController->editForm
|
|
POST /user/@id/update=UserController->update
|
|
|
|
|
|
|
|
; admin
|
|
GET /admin=Admin\HomeController->index
|
|
; admin/priority
|
|
GET /admin/priority=Admin\TicketOptionsController->listPriorities
|
|
GET /admin/priority/create=Admin\TicketOptionsController->createPriorityForm
|
|
POST /admin/priority/create=Admin\TicketOptionsController->createPriority
|
|
GET /admin/priority/@id/edit=Admin\TicketController->editPriorityForm
|
|
POST /admin/priority/@id/update=Admin\TicketController->updatePriority
|
|
GET /admin/priority/@id/delete=Admin\TicketController->deletePriority
|
|
; admin/status
|
|
GET /admin/status=Admin\TicketOptionsController->listStatuses
|
|
GET /admin/status/create=Admin\TicketOptionsController->createStatusForm
|
|
POST /admin/status/create=Admin\TicketOptionsController->createStatus
|
|
GET /admin/status/@id/edit=Admin\TicketController->editStatusForm
|
|
POST /admin/status/@id/update=Admin\TicketController->updateStatus
|
|
GET /admin/status/@id/delete=Admin\TicketController->deleteStatus
|
|
--- End File: app/config/routes.ini ---
|
|
|
|
|
|
--- File: app/controllers/AttachmentController.php ---
|
|
<?php
|
|
|
|
class AttachmentController {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list attachments
|
|
public function index($f3){
|
|
$this->check_access($f3);
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
// fetch attachments
|
|
|
|
$attachments = $db->exec(
|
|
'SELECT a.*, u.username
|
|
FROM attachments a
|
|
LEFT JOIN users u ON u.id = a.uploaded_by
|
|
WHERE a.ticket_id = ?
|
|
ORDER BY a.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
|
|
$f3->set('ticket_id', $ticket_id);
|
|
$f3->set('attachments', $attachments);
|
|
|
|
$f3->set('content', '../ui/views/attachment/index.html');
|
|
// echo \Template::instance()->render('../ui/templates/layout.html');
|
|
echo \Template::instance()->render($f3->get('content'));
|
|
}
|
|
|
|
// handle file upload
|
|
public function upload($f3){
|
|
$this->check_access($f3);
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$uploaded_by = $f3->get('SESSION.user.id');
|
|
|
|
if(!isset($_FILES['attachment']) || $_FILES['attachment']['error'] !== UPLOAD_ERR_OK){
|
|
$f3->reroute('/ticket/'.$ticket_id.'/attachments');
|
|
}
|
|
|
|
$file_info = $_FILES['attachment'];
|
|
$original_name = $file_info['name'];
|
|
$tmp_path = $file_info['tmp_name'];
|
|
|
|
// create a unique file path
|
|
$upload_dir = '../storage/attachments/tickets/'.$ticket_id.'/';
|
|
if(!is_dir($upload_dir)){
|
|
mkdir($upload_dir, 0777, true);
|
|
}
|
|
|
|
// if file exists increment version
|
|
$db = $f3->get('DB');
|
|
$existing = $db->exec(
|
|
'SELECT * FROM attachments
|
|
WHERE ticket_id =? AND file_name = ?
|
|
ORDER BY version_number DESC
|
|
LIMIT 1',
|
|
[$ticket_id, $original_name]
|
|
);
|
|
|
|
$new_version = 1;
|
|
if($existing){
|
|
$new_version = $existing[0]['version_number'] + 1;
|
|
}
|
|
|
|
$final_path = $upload_dir.$new_version.'_'.$original_name;
|
|
|
|
// move file
|
|
move_uploaded_file($tmp_path, $final_path);
|
|
|
|
// store meta data in DB
|
|
$db->exec(
|
|
'INSERT INTO attachments
|
|
(ticket_id, path, file_name, version_number, uploaded_by, created_at)
|
|
VALUES (?,?,?,?,?,NOW())',
|
|
[$ticket_id, $final_path, $original_name, $new_version, $uploaded_by]
|
|
);
|
|
|
|
$f3->reroute('/ticket/'.$ticket_id.'');
|
|
}
|
|
|
|
// download attachment
|
|
public function download($f3){
|
|
$this->check_access($f3);
|
|
|
|
$attachment_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec('SELECT * FROM attachments WHERE id = ?', [$attachment_id]);
|
|
|
|
if(!$rows){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
$attachment = $rows[0];
|
|
$file_path = $attachment['path'];
|
|
$file_name = $attachment['file_name'];
|
|
|
|
// validate file exists
|
|
if(!file_exists($file_path)){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
// output headers for download
|
|
header('Content-Description: File Transfer');
|
|
header('Content-Type: application/octet-stream');
|
|
header('Content-Disposition: attachment; filename="'.basename($file_name).'"');
|
|
header('Content-Length: '. filesize($file_path));
|
|
|
|
// flush headers
|
|
flush();
|
|
|
|
// read file
|
|
readfile($file_path);
|
|
exit;
|
|
}
|
|
|
|
// delete an attachment
|
|
public function delete($f3){
|
|
$this->check_access($f3);
|
|
|
|
$attachment_id = (int) $f3->get('PARAMS.id');
|
|
$current_user = $f3->get('SESSION.user');
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec('SELECT * FROM attachments WHERE id =? LIMIT 1', [$attachment_id]);
|
|
if(!$rows){
|
|
$f3->error(404, "Attachment not found");
|
|
return;
|
|
}
|
|
|
|
$attachment = $rows[0];
|
|
// TODO: role or ownership
|
|
|
|
if(file_exists($attachment['path'])){
|
|
unlink($attachment['path']);
|
|
}
|
|
|
|
// remove DB row
|
|
$db->exec('DELETE FROM attachments WHERE id =?', [$attachment_id]);
|
|
}
|
|
|
|
// view attachment
|
|
public function view($f3){
|
|
$this->check_access($f3);
|
|
|
|
$attachment_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec('SELECt * FROM attachments WHERE id = ?', [$attachment_id]);
|
|
|
|
if(!$rows){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
$attachment = $rows[0];
|
|
$file_path = $attachment['path'];
|
|
$file_name = $attachment['file_name'];
|
|
|
|
if(!file_exists($file_path)){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
// detect mime type
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mime_type = finfo_file($finfo, $file_path);
|
|
finfo_close($finfo);
|
|
|
|
header('Content-Type: ' . $mime_type);
|
|
header('Content-Disposition: inline; filename="' . basename($file_name) . '"');
|
|
header('Content-Length: ' . filesize($file_path));
|
|
|
|
flush();
|
|
readfile($file_path);
|
|
exit;
|
|
}
|
|
}
|
|
--- End File: app/controllers/AttachmentController.php ---
|
|
|
|
|
|
--- File: app/controllers/AuthController.php ---
|
|
<?php
|
|
|
|
class AuthController {
|
|
|
|
|
|
public function showLoginForm($f3){
|
|
|
|
// store session errors or messages, then clear
|
|
$f3->set('error', $f3->get('SESSION.login_error'));
|
|
$f3->clear('SESSION.login_error');
|
|
|
|
// this can be in our controller base
|
|
$f3->set('content', '../ui/views/login.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('error');
|
|
}
|
|
|
|
public function login($f3){
|
|
$username = $f3->get('POST.username');
|
|
$password = $f3->get('POST.password');
|
|
|
|
$db = $f3->get('DB');
|
|
// query for user
|
|
$result = $db->exec(
|
|
'SELECT u.id, u.username, u.password, u.role, u.is_admin, r.role as role_name
|
|
FROM users u
|
|
LEFT JOIN roles r ON r.id = u.role
|
|
WHERE username =?
|
|
LIMIT 1', $username
|
|
);
|
|
|
|
// verifiy password
|
|
if($result){
|
|
$user = $result[0]; // first row
|
|
if(password_verify($password, $user['password'])){
|
|
// valid
|
|
$f3->set('SESSION.user', [
|
|
'id'=> $user['id'],
|
|
'username' => $user['username'],
|
|
'role' => $user['role'],
|
|
'role_name' => $user['role_name'],
|
|
'is_admin' => $user['is_admin']
|
|
]);
|
|
|
|
if($f3->exists('SESSION.redirect')){
|
|
$redirect = $f3->get('SESSION.redirect');
|
|
$f3->clear('SESSION.redirect');
|
|
$f3->reroute($redirect);
|
|
}
|
|
|
|
$f3->reroute('/dashboard');
|
|
} else {
|
|
$f3->set('SESSION.login_error', 'Invalid password');
|
|
}
|
|
} else {
|
|
// if here, login failed.
|
|
$f3->set('SESSION.login_error', 'Invalid username');
|
|
}
|
|
|
|
$f3->reroute('/login');
|
|
|
|
}
|
|
|
|
public function logout($f3){
|
|
$f3->clear('SESSION');
|
|
$f3->reroute('/');
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/AuthController.php ---
|
|
|
|
|
|
--- File: app/controllers/BaseController.php ---
|
|
<?php
|
|
|
|
abstract class BaseController
|
|
{
|
|
|
|
use RequiresAuth;
|
|
|
|
protected $f3;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->f3 = \Base::instance();
|
|
|
|
}
|
|
|
|
|
|
// helper function
|
|
|
|
protected function getDB()
|
|
{
|
|
return $this->f3->get('DB');
|
|
}
|
|
|
|
/**
|
|
* Enforce that the user is logged in before proceeding.
|
|
*/
|
|
protected function requireLogin()
|
|
{
|
|
// using trait
|
|
$this->check_access($this->f3);
|
|
return;
|
|
|
|
// abstract
|
|
if(!$this->f3->exists('SESSION.user')){
|
|
$this->f3->set('SESSION.redirect', $this->f3->get('PATH'));
|
|
$this->f3->reroute('/login');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enforce that the user is logged in AND is an admin before proceeding.
|
|
*/
|
|
protected function requireAdmin()
|
|
{
|
|
$this->requireLogin(); // First, ensure the user is logged in
|
|
|
|
// Check if the user is an admin (assuming 'is_admin' property in session)
|
|
if (!$this->f3->get('SESSION.user.is_admin')) {
|
|
// Optionally set an error message
|
|
$this->f3->set('SESSION.error', 'Admin access required.');
|
|
$this->f3->reroute('/'); // Redirect non-admins to home page
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up a main layout template and inject the specified view path
|
|
* optional $data to pass variables down to template
|
|
*/
|
|
protected function renderView(string $viewPath, array $data = []):void
|
|
{
|
|
foreach($data as $key => $value){
|
|
$this->f3->set($key, $value);
|
|
}
|
|
|
|
// set {{content}}
|
|
$this->f3->set('content', $viewPath);
|
|
|
|
// render tempalte
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
// clear SESSION.error
|
|
$this->f3->clear('SESSION.error');
|
|
}
|
|
}
|
|
--- End File: app/controllers/BaseController.php ---
|
|
|
|
|
|
--- File: app/controllers/CommentController.php ---
|
|
<?php
|
|
|
|
class CommentController {
|
|
|
|
/**
|
|
* Add a new comment to a ticket.
|
|
* Expects POST data: comment (text)
|
|
* Route: POST /ticket/@id/comment
|
|
*/
|
|
public function create($f3){
|
|
// check logged in
|
|
if(!$f3->exists('SESSION.user')){
|
|
$f3->reroute('/login');
|
|
}
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$comment_text = $f3->get('POST.comment');
|
|
$current_user_id = $f3->get('SESSION.user.id');
|
|
|
|
if(empty($comment_text)){
|
|
$f3->set('SESSION.error', 'ticket not updated. No content');
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
// insert comment
|
|
$db = $f3->get('DB');
|
|
$db->exec(
|
|
'INSERT INTO ticket_comments (ticket_id, comment, created_by, created_at)
|
|
VALUES (?, ?, ?, NOW())',
|
|
[$ticket_id, $comment_text, $current_user_id]
|
|
);
|
|
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
/**
|
|
* Delete an existing comment
|
|
* Route: GET /tickey/@id/comment/@comment_id/delete
|
|
*/
|
|
public function delete($f3){
|
|
if(!$f3->exists('SESSION.user')){
|
|
$f3->reroute('/login');
|
|
}
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$comment_id = (int) $f3->get('PARAMS.comment_id');
|
|
$current_user = $f3->get('SESSION.user');
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
//optional: check if user is allowed to delete comment.
|
|
// fetch who created the comment
|
|
$comment_row = $db->exec(
|
|
'SELECT created_by FROM ticket_comments WHERE id = ? AND ticket_id = ? LIMIT 1',
|
|
[$comment_id, $ticket_id]
|
|
);
|
|
if(!$comment_row){
|
|
$f3->set('SESSION.error', 'Error: Ticket comment ID not found.');
|
|
$f3->reroute('/ticket/'.$ticket_id);
|
|
}
|
|
$comment_owner = $comment_row[0]['created_by'];
|
|
// TODO: $is_admin = ()
|
|
if($current_user['id'] !== $comment_owner){
|
|
// no permission
|
|
$f3->set('SESSION.error', 'You do not have permission to delete this ticket');
|
|
$f3->reroute('/ticket/'. $ticket_id);
|
|
}
|
|
|
|
// Delete - addition, rather than delete, we set a delete flag
|
|
$db->exec('UPDATE ticket_comments SET deleted = 1 WHERE id = ?', [$comment_id]);
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
// view comments
|
|
public function index($f3){
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
$results = $db->exec('
|
|
SELECT c.*, u.username AS author_name
|
|
FROM ticket_comments c
|
|
LEFT JOIN users u ON c.created_by = u.id
|
|
WHERE c.ticket_id = ?
|
|
ORDER BY c.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
$comments = $results;
|
|
$f3->set('comments', $comments);
|
|
|
|
echo \Template::instance()->render('../ui/views/comments/view.html');
|
|
}
|
|
}
|
|
--- End File: app/controllers/CommentController.php ---
|
|
|
|
|
|
--- File: app/controllers/DashboardController.php ---
|
|
<?php
|
|
|
|
class DashboardController extends BaseController {
|
|
|
|
function index($f3){
|
|
|
|
$this->requireLogin();
|
|
|
|
$this->renderView('/ui/views/dashboard.html');
|
|
}
|
|
}
|
|
--- End File: app/controllers/DashboardController.php ---
|
|
|
|
|
|
--- File: app/controllers/HomeController.php ---
|
|
<?php
|
|
|
|
class HomeController extends BaseController {
|
|
|
|
public function display($f3){
|
|
|
|
$this->renderView('/ui/views/home.html');
|
|
|
|
}
|
|
// ...
|
|
}
|
|
--- End File: app/controllers/HomeController.php ---
|
|
|
|
|
|
--- File: app/controllers/KBController.php ---
|
|
<?php
|
|
|
|
class KBController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
public function index($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$search_term = $f3->get('GET.search');
|
|
$tag_param = $f3->get('GET.tag');
|
|
|
|
// base query
|
|
$sql = 'SELECT a.* FROM kb a';
|
|
$args = [];
|
|
|
|
if($tag_param){
|
|
$sql .= '
|
|
JOIN kb_tags AS at ON a.id = at.article_id
|
|
JOIN tags t ON at.tag_id = t.id
|
|
WHERE t.name = ?
|
|
';
|
|
$args[] = $tag_param;
|
|
|
|
if($search_term){
|
|
$sql .= ' AND LOWER(a.title) LIKE LOWER(?)';
|
|
$args[] = '%' . $search_term . '%';
|
|
}
|
|
} else if ($search_term){
|
|
$sql .= ' WHERE LOWER(a.title) LIKE LOWER(?)';
|
|
$args[] = '%' . $search_term . '%';
|
|
}
|
|
|
|
$sql .= ' ORDER BY a.created_at DESC';
|
|
|
|
$articles = $db->exec($sql, $args);
|
|
|
|
// render
|
|
$f3->set('articles', $articles);
|
|
$f3->set('content', '../ui/views/kb/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
|
|
}
|
|
|
|
/**
|
|
* Form to create new article
|
|
*/
|
|
public function createForm($f3){
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$all_tags = $db->exec('SELECT * FROM tags ORDER BY name ASC');
|
|
$f3->set('all_tags', $all_tags);
|
|
|
|
// render
|
|
$f3->set('content', '../ui/views/kb/create.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
|
|
}
|
|
|
|
// handle POST
|
|
public function create($f3){
|
|
$this->check_access($f3);
|
|
|
|
$title = $f3->get('POST.title');
|
|
$content = $f3->get('POST.content');
|
|
$created_by = $f3->get('SESSION.user.id');
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
// insert
|
|
|
|
$db->exec(
|
|
'INSERT INTO kb (title, content, created_by, updated_by, created_at, updated_at)
|
|
VALUES (?,?,?,?, NOW(), NOW())',
|
|
[$title, $content, $created_by, $created_by]
|
|
);
|
|
|
|
$article_id = $db->lastInsertId();
|
|
|
|
// TODO: tags
|
|
|
|
$f3->reroute('/kb');
|
|
}
|
|
|
|
//
|
|
|
|
protected function check_kb_exists($article_id, $db, $f3){
|
|
$articles = $db->exec(
|
|
'SELECT * FROM kb WHERE id = ? LIMIT 1', [$article_id]
|
|
);
|
|
if(!$articles){
|
|
$f3->set('SESSION.error', 'Article not found');
|
|
$f3->reroute('/kb');
|
|
}
|
|
return $articles;
|
|
}
|
|
|
|
// view a single
|
|
public function view($f3){
|
|
$this->check_access($f3);
|
|
$article_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$articles = $this->check_kb_exists($article_id, $db, $f3);
|
|
|
|
$article = $articles[0];
|
|
$f3->set('article', $article);
|
|
|
|
// TODO: tags
|
|
$tags = $db->exec(
|
|
'SELECT t.* FROM tags AS t
|
|
JOIN kb_tags AS at ON t.id = at.tag_id
|
|
WHERE at.kb_id = ?',
|
|
[$article_id]
|
|
);
|
|
|
|
// render
|
|
$f3->set('content', '../ui/views/kb/view.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
}
|
|
|
|
/**
|
|
* Form to edit existing kb article
|
|
*/
|
|
public function editForm($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$article_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$articles = $this->check_kb_exists($article_id, $db, $f3);
|
|
|
|
$article = $articles[0];
|
|
$f3->set('article', $article);
|
|
|
|
// fetch current tags
|
|
$current_tag_ids = $db->exec(
|
|
'SELECT tag_id FROM kb_tags WHERE kb_id = ?', [$article_id]
|
|
);
|
|
|
|
$article_tag_ids = array_column($current_tag_ids, 'tag_id');
|
|
$f3->set('article_tag_ids', $article_tag_ids);
|
|
|
|
// render
|
|
$f3->set('js', 'kb_edit.js');
|
|
$f3->set('content', '../ui/views/kb/edit.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle POST to edit existing article
|
|
*/
|
|
public function update($f3){
|
|
$this->check_access($f3);
|
|
$article_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$articles = $this->check_kb_exists($article_id, $db, $f3);
|
|
$article = $articles[0];
|
|
|
|
$title = $f3->get('POST.title');
|
|
$content = $f3->get('POST.content');
|
|
$updated_by = $f3->get('SESSION.user.id');
|
|
|
|
$db->exec(
|
|
'UPDATE kb
|
|
SET title=?, content=?, updated_by =?, updated_at = NOW()
|
|
WHERE id = ?',
|
|
[$title, $content, $updated_by, $article_id]
|
|
);
|
|
|
|
// update tags - first delete
|
|
$db->exec('DELETE FROM kb_tags WHERE kb_id = ?', [$article_id]);
|
|
|
|
$tags_id = $f3->get('POST.tags');
|
|
if(!empty($tags_id) && is_array($tags_id)){
|
|
foreach($tags_id as $tag_id){
|
|
$db->exec(
|
|
'INSERT IGNORE INTO kb_tags (article_id, tag_id) VALUES (?,?)',
|
|
[$article_id, $tag_id]
|
|
);
|
|
}
|
|
}
|
|
|
|
$f3->reroute('/kb/'.$article_id);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
--- End File: app/controllers/KBController.php ---
|
|
|
|
|
|
--- File: app/controllers/ParsedownPreview.php ---
|
|
<?php
|
|
|
|
class ParsedownPreview {
|
|
|
|
public function view($f3){
|
|
|
|
$preview_text = $f3->get('POST.content');
|
|
echo Parsedown::instance()->text($preview_text);
|
|
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/ParsedownPreview.php ---
|
|
|
|
|
|
--- File: app/controllers/ProjectController.php ---
|
|
<?php
|
|
|
|
class ProjectController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list all projects
|
|
public function index($f3){
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
// retrieve projects
|
|
$projects = $db->exec('SELECT * FROM projects ORDER BY created_at DESC');
|
|
|
|
$f3->set('projects', $projects);
|
|
|
|
|
|
$f3->set('content', '../ui/views/project/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
$f3->clear('SESSION.error');
|
|
}
|
|
|
|
// create a new project
|
|
public function createForm($f3){
|
|
$this->check_access($f3);
|
|
$f3->set('content', '../ui/views/project/create.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
}
|
|
|
|
public function create($f3){
|
|
|
|
}
|
|
|
|
// show project details including links, tickets, events, tasks
|
|
public function view($f3){
|
|
$this->check_access($f3);
|
|
|
|
$project_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$result = $db->exec(
|
|
'SELECT * FROM projects WHERE id = ? LIMIT 1', [$project_id]
|
|
);
|
|
$project = $result[0];
|
|
$f3->set('project', $project);
|
|
|
|
$f3->set('content', '../ui/views/project/view.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
// update project details
|
|
public function editForm($f3){
|
|
|
|
$this->check_access($f3);
|
|
$f3->set('content', '../ui/views/project/edit.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
}
|
|
|
|
public function update($f3){}
|
|
|
|
}
|
|
--- End File: app/controllers/ProjectController.php ---
|
|
|
|
|
|
--- File: app/controllers/TagController.php ---
|
|
<?php
|
|
|
|
class TagController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
/**
|
|
* List all tags
|
|
*/
|
|
public function index($f3){
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$tags = $db->exec('SELECT * FROM tags ORDER BY name ASC');
|
|
$f3->set('tags', $tags);
|
|
|
|
$f3->set('content', '../ui/views/tag/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function createForm($f3){
|
|
$this->check_access($f3);
|
|
|
|
$f3->set('content', '../ui/views/tag/create.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function create($f3){
|
|
$this->check_access($f3);
|
|
|
|
$name = $f3->get('POST.name');
|
|
$color = $f3->get('POST.color');
|
|
$db = $f3->get('DB');
|
|
|
|
// insert new tag
|
|
$db->exec('INSERT IGNORE INTO tags (name, color) VALUES (?, ?)', [$name, $color]);
|
|
$f3->reroute('/tags');
|
|
}
|
|
|
|
public function view($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function editForm($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function update($f3)
|
|
{
|
|
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/TagController.php ---
|
|
|
|
|
|
--- File: app/controllers/ThemeController.php ---
|
|
<?php
|
|
|
|
class ThemeController
|
|
{
|
|
function toggle($f3)
|
|
{
|
|
$current = $f3->get('SESSION.theme') ?: 'light';
|
|
$new_theme = ($current === 'light') ? 'dark' : 'light';
|
|
$f3->set('SESSION.theme', $new_theme);
|
|
|
|
$f3->reroute($f3->get('HEADERS.Referer') ?: '/');
|
|
}
|
|
}
|
|
--- End File: app/controllers/ThemeController.php ---
|
|
|
|
|
|
--- File: app/controllers/TicketController.php ---
|
|
<?php
|
|
|
|
class TicketController extends BaseController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list all tickts
|
|
public function index($f3){
|
|
|
|
$this->requireLogin();
|
|
|
|
$filter = $f3->get('GET.status');
|
|
|
|
// retrieve tickets
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
|
|
if($filter){
|
|
$tickets = $ticket_mapper->findFiltered($filter);
|
|
} else {
|
|
$tickets = $ticket_mapper->findAll();
|
|
}
|
|
|
|
// render
|
|
$this->renderView('../ui/views/ticket/index.html',
|
|
['tickets' => $tickets]
|
|
);
|
|
|
|
$f3->clear('SESSION.error');
|
|
}
|
|
|
|
// view a single ticket
|
|
// TODO_PROJECTS: show a link back to the related project
|
|
public function view($f3){
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = $f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
// render
|
|
$this->renderView('../ui/views/ticket/view.html', [
|
|
'ticket' => $ticket,
|
|
'attachments' => $ticket->attachments(),
|
|
'comments' => $ticket->comments(),
|
|
'parent_tickets' => $ticket->getParentTickets(),
|
|
'child_tickets' => $ticket->getChildTickets(),
|
|
'ticket_meta' => $ticket->getMetaAssoc()
|
|
]);
|
|
|
|
}
|
|
|
|
// show create form
|
|
// TODO_PROJECTS: dropdown to associate ticket with project
|
|
public function createForm($f3){
|
|
$db = $this->getDB();
|
|
$priorities = (new TicketPriority($db))->findAll();
|
|
$statuses = (new TicketStatus($db))->findAll();
|
|
|
|
$this->requireLogin();
|
|
$this->renderView('../ui/views/ticket/create.html',[
|
|
'priorities' => $priorities,
|
|
'statuses' => $statuses
|
|
]);
|
|
}
|
|
|
|
// handle POST
|
|
// including custom forms
|
|
public function create($f3){
|
|
|
|
$this->requireLogin();
|
|
|
|
$data = [
|
|
'title' => $this->f3->get('POST.title'),
|
|
'created_at' => $this->f3->get('POST.created_at'),
|
|
'description' => $this->f3->get('POST.description'),
|
|
'priority_id' => $this->f3->get('POST.priority_id'),
|
|
'status_id' => $this->f3->get('POST.status_id'),
|
|
'created_by' => $this->f3->get('SESSION.user.id')
|
|
];
|
|
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$new_ticket_id = $ticket_mapper->createTicket($data);
|
|
|
|
// custom field
|
|
$meta_keys = $this->f3->get('POST.meta_key');
|
|
$meta_values = $this->f3->get('POST.meta_value');
|
|
$meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values);
|
|
$ticket_mapper->setCustomFields($meta_assoc);
|
|
|
|
$this->f3->reroute('/ticket/' . $new_ticket_id);
|
|
}
|
|
|
|
// show edit form
|
|
// including custom forms
|
|
// TODO_PROJECTS: allow reasssigning or removing a project association
|
|
public function editForm($f3)
|
|
{
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = $f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Ticket not found.');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
//
|
|
$f3->set('js', 'markdown_preview.js');
|
|
|
|
// dropdowns
|
|
$priorities = (new TicketPriority($this->getDB()))->findAll();
|
|
$statuses = (new TicketStatus($this->getDB()))->findAll();
|
|
|
|
$this->renderView('../ui/views/ticket/edit.html',[
|
|
'ticket' => $ticket,
|
|
'ticket_meta' => $ticket->getMeta(),
|
|
'priorities' => $priorities,
|
|
'statuses' => $statuses
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
|
|
// process edit POST TODO: if assigned or admin
|
|
public function update($f3)
|
|
{
|
|
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = $this->f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Ticket not found.');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
$data = [
|
|
'title' => $this->f3->get('POST.title'),
|
|
'created_at' => $this->f3->get('POST.created_at'),
|
|
'description' => $this->f3->get('POST.description'),
|
|
'priority_id' => $this->f3->get('POST.priority_id'),
|
|
'status_id' => $this->f3->get('POST.status_id'),
|
|
'updated_by' => $this->f3->get('SESSION.user.id')
|
|
];
|
|
$ticket->updateTicket($data);
|
|
|
|
// deal with meta data / custom fields
|
|
$meta_keys = $this->f3->get('POST.meta_key');
|
|
$meta_values = $this->f3->get('POST.meta_value');
|
|
$meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values);
|
|
$ticket->setCustomFields($meta_assoc);
|
|
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
// subtask
|
|
public function addSubtask($f3){
|
|
$this->requireLogin();
|
|
|
|
$parent_id = (int) $f3->get('PARAMS.id');
|
|
$child_id = (int) $f3->get('POST.child_ticket_id');
|
|
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($parent_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Parent Ticket not found');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
$ticket->addChildTicket($child_id);
|
|
$this->f3->reroute('/ticket/' . $parent_id);
|
|
}
|
|
|
|
public function delete(): void
|
|
{
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = (int)$this->f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Ticket not found');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
$ticket->softDelete();
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/TicketController.php ---
|
|
|
|
|
|
--- File: app/controllers/UserController.php ---
|
|
<?php
|
|
|
|
class UserController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list all users (admin only)
|
|
|
|
public function index($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$users = $db->exec(
|
|
'SELECT u.*, r.role AS role_name
|
|
FROM users u
|
|
LEFT JOIN roles r ON r.id = u.role
|
|
ORDER BY id ASC'
|
|
);
|
|
$f3->set('users', $users);
|
|
|
|
$f3->set('content', '../ui/views/user/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function editForm($f3){
|
|
$this->check_access($f3);
|
|
|
|
$user_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec(
|
|
'SELECt * FROM users WHERE id = ? LIMIT 1',
|
|
[$user_id]
|
|
);
|
|
if(!$rows){
|
|
$f3->reroute('/users');
|
|
}
|
|
$f3->set('edit_user', $rows[0]);
|
|
$f3->set('content', '../ui/views/user/edit.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function update($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$user_id = (int) $f3->get('PARAMS.id');
|
|
$new_username = $f3->get('POST.username');
|
|
// $new_role = $f3->get('POST.role_name')
|
|
$db = $f3->get('DB');
|
|
$db->exec(
|
|
'UPDATE users SET username = ? WHERE id =? LIMIT 1',
|
|
[$new_username, $user_id]);
|
|
$f3->reroute('/users');
|
|
}
|
|
|
|
public function createForm($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function create($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function view($f3)
|
|
{
|
|
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/UserController.php ---
|
|
|
|
|
|
--- File: app/controllers/Admin/HomeController.php ---
|
|
<?php
|
|
|
|
namespace Admin;
|
|
|
|
class HomeController extends \BaseController
|
|
{
|
|
public function index($f3)
|
|
{
|
|
$this->renderView('/ui/views/admin/index.html');
|
|
}
|
|
}
|
|
--- End File: app/controllers/Admin/HomeController.php ---
|
|
|
|
|
|
--- File: app/controllers/Admin/TicketOptionsController.php ---
|
|
<?php
|
|
|
|
namespace Admin;
|
|
|
|
class TicketOptionsController extends \BaseController
|
|
{
|
|
public function listPriorities()
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin(); // Added admin check
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priorities = $model->findAll();
|
|
|
|
$this->renderView('/ui/views/admin/priorities/index.html', [
|
|
'priorities' => $priorities
|
|
]);
|
|
}
|
|
|
|
public function createPriorityForm()
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin(); // Added admin check
|
|
$this->renderView('/ui/views/admin/priorities/create.html');
|
|
}
|
|
|
|
public function createPriority()
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin(); // Added admin check
|
|
$p = new \TicketPriority($this->getDB());
|
|
$p->name = $this->f3->get('POST.name');
|
|
$p->sort_order = $this->f3->get('POST.sort_order');
|
|
$p->save();
|
|
|
|
// Redirect after save
|
|
$this->f3->reroute('/admin/priorities');
|
|
}
|
|
|
|
public function editPriorityForm($f3, $params)
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin();
|
|
$priorityId = $params['id'];
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priority = $model->load(['id = ?', $priorityId]);
|
|
|
|
if (!$priority) {
|
|
$f3->error(404, 'Priority not found');
|
|
return;
|
|
}
|
|
|
|
$this->renderView('/ui/views/admin/priorities/edit.html', [
|
|
'priority' => $priority
|
|
]);
|
|
}
|
|
|
|
public function updatePriority($f3, $params)
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin();
|
|
$priorityId = $params['id'];
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priority = $model->load(['id = ?', $priorityId]);
|
|
|
|
if (!$priority) {
|
|
$f3->error(404, 'Priority not found');
|
|
return;
|
|
}
|
|
|
|
$priority->name = $this->f3->get('POST.name');
|
|
$priority->sort_order = $this->f3->get('POST.sort_order');
|
|
$priority->save();
|
|
|
|
// Redirect after update
|
|
$this->f3->reroute('/admin/priorities');
|
|
}
|
|
|
|
public function deletePriority($f3, $params)
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin();
|
|
$priorityId = $params['id'];
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priority = $model->load(['id = ?', $priorityId]);
|
|
|
|
if (!$priority) {
|
|
// Optionally show an error message or just redirect
|
|
$this->f3->reroute('/admin/priorities');
|
|
return;
|
|
}
|
|
|
|
$priority->erase();
|
|
|
|
// Redirect after delete
|
|
$this->f3->reroute('/admin/priorities');
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/Admin/TicketOptionsController.php ---
|
|
|
|
|
|
--- File: app/controllers/Admin/UserController.php ---
|
|
<?php
|
|
|
|
namespace Admin;
|
|
|
|
class UserController extends \BaseController implements \CRUD
|
|
{
|
|
public function index($f3)
|
|
{
|
|
// TODO: Implement index() method.
|
|
}
|
|
|
|
public function createForm($f3)
|
|
{
|
|
// TODO: Implement createForm() method.
|
|
}
|
|
|
|
public function create($f3)
|
|
{
|
|
// TODO: Implement create() method.
|
|
}
|
|
|
|
public function view($id)
|
|
{
|
|
// TODO: Implement view() method.
|
|
}
|
|
|
|
public function editForm($id)
|
|
{
|
|
// TODO: Implement editForm() method.
|
|
}
|
|
|
|
public function update($id)
|
|
{
|
|
// TODO: Implement update() method.
|
|
}
|
|
|
|
|
|
|
|
}
|
|
--- End File: app/controllers/Admin/UserController.php ---
|
|
|
|
|
|
--- File: app/extensions/BulmaFormHelper.php ---
|
|
<?php
|
|
|
|
class BulmaFormHelper extends \Prefab {
|
|
|
|
const H_FIELD_INPUT = 1;
|
|
const H_FIELD_TEXTAREA = 2;
|
|
const H_FIELD_SELECT = 3;
|
|
const H_FIELD_SELECT_NEW = 4;
|
|
|
|
const FIELD_INPUT = 10;
|
|
const FIELD_TEXTAREA = 11;
|
|
const FIELD_SELECT = 13;
|
|
|
|
static public function render($node) {
|
|
|
|
$attr = $node['@attrib'] ?? [];
|
|
$type = strtoupper($attr['type']) ?? null;
|
|
|
|
// all *
|
|
$label = $attr['label'] ?? '';
|
|
$name = $attr['name'] ?? '';
|
|
$value = $attr['value'] ?? '';
|
|
$class = $attr['class'] ?? '';
|
|
// select
|
|
$options = $attr['options'] ?? [];
|
|
$selected = $attr['selected'] ?? [];
|
|
// textarea
|
|
$rows = $attr['rows'] ?? '';
|
|
|
|
$label = \Template::instance()->build($label);
|
|
$name = \Template::instance()->build($name);
|
|
$value = \Template::instance()->build($value);
|
|
$selected = \Template::instance()->build($selected);
|
|
|
|
if(defined("BulmaFormHelper::$type")){
|
|
|
|
$type_const = constant("BulmaFormHelper::$type");
|
|
|
|
switch( $type_const ){
|
|
case BulmaFormHelper::H_FIELD_INPUT:
|
|
return BulmaFormHelper::build_h_field_input($label, $name, $value);
|
|
break;
|
|
case BulmaFormHelper::H_FIELD_TEXTAREA:
|
|
return BulmaFormHelper::build_h_field_textarea($label, $name, $value);
|
|
break;
|
|
case BulmaFormHelper::H_FIELD_SELECT:
|
|
return BulmaFormHelper::build_h_field_select($label, $name, $options, $selected);
|
|
break;
|
|
case BulmaFormHelper::H_FIELD_SELECT_NEW:
|
|
return BulmaFormHelper::build_h_field_select_new($attr);
|
|
break;
|
|
case BulmaFormHelper::FIELD_INPUT:
|
|
return BulmaFormHelper::build_field_input($label, $name, $value, $class);
|
|
break;
|
|
case BulmaFormHelper::FIELD_TEXTAREA:
|
|
return BulmaFormHelper::build_field_textarea($label, $name, $value, $class, $rows);
|
|
break;
|
|
case BulmaFormHelper::FIELD_SELECT:
|
|
return BulmaFormHelper::build_field_select($attr);
|
|
break;
|
|
default:
|
|
return '<div class="notification is-danger">Error: Bulma CSS Form TYPE ('.$type.') not defined.</div>';
|
|
break;
|
|
}
|
|
|
|
} else {
|
|
return '<div class="notification is-danger">Error: Bulma CSS Form TYPE not defined.</div>';
|
|
}
|
|
|
|
}
|
|
|
|
static function build_field_input($label, $name, $value, $class, $rows=10){
|
|
|
|
$string_label = $label !== '' ? sprintf('<label class="label">%1$s</label>', $label) : '';
|
|
$string = '
|
|
<div class="field %4$s">
|
|
%1$s
|
|
<div class="control">
|
|
<input class="input" id="%2$s" name="%2$s" type="text" placeholder="" value="%3$s">
|
|
</div>
|
|
</div>
|
|
';
|
|
return sprintf($string, $string_label, $name, $value, $class, $rows);
|
|
}
|
|
|
|
static function build_field_textarea($label, $name, $value, $class, $rows=10)
|
|
{
|
|
$string_label = $label !== '' ? sprintf('<label class="label">%1$s</label>', $label) : '';
|
|
$string = '
|
|
<div class="field %4$s">
|
|
%1$s
|
|
<div class="control">
|
|
<textarea class="textarea" id="%2$s" name="%2$s" rows="%5$s">%3$s</textarea>
|
|
</div>
|
|
</div>
|
|
';
|
|
return sprintf($string, $string_label, $name, $value, $class,$rows);
|
|
}
|
|
|
|
static function build_h_field_textarea($label, $name, $value){
|
|
$string = '
|
|
<div class="field is-horizontal">
|
|
<div class="field-label is-normal">
|
|
<label class="label">'.$label.'</label>
|
|
</div>
|
|
<div class="field-body">
|
|
<div class="field">
|
|
<div class="control">
|
|
<textarea class="textarea" id="'.$name.'" name="'.
|
|
$name.'">'.$value.'</textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
return $string;
|
|
}
|
|
|
|
static function build_h_field_input($label, $name, $value){
|
|
$string = '
|
|
<div class="field is-horizontal">
|
|
<div class="field-label is-normal">
|
|
<label class="label">'.$label.'</label>
|
|
</div>
|
|
<div class="field-body">
|
|
<div class="field">
|
|
<div class="control">
|
|
<input class="input" type="text" id="'.$name.'" name="'.
|
|
$name.'" value="'.
|
|
$value.'">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
return $string;
|
|
}
|
|
|
|
|
|
/**
|
|
* build_field_select_new
|
|
*
|
|
* `<bulma type="H_FIELD_SELECT" label="Priority:" name="priority_id"
|
|
* options="priorities" option_value="id" option_name="name"
|
|
* selected="{{@ticket.priority_id}}"></bulma>`
|
|
*
|
|
* @param mixed $attr
|
|
* @return void
|
|
*/
|
|
static function build_field_select($attr)
|
|
{
|
|
$f3 = \Base::instance();
|
|
|
|
$class = $attr['class'] ?? '';
|
|
$label = $attr['label'] ?? '';
|
|
$name = $attr['name'] ?? '';
|
|
// $options_arr = $attr['options'] ?? [];
|
|
$option_value = $attr['option_value'] ?? 'id';
|
|
$option_name = $attr['option_name'] ?? 'name';
|
|
|
|
$options = \Template::instance()->token($attr['options']);
|
|
$selected = \Template::instance()->token($attr['selected']);
|
|
|
|
// TODO: label - this could be moved into a seperate function
|
|
$html_label = $label !== '' ? sprintf('<label class="label">%1$s</label>', $label) : '';
|
|
|
|
$tmp_options = '<?php echo \BulmaFormHelper::instance()->field_select('.
|
|
$options.', '.$selected.', "'.$option_value.'", "'.$option_name.'"); ?>';
|
|
|
|
$html = '
|
|
<div class="field %4$s">
|
|
%1$s
|
|
<div class="control">
|
|
<div class="select">
|
|
<select name="%3$s" id="%3$s">
|
|
%2$s
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
|
|
return sprintf($html, $html_label, $tmp_options, $name, $class);
|
|
}
|
|
|
|
function field_select($options, $selected, $option_value, $option_name){
|
|
$html_options = '';
|
|
foreach ($options as $option) {
|
|
$value = $option[$option_value] ?? '';
|
|
$text = $option[$option_name] ?? '';
|
|
$html_selected = ((string)$value === (string)$selected) ? ' selected="selected"' : '';
|
|
$html_option = '<option value="%s"%s>%s</option>';
|
|
$html_options .= sprintf($html_option, $value, $html_selected, $text);
|
|
}
|
|
echo $html_options;
|
|
}
|
|
|
|
static function build_h_field_select_new($attr)
|
|
{
|
|
$f3 = \Base::instance();
|
|
|
|
$label = $attr['label'] ?? '';
|
|
$name = $attr['name'] ?? '';
|
|
$options_arr = $attr['options'] ?? [];
|
|
$optionValue = $attr['option_value'] ?? 'id';
|
|
$optionName = $attr['option_name'] ?? 'name';
|
|
$selected = $attr['selected'] ?? '';
|
|
|
|
$options = $f3->get($options_arr);
|
|
|
|
$html = '<div class="field is-horizontal"><div class="field-label is-normal">';
|
|
if (!empty($label)) {
|
|
$html .= '<label class="label">'.$label.'</label>';
|
|
}
|
|
$html .= '</div><div class="field-body"><div class="field">';
|
|
$html .= '<div class="select">';
|
|
$html .= '<select name="'.$name.'">';
|
|
|
|
foreach ($options as $option) {
|
|
$value = $option[$optionValue] ?? '';
|
|
$text = $option[$optionName] ?? '';
|
|
$sel = ((string)$value === (string)$selected) ? ' selected="selected"' : '';
|
|
$html .= '<option value="'.$value.'"'.$sel.'>'.$text.'</option>';
|
|
}
|
|
|
|
$html .= '</select>';
|
|
$html .= '</div></div></div></div>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
|
|
|
|
static function build_h_field_select($label, $name, $options, $selected){
|
|
$opts = json_decode(str_replace("'", '"', $options));
|
|
$opts_string = "";
|
|
foreach($opts as $k => $v){
|
|
if($v == $selected){
|
|
$selected_str = " selected";
|
|
} else {
|
|
$selected_str = "";
|
|
}
|
|
$opts_string .= '<option'.$selected_str.'>'.$v.'</option>';
|
|
}
|
|
|
|
$string =
|
|
'<div class="field is-horizontal">
|
|
<div class="field-label is-normal">
|
|
<label class="label">'.$label.'</label>
|
|
</div>
|
|
<div class="field-body">
|
|
<div class="field">
|
|
<div class="select">
|
|
<select id="'.$name.'" name="'.$name.'">
|
|
'.$opts_string.'
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
return $string;
|
|
}
|
|
|
|
}
|
|
|
|
\Template::instance()->extend('bulma', 'BulmaFormHelper::render');
|
|
--- End File: app/extensions/BulmaFormHelper.php ---
|
|
|
|
|
|
--- File: app/extensions/IconsHelper.php ---
|
|
<?php
|
|
|
|
class IconsHelper extends \Prefab {
|
|
|
|
static public $status_icons = [
|
|
'open' => ['fas fa-circle-dot has-text-success', "new"],
|
|
'in_progress' => ['fas fa-circle-play has-text-link', "reload"],
|
|
'on_hold' => ['fas fa-pause-circle has-text-warning',"pause"],
|
|
'completed' => ['fas fa-check has-text-danger', "check"]
|
|
];
|
|
|
|
static public $status_names = [
|
|
'open' => 'Open',
|
|
'in_progress' => 'In Progress',
|
|
'on_hold' => 'On Hold',
|
|
'completed' => 'Completed'
|
|
];
|
|
|
|
static public $priority_icons = [
|
|
'Low' => ['fas fa-circle-down',"green"],
|
|
'Medium' => ['fas fa-circle-dot', "yellow"],
|
|
'High' => ['fas fa-circle-up', "red"]
|
|
];
|
|
|
|
static public $priority_colors = [
|
|
'Low' => 'success',
|
|
'Medium' => 'warning',
|
|
'High' => 'danger',
|
|
'' => 'info'
|
|
];
|
|
|
|
static public function icons($node){
|
|
|
|
$attr = $node['@attrib'];
|
|
|
|
$tpl = Template::instance();
|
|
$f3 = Base::instance();
|
|
|
|
$context = $f3->hive();
|
|
$inner = $tpl->token($node[0], $context);
|
|
|
|
return '<?php echo IconsHelper::do_the_switch("' . $attr['type'] . '", ' . $inner . '); ?>';
|
|
|
|
}
|
|
|
|
static function do_the_switch($type, $value){
|
|
|
|
if($value !== null) {
|
|
$value = str_replace(' ', '_', strtolower($value));
|
|
}
|
|
$icon_class = '';
|
|
switch(strtolower($type)){
|
|
case 'status':
|
|
$icon_class = IconsHelper::$status_icons[$value] ?? ['fas fa-question-circle has-text-info', "🔲"];
|
|
break;
|
|
case 'priority':
|
|
$icon_class = IconsHelper::$priority_icons[$value] ?? ['fas fa-question-circle', "🔲"];
|
|
$icon_color = IconsHelper::$priority_colors[$value] ?? 'info';
|
|
break;
|
|
default:
|
|
$icon_class = 'fas fa-question-circle';
|
|
}
|
|
|
|
if($type == 'priority'){
|
|
// return '<p class="button"><span class="icon is-small">'
|
|
return '<span class="icon is-medium">
|
|
<i class="'.$icon_class[0].' fa-lg has-text-'.$icon_color.'"></i>
|
|
</span>';
|
|
} else {
|
|
return '<span class="icon is-medium"><i class="'.$icon_class[0].' fa-lg"></i></span>';
|
|
}
|
|
return '<span class="is-size-5">'.$icon_class[1].'</span>';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
\Template::instance()->extend('icons', 'IconsHelper::icons');
|
|
--- End File: app/extensions/IconsHelper.php ---
|
|
|
|
|
|
--- File: app/extensions/ParsedownHelper.php ---
|
|
<?php
|
|
|
|
class ParsedownHelper extends \Prefab {
|
|
|
|
static public function render($args) {
|
|
|
|
if(isset($args['@attrib']) && $args['@attrib']['inline'] === 'true'){
|
|
|
|
$return = \Parsedown::instance()->text($args[0]);
|
|
|
|
return '<!-- tableextension -->
|
|
<div class="content">
|
|
<parsedown_rendered>'.$return.'</parsedown_rendered>
|
|
</div>
|
|
';
|
|
|
|
}
|
|
|
|
// return '<pre>'.print_r($args,1).'</pre>';
|
|
|
|
$content = $args[0];
|
|
$content_token = \Template::instance()->token($content);
|
|
|
|
return '
|
|
<parsedown_rendered class="content">
|
|
<?php echo \ParsedownHelper::instance()->build('.$content_token.'); ?>
|
|
</parsedown_rendered>';
|
|
}
|
|
|
|
function build($content){
|
|
return \ParsedownTableExtension::instance()->text($content);
|
|
}
|
|
}
|
|
|
|
\Template::instance()->extend('parsedown', 'ParsedownHelper::render');
|
|
--- End File: app/extensions/ParsedownHelper.php ---
|
|
|
|
|
|
--- File: app/extensions/ParsedownTableExtension.php ---
|
|
<?php
|
|
|
|
class ParsedownTableExtension extends ParsedownCheckbox
|
|
{
|
|
protected function blockTable($Line, array $Block = null)
|
|
{
|
|
// Let Parsedown do its normal 'start-of-table' parsing.
|
|
$Block = parent::blockTable($Line, $Block);
|
|
|
|
// If this line didn't create or start a table, do nothing.
|
|
if (!isset($Block)) {
|
|
return null;
|
|
}
|
|
|
|
// Flag it so we know in blockTableComplete that this is a table block.
|
|
$Block['isMyTable'] = true;
|
|
|
|
return $Block;
|
|
}
|
|
|
|
protected function blockTableContinue($Line, array $Block)
|
|
{
|
|
// Continue letting Parsedown do its normal table parsing.
|
|
$Block = parent::blockTableContinue($Line, $Block);
|
|
return $Block;
|
|
}
|
|
|
|
protected function blockTableComplete(array $Block)
|
|
{
|
|
// Let Parsedown finalize the table structure.
|
|
// $Block = parent::blockTableComplete($Block);
|
|
// If we flagged this as our table block, wrap it now.
|
|
if (!empty($Block['isMyTable'])) {
|
|
// $Block['element'] should now be fully formed, e.g.:
|
|
// [
|
|
// 'name' => 'table',
|
|
// 'handler' => 'elements',
|
|
// 'text' => [ ... ],
|
|
// 'attributes' => [...],
|
|
// ]
|
|
|
|
// Add your custom class to the <table> itself:
|
|
if (!isset($Block['element']['attributes'])) {
|
|
$Block['element']['attributes'] = [];
|
|
}
|
|
$Block['element']['attributes']['class'] = 'table is-bordered';
|
|
|
|
// Wrap the <table> in a <div class="table-container">:
|
|
$wrapped = [
|
|
'name' => 'div',
|
|
'attributes' => [
|
|
'class' => 'table-container',
|
|
],
|
|
'handler' => 'elements',
|
|
'text' => [
|
|
$Block['element'], // the <table> itself
|
|
],
|
|
];
|
|
|
|
// Replace the original element with our wrapped version:
|
|
$Block['element'] = $wrapped;
|
|
}
|
|
|
|
return $Block;
|
|
}
|
|
}
|
|
--- End File: app/extensions/ParsedownTableExtension.php ---
|
|
|
|
|
|
--- File: app/interfaces/CRUD.php ---
|
|
<?php
|
|
|
|
interface CRUD {
|
|
|
|
|
|
// list all
|
|
public function index($f3);
|
|
|
|
// show form
|
|
public function createForm($f3);
|
|
|
|
// handle POST
|
|
public function create($f3);
|
|
|
|
// show single
|
|
public function view($f3);
|
|
|
|
// show edit form
|
|
public function editForm($f3);
|
|
|
|
// handle post
|
|
public function update($f3);
|
|
|
|
|
|
}
|
|
--- End File: app/interfaces/CRUD.php ---
|
|
|
|
|
|
--- File: app/models/Attachment.php ---
|
|
<?php
|
|
|
|
class Attachment extends \DB\SQL\Mapper {
|
|
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'attachments');
|
|
}
|
|
|
|
public function findWithUserByTicketId($ticket_id){
|
|
return $this->db->exec(
|
|
'SELECT a.*, u.username
|
|
FROM attachments a
|
|
LEFT JOIN users u ON u.id = a.uploaded_by
|
|
WHERE a.ticket_id = ?
|
|
ORDER BY a.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
}
|
|
|
|
}
|
|
--- End File: app/models/Attachment.php ---
|
|
|
|
|
|
--- File: app/models/Comment.php ---
|
|
<?php
|
|
|
|
class Comment extends \DB\SQL\Mapper {
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'ticket_comments');
|
|
}
|
|
|
|
public function findWithUserByTicketId($ticket_id){
|
|
return $this->db->exec(
|
|
'SELECT c.*, u.username AS author_name
|
|
FROM ticket_comments c
|
|
LEFT JOIN users u ON c.created_by = u.id
|
|
WHERE c.ticket_id = ?
|
|
ORDER BY c.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
}
|
|
}
|
|
--- End File: app/models/Comment.php ---
|
|
|
|
|
|
--- File: app/models/Tag.php ---
|
|
<?php
|
|
|
|
class Tag extends \DB\SQL\Mapper
|
|
{
|
|
|
|
protected $tag_table, $tag_table_id;
|
|
|
|
function __construct($db, $type = null)
|
|
{
|
|
if($type == null){
|
|
// do tag mapping
|
|
parent::__construct($db, 'tags');
|
|
} else {
|
|
$this->tag_table = $type . '_tags';
|
|
$this->tag_table_id = $type . '_id';
|
|
parent::__construct($db, $this->tag_table);
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
// VERIFY: possible issue with this?
|
|
public function getTagsFor($objects, $id_key = 'id')
|
|
{
|
|
// echo $this->get('_type_id'); exit;
|
|
// printf('<pre>%s</pre>', print_r($this,1)); exit;
|
|
if(empty($objects)) return [];
|
|
$ids = array_column($objects, $id_key);
|
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
$sql =
|
|
'SELECT tt.%1$s, t.id, t.name, t.color
|
|
FROM %2$s tt
|
|
INNER JOIN tags t ON tt.tag_id = t.id
|
|
WHERE tt.%1$s IN (%3$s)';
|
|
$sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table, $placeholders);
|
|
$rows = $this->db->exec($sql_sprintf, $ids);
|
|
|
|
$tags_map = [];
|
|
foreach($rows as $row)
|
|
{
|
|
$tags_map[$row[$this->tag_table_id]][] = $row;
|
|
}
|
|
|
|
foreach($objects as &$object)
|
|
{
|
|
$object['tags'] = $tags_map[$object[$id_key]] ?? [];
|
|
}
|
|
return $objects;
|
|
|
|
}
|
|
|
|
public function getTagsForID($id, $id_key = 'id')
|
|
{
|
|
$sql = 'SELECT tt.%1$s, t.id, t.name, t.color
|
|
FROM %2$s tt
|
|
INNER JOIN tags t ON tt.tag_id = t.id
|
|
WHERE tt.%1$s = ?';
|
|
$sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table);
|
|
$rows = $this->db->exec($sql_sprintf, $id);
|
|
return $rows;
|
|
}
|
|
|
|
public function findLinkedTags($id = '')
|
|
{
|
|
$sql = '
|
|
SELECT t.name, t.color
|
|
FROM `?` tt
|
|
LEFT JOIN `tags` t ON t.id = tt.id
|
|
WHERE tt.`?` = ?
|
|
';
|
|
$params = [
|
|
$this->_type,
|
|
$this->_type_id,
|
|
$id
|
|
];
|
|
return $this->db->exec($sql, $params);
|
|
}
|
|
|
|
|
|
}
|
|
--- End File: app/models/Tag.php ---
|
|
|
|
|
|
--- File: app/models/Ticket.php ---
|
|
<?php
|
|
|
|
class Ticket extends \DB\SQL\Mapper {
|
|
function __construct($db){
|
|
parent::__construct($db, 'tickets');
|
|
|
|
}
|
|
|
|
/**
|
|
* Return all tickets in descending order of creation
|
|
*/
|
|
public function findAll(): array
|
|
{
|
|
$tickets = $this->db->exec(
|
|
'SELECT t.id, t.title, t.created_at,
|
|
tp.name AS priority_name, ts.name AS status_name, u.display_name
|
|
FROM tickets t
|
|
LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id
|
|
LEFT JOIN ticket_statuses ts ON t.status_id = ts.id
|
|
LEFT JOIN users u ON t.created_by = u.id
|
|
WHERE t.recycled = 0
|
|
ORDER BY t.created_at DESC'
|
|
);
|
|
$result = $this->getTagsForTickets($tickets);
|
|
return $result;
|
|
}
|
|
|
|
public function findFiltered(string $filter): array
|
|
{
|
|
$sql = '
|
|
SELECT t.*, tp.name AS priority_name, ts.name AS status_name, u.display_name
|
|
FROM tickets t
|
|
LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id
|
|
LEFT JOIN ticket_statuses ts ON t.status_id = ts.id
|
|
LEFT JOIN users u ON t.created_by = u.id
|
|
WHERE t.recycled = 0
|
|
';
|
|
$params = [];
|
|
switch($filter){
|
|
case 'open':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 1;
|
|
break;
|
|
case 'in_progress':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 2;
|
|
break;
|
|
case 'on_hold':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 3;
|
|
break;
|
|
case 'completed':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 4;
|
|
break;
|
|
}
|
|
|
|
$sql .= ' ORDER BY t.created_at DESC';
|
|
$tickets = $this->db->exec($sql, $params);
|
|
$result = $this->getTagsForTickets($tickets);
|
|
return $result;
|
|
}
|
|
|
|
public function getTagsForTickets(array $tickets)
|
|
{
|
|
$tag_mapper = new Tag($this->db, 'ticket');
|
|
$tickets = $tag_mapper->getTagsFor($tickets);
|
|
|
|
return $tickets;
|
|
}
|
|
|
|
public function findById($id): ?Ticket
|
|
{
|
|
$this->status_name = 'SELECT name FROM ticket_statuses WHERE tickets.status_id = ticket_statuses.id';
|
|
$this->priority_name = 'SELECT name FROM ticket_priorities WHERE tickets.priority_id = ticket_priorities.id';
|
|
$this->load(['id = ?', $id]);
|
|
$this->tags = (new Tag($this->db,'ticket'))->getTagsForID($id, 'ticket_id');
|
|
return $this->dry() ? null : $this;
|
|
}
|
|
|
|
public function createTicket(array $data): int
|
|
{
|
|
$this->reset();
|
|
|
|
$this->title = $data['title'] ?? '';
|
|
$this->description = $data['description'] ?? '';
|
|
//
|
|
$this->priority_id = $data['priority_id'] ?? null;
|
|
$this->status_id = $data['status_id'] ?? null;
|
|
//
|
|
$this->created_by = $data['created_by'] ?? null;
|
|
$this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s');
|
|
$this->updated_at = date('Y-m-d H:i:s');
|
|
|
|
$this->save();
|
|
return (int)$this->id;
|
|
}
|
|
|
|
public function updateTicket(array $data): void
|
|
{
|
|
if(isset($data['title'])){ $this->title = $data['title']; }
|
|
if(isset($data['description'])) { $this->description = $data['description']; }
|
|
if(isset($data['priority_id'])) { $this->priority_id = $data['priority_id']; }
|
|
if(isset($data['status_id'])) { $this->status_id = $data['status_id']; }
|
|
if(isset($data['updated_by'])) { $this->updated_by = $data['updated_by']; }
|
|
$this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s');
|
|
$this->updated_at = date('Y-m-d H:i:s');
|
|
$this->save();
|
|
}
|
|
|
|
public function softDelete():void {
|
|
$this->recycled = 1;
|
|
$this->save();
|
|
}
|
|
|
|
public function attachments(){
|
|
$attachment = new Attachment($this->db);
|
|
return $attachment->findWithUserByTicketId($this->id);
|
|
}
|
|
|
|
public function comments(){
|
|
$comment = new Comment($this->db);
|
|
return $comment->findWithUserByTicketId($this->id);
|
|
}
|
|
|
|
public function getParentTickets()
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT p.*
|
|
FROM ticket_relations r
|
|
INNER JOIN tickets p ON r.parent_ticket_id = p.id
|
|
WHERE r.child_ticket_id = ?',
|
|
[$this->id]
|
|
);
|
|
}
|
|
|
|
public function getChildTickets()
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT c.*
|
|
FROM ticket_relations r
|
|
INNER JOIN tickets c ON r.child_ticket_id = c.id
|
|
WHERE r.parent_ticket_id = ?',
|
|
[$this->id]
|
|
);
|
|
}
|
|
|
|
public function addChildTicket(int $childId)
|
|
{
|
|
$this->db->exec(
|
|
'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id)
|
|
VALUES (?, ?)',
|
|
[$this->id, $childId]
|
|
);
|
|
}
|
|
|
|
// meta data
|
|
public function getMeta()
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT id, meta_key, meta_value
|
|
FROM ticket_meta
|
|
WHERE ticket_id = ?',
|
|
[$this->id]
|
|
);
|
|
}
|
|
|
|
public function getMetaAssoc()
|
|
{
|
|
$rows = $this->getMeta();
|
|
$assoc = [];
|
|
foreach($rows as $row){
|
|
$assoc[$row['meta_key']] = $row['meta_value'];
|
|
}
|
|
return $assoc;
|
|
}
|
|
|
|
public function assocExistingMeta($meta_ids, $meta_keys, $meta_values){
|
|
if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){
|
|
$field_assoc = [];
|
|
foreach($meta_ids as $i => $m_id){
|
|
$key = $meta_keys[$i] ?? '';
|
|
$value = $meta_values[$i] ?? '';
|
|
if(!empty($key) && $value !== ''){
|
|
$field_assoc[$key] = $value;
|
|
}
|
|
}
|
|
return $field_assoc;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
public function assocMetaFromKeyValue($meta_keys, $meta_values)
|
|
{
|
|
if(is_array($meta_keys) && is_array($meta_values)){
|
|
$field_assoc = [];
|
|
foreach($meta_keys as $i => $key){
|
|
$val = $meta_values[$i] ?? '';
|
|
if(!empty($key) && $val != ''){
|
|
$field_assoc[$key] = $val;
|
|
}
|
|
}
|
|
return $field_assoc;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
public function setCustomFields(array $fields)
|
|
{
|
|
$this->db->exec(
|
|
'DELETE FROM ticket_meta WHERE ticket_id = ?', [$this->id]
|
|
);
|
|
|
|
foreach($fields as $key => $value){
|
|
$this->db->exec(
|
|
'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value)
|
|
VALUES (?, ?, ?)',
|
|
[$this->id, $key, $value]
|
|
);
|
|
}
|
|
}
|
|
|
|
}
|
|
--- End File: app/models/Ticket.php ---
|
|
|
|
|
|
--- File: app/models/TicketPriority.php ---
|
|
<?php
|
|
|
|
class TicketPriority extends \DB\SQL\Mapper
|
|
{
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'ticket_priorities');
|
|
}
|
|
|
|
public function findAll(): array
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT * FROM ticket_priorities ORDER BY sort_order ASC'
|
|
);
|
|
}
|
|
}
|
|
--- End File: app/models/TicketPriority.php ---
|
|
|
|
|
|
--- File: app/models/TicketStatus.php ---
|
|
<?php
|
|
|
|
class TicketStatus extends \DB\SQL\Mapper
|
|
{
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'ticket_statuses');
|
|
}
|
|
|
|
public function findAll(): array
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT * FROM ticket_statuses ORDER BY sort_order ASC'
|
|
);
|
|
}
|
|
}
|
|
--- End File: app/models/TicketStatus.php ---
|
|
|
|
|
|
--- File: app/traits/RequiresAuth.php ---
|
|
<?php
|
|
|
|
trait RequiresAuth {
|
|
|
|
public function check_access($f3){
|
|
if(!$f3->exists('SESSION.user')){
|
|
// $f3->set('SESSION.error', 'You don\'t have permission for this ticket.');
|
|
$f3->set('SESSION.redirect', $f3->get('PATH'));
|
|
$f3->reroute('/login');
|
|
}
|
|
}
|
|
|
|
}
|
|
--- End File: app/traits/RequiresAuth.php ---
|
|
|
|
|
|
--- File: public/index.php ---
|
|
<?php
|
|
|
|
require '../lib/autoload.php';
|
|
|
|
$f3 = \Base::instance();
|
|
$f3->config('../app/config/.env.cfg');
|
|
$f3->set('DEBUG', 3); // development debug
|
|
$f3->set('CACHE', FALSE);
|
|
|
|
/**
|
|
* Not required yet
|
|
*/
|
|
$htmlpurifier = \HTMLPurifier::instance();
|
|
// $htmlpurifier->purify($input);
|
|
$md = \ParsedownTableExtension::instance();
|
|
$md->setSafeMode(true);
|
|
|
|
$f3->set('EXT', [new ParsedownHelper, new BulmaFormHelper, new IconsHelper]);
|
|
|
|
$f3->set('DB', new \DB\SQL(
|
|
'mysql:host=localhost;port=3306;dbname=' . $f3->get('database.db_name'),
|
|
$f3->get('database.username'),
|
|
$f3->get('database.password')
|
|
));
|
|
|
|
new \DB\SQL\Session($f3->get('DB'));
|
|
$f3->set('SESSION.status', 'running');
|
|
|
|
$f3->run();
|
|
--- End File: public/index.php ---
|
|
|
|
|
|
--- File: public/logo.svg ---
|
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="300.000000pt" height="300.000000pt" viewBox="0 0 300.000000 300.000000" preserveAspectRatio="xMidYMid meet"> <g transform="translate(0.000000,300.000000) scale(0.100000,-0.100000)" fill="#03045e" stroke="none"> <path d="M465 2639 c-88 -12 -236 -51 -312 -83 l-58 -24 0 -1054 c0 -880 2 -1053 14 -1050 177 37 258 45 461 46 187 1 230 -2 328 -22 145 -29 257 -69 414 -148 70 -35 130 -64 133 -64 3 0 5 470 5 1044 l0 1044 -26 31 c-14 17 -55 50 -92 74 -256 170 -569 244 -867 206z m397 -313 c85 -17 147 -37 241 -79 l67 -30 0 -318 c0 -176 -2 -319 -5 -319 -4 0 -46 14 -95 31 l-88 31 -4 97 c-3 90 -5 101 -32 135 -16 22 -50 48 -80 62 -33 16 -43 24 -29 24 12 0 37 15 56 33 46 43 56 98 29 161 -26 59 -66 80 -139 74 -77 -7 -114 -45 -121 -122 -6 -68 21 -119 74 -138 l34 -12 -51 -24 c-76 -36 -103 -84 -108 -189 -3 -82 -3 -83 -30 -84 -14 0 -65 -10 -114 -23 -48 -12 -90 -20 -92 -17 -3 2 -5 156 -5 342 l0 339 23 5 c148 36 343 45 469 21z m26 -990 c99 -22 209 -59 285 -97 34 -17 64 -33 66 -35 1 -1 -7 -20 -19 -42 l-22 -40 -86 40 c-244 113 -543 132 -803 52 -12 -3 -19 6 -27 38 -6 24 -8 46 -4 49 8 8 123 35 202 49 78 13 322 5 408 -14z m-5 -281 c110 -23 321 -100 355 -130 2 -2 -6 -22 -18 -44 l-20 -41 -48 24 c-74 37 -213 83 -302 100 -154 30 -381 17 -536 -29 -18 -6 -23 -1 -32 37 -6 23 -8 46 -4 50 7 7 175 43 242 52 67 8 285 -3 363 -19z m-33 -286 c88 -13 219 -54 313 -96 42 -20 77 -38 77 -41 0 -11 -44 -82 -49 -79 -105 58 -268 111 -397 129 l-94 12 0 41 c0 23 3 44 6 48 6 6 30 3 144 -14z m-334 -23 c3 -19 4 -40 2 -47 -2 -7 -42 -20 -89 -29 -46 -10 -96 -21 -110 -25 -23 -6 -27 -3 -37 33 -7 22 -12 42 -12 45 0 7 62 25 145 43 92 19 93 18 101 -20z"/> <path d="M2180 2634 c-218 -35 -517 -171 -605 -275 l-25 -31 0 -1044 c0 -574 2 -1044 5 -1044 3 0 63 29 133 64 157 79 269 119 414 148 98 20 141 23 328 22 203 -1 284 -9 461 -46 12 -3 14 170 17 1049 l2 1052 -47 21 c-188 83 -472 118 -683 84z m118 -246 l3 -48 -34 0 c-92 0 -268 -47 -393 -105 l-72 -33 -22 40 c-12 22 -20 41 -19 42 9 8 110 55 159 74 81 31 165 53 255 68 118 18 119 18 123 -38z m352 15 c36 -9 68 -19 72 -22 11 -10 -13 -93 -26 -88 -10 4 -81 19 -193 43 -24 5 -29 26 -16 72 5 22 9 23 52 17 25 -4 75 -14 111 -22z m-180 -253 c93 -10 231 -38 250 -50 9 -6 9 -17 -1 -49 -7 -22 -13 -41 -15 -41 -1 0 -42 10 -91 22 -75 19 -114 23 -263 22 -156 0 -186 -3 -272 -27 -53 -14 -136 -44 -184 -66 -48 -23 -88 -41 -90 -41 -2 0 -13 18 -24 39 l-19 39 22 14 c47 31 197 88 279 108 155 37 274 45 408 30z m-15 -280 c94 -8 259 -41 272 -54 3 -2 -1 -23 -8 -46 -11 -38 -15 -41 -38 -36 -14 3 -55 13 -91 21 -103 25 -298 30 -411 11 -100 -16 -239 -61 -321 -102 -26 -13 -50 -24 -52 -24 -3 0 -15 18 -26 40 l-20 40 57 29 c129 64 282 108 428 121 44 4 85 8 90 8 6 1 60 -3 120 -8z m130 -398 l40 -7 3 -340 c1 -187 0 -345 -2 -351 -3 -8 -22 -7 -68 2 -168 36 -398 27 -581 -22 -51 -13 -104 -27 -120 -30 l-27 -7 2 328 3 328 95 34 c103 37 190 60 285 73 65 10 305 4 370 -8z"/> <path d="M2174 1248 c-15 -29 -27 -54 -29 -56 -1 -1 -30 -7 -64 -13 l-63 -11 47 -48 47 -47 -11 -58 c-6 -32 -11 -63 -11 -68 0 -4 25 6 56 24 l57 32 59 -33 c51 -28 59 -30 55 -14 -3 11 -9 41 -13 67 -6 48 -6 49 41 97 l48 48 -49 8 c-77 11 -89 19 -113 73 -12 28 -24 51 -26 51 -3 0 -17 -24 -31 -52z"/> </g> </svg>
|
|
--- End File: public/logo.svg ---
|
|
|
|
|
|
--- File: public/style.css ---
|
|
html, body {padding:0; margin:0;}
|
|
html, body, #sidebar, #page,#base_body {
|
|
min-height: 100%
|
|
}
|
|
|
|
#page { min-height: calc(100vh - 170px - 52px) }
|
|
i.fa { font-weight: 100 !important ; }
|
|
|
|
.table th.th-icon { width: 2rem; }
|
|
|
|
#ticket_list .g-flex-item { border-bottom: 1px solid var(--bulma-text-soft); }
|
|
|
|
a { word-break: break-word; }
|
|
|
|
/* parsedown check-checkbox */
|
|
li.parsedown-task-list {
|
|
list-style: none;
|
|
}
|
|
|
|
/* List Component */
|
|
.list{
|
|
--be-list-color:var(--bulma-text);
|
|
--be-list-item-description-color:var(--bulma-text-50);
|
|
--be-list-item-divider-color:var(--bulma-border);
|
|
--be-list-item-hover-color:var(--bulma-scheme-main-bis);
|
|
--be-list-item-image-margin:.75em;
|
|
--be-list-item-padding:.75em;
|
|
--be-list-item-title-color:var(--bulma-text-strong);
|
|
--be-list-item-title-weight:var(--bulma-weight-semibold);
|
|
color:var(--be-list-color);
|
|
flex-direction:column;
|
|
display:flex
|
|
}
|
|
.list.has-hidden-images .list-item-image{
|
|
display:none
|
|
}
|
|
.list.has-hoverable-list-items .list-item:hover{
|
|
background-color:var(--be-list-item-hover-color)
|
|
}
|
|
.list.has-overflow-ellipsis .list-item-content{
|
|
min-inline-size:0;
|
|
max-inline-size:calc(var(--length)*1ch)
|
|
}
|
|
.list.has-overflow-ellipsis .list-item-content>*{
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
overflow:hidden
|
|
}
|
|
@media (hover:hover){
|
|
.list:not(.has-visible-pointer-controls) .list-item-controls{
|
|
opacity:0;
|
|
visibility:hidden
|
|
}
|
|
}
|
|
.list .list-item{
|
|
align-items:center;
|
|
transition:background-color .125s ease-out;
|
|
display:flex;
|
|
position:relative;
|
|
/* TP: update + align top */
|
|
align-items: flex-start;
|
|
}
|
|
@media (hover:hover){
|
|
.list .list-item:hover .list-item-controls,.list .list-item:focus-within .list-item-controls{
|
|
opacity:initial;
|
|
visibility:initial
|
|
}
|
|
}
|
|
.list .list-item:not(.box){
|
|
padding-block:var(--be-list-item-padding);
|
|
padding-inline:var(--be-list-item-padding)
|
|
}
|
|
.list .list-item:not(:last-child):not(.box){
|
|
border-block-end:1px solid var(--be-list-item-divider-color)
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list:not(.has-overflow-ellipsis) .list .list-item{
|
|
flex-wrap:wrap
|
|
}
|
|
}
|
|
.list .list-item-image{
|
|
flex-shrink:0;
|
|
margin-inline-end:var(--be-list-item-image-margin);
|
|
/* TP: update + add margin-top */
|
|
margin-top: 0.5rem;
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list .list-item-image{
|
|
padding-block:.5rem;
|
|
padding-inline:0
|
|
}
|
|
}
|
|
.list .list-item-content{
|
|
flex-direction:column;
|
|
flex-grow:1;
|
|
display:flex
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list .list-item-content{
|
|
padding-block:.5rem;
|
|
padding-inline:0
|
|
}
|
|
}
|
|
.list .list-item-title{
|
|
color:var(--be-list-item-title-color);
|
|
font-weight:var(--be-list-item-title-weight);
|
|
margin-bottom: .25rem;
|
|
}
|
|
.list .list-item-description{
|
|
color:var(--be-list-item-description-color)
|
|
}
|
|
.list .list-item-controls{
|
|
flex-shrink:0;
|
|
transition:opacity .125s ease-out
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list .list-item-controls{
|
|
flex-wrap:wrap;
|
|
padding-block:.5rem;
|
|
padding-inline:0
|
|
}
|
|
}
|
|
@media screen and (width>=769px),print{
|
|
.list .list-item-controls{
|
|
padding-inline-start:var(--be-list-item-padding)
|
|
}
|
|
.list:not(.has-visible-pointer-controls) .list .list-item-controls{
|
|
block-size:100%;
|
|
align-items:center;
|
|
padding-block-end:var(--be-list-item-padding);
|
|
display:flex;
|
|
position:absolute;
|
|
inset-inline-end:0
|
|
}
|
|
}
|
|
--- End File: public/style.css ---
|
|
|
|
|
|
--- File: public/test.md.php ---
|
|
<?php
|
|
?>
|
|
|
|
<head>
|
|
<script src="js/tp_md_editor.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
tp_md_editor.init({
|
|
groups: [
|
|
['h1', 'h2', 'h3'],
|
|
['bold', 'italic', 'quote'],
|
|
['link', 'code'],
|
|
['bullet', 'number', 'task'],
|
|
['hr', 'table']
|
|
]
|
|
});
|
|
});
|
|
</script>
|
|
<!-- font awesome -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
|
|
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
|
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
|
|
<style>
|
|
html { font-family: sans-serif; }
|
|
tp-md-editor { width: 50%; display: block; padding: .2em; }
|
|
tp-md-toolbar, tp-md-toolbar-group { display: flex; gap: .5em; }
|
|
tp-md-toolbar-group:not(:last-child) { border-right:1px solid #ccc; padding-right: .5em; }
|
|
tp-md-toolbar-button { width: 32px; height: 32px; align-content:center;}
|
|
tp-md-toolbar-button { display:inline-block; text-align:center; border: none; cursor: pointer; }
|
|
tp-md-toolbar-button:hover { color:royalblue; }
|
|
tp-md-editor textarea { border: 1px solid #ccc; padding: 1em; margin-top: 1em; width: 100%; }
|
|
tp-md-editor textarea:focus-visible { outline: 0; }
|
|
|
|
.fa-stack-100 { width: 2em; line-height: 2em; }
|
|
.fa-stack-offset { right: 10%; position: absolute; bottom: 15%; font-size: .5em; }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<h1>MD Testing</h1>
|
|
<tp-md-editor name="description">
|
|
1. MD CONTENT
|
|
2. list item two
|
|
|
|
and something else
|
|
|
|
- and then
|
|
- and then
|
|
- and then
|
|
</tp-md-editor>
|
|
</body>
|
|
--- End File: public/test.md.php ---
|
|
|
|
|
|
--- File: public/js/kb_edit.js ---
|
|
|
|
|
|
// switch to target tab pane
|
|
function switchTab(targetId){
|
|
var panes = document.querySelectorAll('.tab-content .tab-pane');
|
|
for (var i=0; i< panes.length; i++){
|
|
panes[i].style.display = 'none';
|
|
}
|
|
var targetPane = document.getElementById(targetId);
|
|
if(targetPane){
|
|
targetPane.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// send ajax post request with content to specified url
|
|
function ajaxPost(content, url, callback){
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', url, true);
|
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
|
xhr.onreadystatechange = function(){
|
|
if(xhr.readyState === XMLHttpRequest.DONE){
|
|
if(xhr.status === 200){
|
|
callback(xhr.responseText);
|
|
} else {
|
|
console.error("AJAX error: " + xhr.status);
|
|
}
|
|
}
|
|
};
|
|
var params = 'content=' + encodeURIComponent(content);
|
|
xhr.send(params);
|
|
}
|
|
|
|
// load preview via ajax into preview element
|
|
function loadPreview(previewElement){
|
|
var sourceId = previewElement.getAttribute('data-source');
|
|
var handlerUrl = previewElement.getAttribute('data-handler');
|
|
var method = previewElement.getAttribute('data-method');
|
|
var sourceElement = document.getElementById(sourceId);
|
|
if(sourceElement){
|
|
var content = sourceElement.value;
|
|
if(method && method.toLowerCase() == 'post'){
|
|
ajaxPost(content, handlerUrl, function (response){
|
|
previewElement.innerHTML = response;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// initialise tab links to handle tab switching
|
|
function initTabs(){
|
|
var tabLinks = document.querySelectorAll('.tabs a[data-target]');
|
|
for(var i=0; i<tabLinks.length; i++){
|
|
tabLinks[i].addEventListener('click', function(e){
|
|
e.preventDefault();
|
|
var target = this.getAttribute('data-target');
|
|
switchTab(target);
|
|
|
|
// if the new tab has a preview element, load the preview
|
|
var pane = document.getElementById(target);
|
|
if(pane){
|
|
var previewElement = pane.querySelector('.preview');
|
|
if(previewElement){
|
|
console.log('pane has preview el')
|
|
loadPreview(previewElement);
|
|
} else {
|
|
console.log('pane doesnt have preview el');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
initTabs();
|
|
});
|
|
--- End File: public/js/kb_edit.js ---
|
|
|
|
|
|
--- File: public/js/markdown_preview.js ---
|
|
class TabSwitcherController {
|
|
constructor({ tabSelector, contentPrefix, textareaSelector, previewUrl }) {
|
|
this.tabSelector = tabSelector;
|
|
this.contentPrefix = contentPrefix;
|
|
this.textareaSelector = textareaSelector;
|
|
this.previewUrl = previewUrl;
|
|
|
|
this.tabParent = document.querySelector(tabSelector);
|
|
this.tabLinks = this.tabParent.querySelectorAll('a');
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.tabLinks.forEach(link => {
|
|
link.addEventListener('click', (e) => this.handleTabClick(e, link));
|
|
});
|
|
}
|
|
|
|
async handleTabClick(e, link) {
|
|
e.preventDefault();
|
|
const selectedTab = link.getAttribute('data-tab');
|
|
|
|
// Update active tab
|
|
this.tabParent.querySelectorAll('li').forEach(li => li.classList.remove('is-active'));
|
|
link.parentElement.classList.add('is-active');
|
|
|
|
// Show active content
|
|
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
|
|
const activeContent = document.getElementById(`${this.contentPrefix}-${selectedTab}`);
|
|
if (activeContent) activeContent.style.display = '';
|
|
|
|
if (selectedTab === 'preview') {
|
|
await this.loadPreview();
|
|
}
|
|
}
|
|
|
|
async loadPreview() {
|
|
const previewTarget = document.getElementById('preview-output');
|
|
if (!previewTarget) return;
|
|
|
|
previewTarget.innerHTML = `
|
|
<div class="skeleton-lines">
|
|
<div></div><div></div><div></div><div></div><div></div>
|
|
</div>
|
|
`;
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
const textarea = document.querySelector(this.textareaSelector);
|
|
const markdown = textarea ? textarea.value : '';
|
|
|
|
const res = await fetch(this.previewUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `content=${encodeURIComponent(markdown)}`
|
|
});
|
|
|
|
const html = await res.text();
|
|
previewTarget.innerHTML = html;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new TabSwitcherController({
|
|
tabSelector: '.tabs',
|
|
contentPrefix: 'tab',
|
|
textareaSelector: '#description',
|
|
previewUrl: '/parsedown/preview'
|
|
});
|
|
});
|
|
|
|
--- End File: public/js/markdown_preview.js ---
|
|
|
|
|
|
--- File: public/js/ticket_view.js ---
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
const ticket_id = window.location.pathname.split('/')[2];
|
|
const comments_url = `/ticket/${ticket_id}/comments`;
|
|
const attachments_url = `/ticket/${ticket_id}/attachments`;
|
|
|
|
function ajax(url, containerID){
|
|
fetch(url)
|
|
.then(response => {
|
|
if(!response.ok){
|
|
throw new Error('Network response was not ok.');
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(html => {
|
|
const container_el = document.getElementById(containerID);
|
|
if(container_el){
|
|
container_el.innerHTML += html;
|
|
} else {
|
|
throw new Error('Coments container does not exist');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.log('Error fetching comments', error);
|
|
});
|
|
}
|
|
|
|
ajax(attachments_url, 'attachments')
|
|
ajax(comments_url, 'comments')
|
|
});
|
|
|
|
|
|
--- End File: public/js/ticket_view.js ---
|
|
|
|
|
|
--- File: public/js/tp_md_editor.js ---
|
|
/**
|
|
* tp_md_editor.js
|
|
* Self-contained Markdown Editor with Toolbar Buttons
|
|
* Usage: tp_md_editor.init(config)
|
|
*/
|
|
|
|
class TPMarkdownEditor {
|
|
static init(config = {}) {
|
|
document.querySelectorAll('tp-md-editor').forEach(editor => {
|
|
new TPMarkdownEditor(editor, config);
|
|
});
|
|
}
|
|
|
|
constructor(wrapper, config) {
|
|
this.wrapper = wrapper;
|
|
this.name = wrapper.getAttribute('name');
|
|
this.config = config;
|
|
this.textarea = document.createElement('textarea');
|
|
this.textarea.name = this.name;
|
|
this.textarea.rows = 25;
|
|
this.toolbar = document.createElement('tp-md-toolbar');
|
|
|
|
this.undoStack = []
|
|
this.redoStack = []
|
|
this.localStorageKey = this.getStorageKey();
|
|
this.unsaved = false;
|
|
this.autosaveInterval = null;
|
|
|
|
// this.loadInitialContent();
|
|
this.captureInitialContent();
|
|
this.createUnsavedBanner();
|
|
this.loadFromLocalStorage();
|
|
|
|
this.wrapper.appendChild(this.unsavedBanner);
|
|
this.wrapper.appendChild(this.toolbar);
|
|
this.wrapper.appendChild(this.textarea);
|
|
|
|
this.buttonClasses = TPMarkdownEditor.defaultButtons();
|
|
this.buildToolbar();
|
|
this.setupAutoList();
|
|
this.setupUndoRedo();
|
|
this.setupPersistence();
|
|
this.setupAutoSave();
|
|
}
|
|
|
|
getStorageKey(){
|
|
const path = window.location.pathname;
|
|
return `tp_md_editor:${path}:${this.name}`;
|
|
}
|
|
|
|
createUnsavedBanner(){
|
|
const container = document.createElement('div');
|
|
container.style.cssText = 'background: #fff3cd; color: #856404; padding: 5px 10px; font-size: 0.9em; display: flex; justify-content: space-between; align-items: center; display: none;';
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = 'You have unsaved changes.';
|
|
|
|
const discardBtn = document.createElement('button');
|
|
discardBtn.textContent = 'Discard';
|
|
discardBtn.style.cssText = 'margin-left: auto; background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer;';
|
|
discardBtn.addEventListener('click', () => {
|
|
localStorage.removeItem(this.localStorageKey);
|
|
const hidden = this.wrapper.querySelector('.tp-md-initial');
|
|
if(hidden){
|
|
this.textarea.value = hidden.textContent;
|
|
}
|
|
this.clearUnsaved();
|
|
});
|
|
|
|
container.appendChild(text);
|
|
container.appendChild(discardBtn);
|
|
this.unsavedBanner = container
|
|
}
|
|
|
|
markUnsaved(){
|
|
this.unsaved = true;
|
|
this.unsavedBanner.style.display = 'block';
|
|
}
|
|
|
|
clearUnsaved(){
|
|
this.unsaved = false;
|
|
this.unsavedBanner.style.display = 'none';
|
|
}
|
|
|
|
setupPersistence(){
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if(this.unsaved){
|
|
localStorage.setItem(this.localStorageKey, this.textarea.value);
|
|
}
|
|
});
|
|
}
|
|
|
|
setupAutoSave(){
|
|
this.autosaveInterval = setInterval(() => {
|
|
if(this.unsaved){
|
|
localStorage.setItem(this.localStorageKey, this.textarea.value);
|
|
}
|
|
}, 5000); // save every 5 sec
|
|
}
|
|
|
|
loadFromLocalStorage(){
|
|
const saved = localStorage.getItem(this.localStorageKey);
|
|
if(saved){
|
|
this.textarea.value = saved;
|
|
this.markUnsaved();
|
|
}
|
|
}
|
|
|
|
buildToolbar() {
|
|
const groups = this.config.groups || [Object.keys(this.buttonClasses)];
|
|
groups.forEach(group => {
|
|
const groupEl = document.createElement('tp-md-toolbar-group');
|
|
group.forEach(btnType => {
|
|
const BtnClass = this.buttonClasses[btnType];
|
|
if (BtnClass) {
|
|
const btn = new BtnClass(this.textarea);
|
|
groupEl.appendChild(btn.element);
|
|
}
|
|
});
|
|
this.toolbar.appendChild(groupEl);
|
|
});
|
|
}
|
|
|
|
setupUndoRedo(){
|
|
this.textarea.addEventListener('input', ()=>{
|
|
this.undoStack.push(this.textarea.value);
|
|
if(this.undoStack > 100) this.undoStack.shift();
|
|
this.redoStack = [];
|
|
this.markUnsaved();
|
|
});
|
|
|
|
this.textarea.addEventListener('keydown', (e) => {
|
|
if(e.ctrlKey && e.key === 'z'){
|
|
e.preventDefault();
|
|
if(this.undoStack.length > 0 ){
|
|
this.redoStack.push(this.textarea.value);
|
|
this.textarea.value = this.undoStack.pop();
|
|
this.markUnsaved();
|
|
}
|
|
} else if (e.ctrlKey && (e.key === 'y')) { // || e.shiftkey && e.key === 'z'
|
|
e.preventDefault();
|
|
if(this.redoStack.length > 0){
|
|
this.undoStack.push(this.textarea.value);
|
|
this.textarea.value = this.redoStack.pop();
|
|
this.markUnsaved();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
setupAutoList() {
|
|
this.textarea.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
const pos = this.textarea.selectionStart;
|
|
const before = this.textarea.value.slice(0, pos);
|
|
const after = this.textarea.value.slice(pos);
|
|
const lines = before.split('\n');
|
|
const lastLine = lines[lines.length - 1];
|
|
|
|
// need order of task > ul
|
|
let match;
|
|
if ((match = lastLine.match(/^(\s*)- \[( |x)\] /))) {
|
|
// task
|
|
e.preventDefault();
|
|
if (lastLine.trim() === '- [ ]' || lastLine.trim() === '- [x]') {
|
|
this.removeLastLine(pos, lastLine.length);
|
|
} else {
|
|
const insert = '\n' + match[1] + '- [ ] ';
|
|
this.insertAtCursor(insert);
|
|
}
|
|
} else if ((match = lastLine.match(/^(\s*)([-*+] )/))) {
|
|
// ul
|
|
e.preventDefault();
|
|
if (lastLine.trim() === match[2].trim()) {
|
|
this.removeLastLine(pos, lastLine.length);
|
|
} else {
|
|
const insert = '\n' + match[1] + match[2];
|
|
this.insertAtCursor(insert);
|
|
}
|
|
} else if ((match = lastLine.match(/^(\s*)(\d+)\. /))) {
|
|
// ol
|
|
e.preventDefault();
|
|
if (lastLine.trim() === `${match[2]}.`) {
|
|
this.removeLastLine(pos, lastLine.length);
|
|
} else {
|
|
const nextNum = parseInt(match[2]) + 1;
|
|
const insert = `\n${match[1]}${nextNum}. `;
|
|
this.insertAtCursor(insert);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
removeLastLine(cursorPos, lengthToRemove) {
|
|
const start = cursorPos - lengthToRemove;
|
|
this.textarea.setRangeText('', start, cursorPos, 'start');
|
|
this.textarea.setSelectionRange(start, start);
|
|
}
|
|
|
|
insertAtCursor(text) {
|
|
const start = this.textarea.selectionStart;
|
|
const end = this.textarea.selectionEnd;
|
|
this.textarea.setRangeText(text, start, end, 'end');
|
|
const newPos = start + text.length;
|
|
this.textarea.setSelectionRange(newPos, newPos)
|
|
this.markUnsaved();
|
|
}
|
|
|
|
captureInitialContent() {
|
|
const hidden = document.createElement('script');
|
|
hidden.type = 'text/plain';
|
|
// hidden.style.display = 'none';
|
|
hidden.classList.add('tp-md-initial');
|
|
hidden.textContent = this.wrapper.textContent.trim();
|
|
// clear inner content so it's not visible twice
|
|
this.wrapper.textContent = '';
|
|
|
|
this.wrapper.appendChild(hidden);
|
|
this.textarea.value = hidden.textContent;
|
|
}
|
|
|
|
static defaultButtons() {
|
|
return {
|
|
h1: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '# ', 'H1', 'fas fa-heading', '', 'fas fa-1'); }
|
|
},
|
|
h2: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '## ', 'H2', 'fas fa-heading', '', 'fas fa-2'); }
|
|
},
|
|
h3: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '### ', 'H3', 'fas fa-heading', '', 'fas fa-3'); }
|
|
},
|
|
bold: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '**', 'Bold', 'fas fa-bold', '**'); }
|
|
},
|
|
italic: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '_', 'Italic', 'fas fa-italic', '_'); }
|
|
},
|
|
quote: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '> ', 'Quote', 'fas fa-quote-right'); }
|
|
},
|
|
code: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '`', 'Code', 'fas fa-code', '`'); }
|
|
formatSelection(sel) {
|
|
if (!sel || !sel.includes('\n')) {
|
|
return '`' + (sel || 'code') + '`';
|
|
}
|
|
return '```\n' + sel + '\n```';
|
|
}
|
|
},
|
|
link: class extends TPMarkdownButton {
|
|
constructor(textarea) {
|
|
super(textarea, '[', 'Link', 'fas fa-link', '](url)');
|
|
}
|
|
formatSelection(sel) {
|
|
return `[${sel || 'text'}](url)`;
|
|
}
|
|
},
|
|
bullet: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '- ', 'Bullet', 'fas fa-list-ul'); }
|
|
formatSelection(sel){
|
|
return sel.split('\n').map(line => '- ' + line).join('\n');
|
|
}
|
|
},
|
|
number: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '1. ', 'Numbered', 'fas fa-list-ol'); }
|
|
formatSelection(sel){
|
|
return sel.split('\n').map((line, i) => `${i+1}. ${line}`).join('\n');
|
|
}
|
|
},
|
|
task: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '- [ ] ', 'Task', 'fas fa-tasks'); }
|
|
formatSelection(sel){
|
|
return sel.split('\n').map(line => ' - [ ]' + line).join('\n')
|
|
}
|
|
},
|
|
hr: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '---\n', 'HR', 'fas fa-minus'); }
|
|
},
|
|
table: class extends TPMarkdownButton {
|
|
constructor(textarea) {
|
|
super(textarea, '', 'Table', 'fas fa-table');
|
|
}
|
|
formatSelection(_) {
|
|
return '| Col1 | Col2 |\n|------|------|\n| Val1 | Val2 |';
|
|
}
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
class TPMarkdownButton {
|
|
constructor(textarea, prefix = '', title = '', icon = '', suffix = '', icon_offset = '') {
|
|
this.textarea = textarea;
|
|
this.prefix = prefix;
|
|
this.suffix = suffix;
|
|
this.element = document.createElement('tp-md-toolbar-button');
|
|
this.element.title = title;
|
|
if (icon_offset == '') {
|
|
this.element.innerHTML = `<i class="${icon}"></i>`;
|
|
} else {
|
|
this.element.innerHTML = `<span class="fa-stack fa-stack-100"><i class="fa-stack-1x ${icon}"></i><i class="fa-stack-offset ${icon_offset}"></i></span>`;
|
|
}
|
|
this.element.addEventListener('click', () => this.apply());
|
|
}
|
|
|
|
formatSelection(sel) {
|
|
return this.prefix + (sel || 'text') + this.suffix;
|
|
}
|
|
|
|
apply() {
|
|
const textarea = this.textarea;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const text = textarea.value;
|
|
this.previousValue = textarea.value;
|
|
const selected = text.substring(start, end);
|
|
const formatted = this.formatSelection(selected);
|
|
textarea.setRangeText(formatted, start, end, 'end');
|
|
|
|
if(this.previousValue !== textarea.value){
|
|
if(!textarea.undoStack) textarea.undoStack = [];
|
|
textarea.undoStack.push(this.previousValue);
|
|
}
|
|
|
|
textarea.focus();
|
|
}
|
|
}
|
|
|
|
// Export as global
|
|
window.tp_md_editor = TPMarkdownEditor;
|
|
--- End File: public/js/tp_md_editor.js ---
|
|
|
|
|
|
--- File: scss/main.scss ---
|
|
// import bulma
|
|
@use "vendor/bulma";
|
|
|
|
// import custom components
|
|
@use "components/ticket-item";
|
|
--- End File: scss/main.scss ---
|
|
|
|
|
|
--- File: scss/components/_ticket-item.scss ---
|
|
@use "../vendor/bulma";
|
|
|
|
.ticket-item {
|
|
@extend .is-flex;
|
|
@extend .mb-1;
|
|
@extend .pt-1;
|
|
@extend .pb-2;
|
|
@extend .is-align-items-flex-start;
|
|
border-bottom: 1px solid var(--bulma-text-90);
|
|
|
|
.ticket-icon {
|
|
@extend .mr-2;
|
|
display: flex;
|
|
align-items: baseline;
|
|
|
|
.checkbox {
|
|
margin-right: 0.5rem;
|
|
}
|
|
}
|
|
|
|
.ticket-content {
|
|
@extend .is-flex;
|
|
@extend .is-flex-direction-column;
|
|
@extend .is-flex-grow-1;
|
|
align-self: baseline;
|
|
|
|
.ticket-header {
|
|
@extend .is-flex;
|
|
@extend .is-justify-content-flex-start;
|
|
@extend .is-flex-wrap-wrap;
|
|
@extend .mb-1;
|
|
align-items: center;
|
|
|
|
.ticket-title {
|
|
@extend .title;
|
|
@extend .mb-0;
|
|
@extend .is-5;
|
|
font-weight: normal;
|
|
}
|
|
|
|
.tags {
|
|
@extend .ml-2;
|
|
}
|
|
}
|
|
|
|
.ticket-meta {
|
|
@extend .is-flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
|
|
p {
|
|
@extend .subtitle;
|
|
@extend .is-6;
|
|
font-weight: 300;
|
|
margin: 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
--- End File: scss/components/_ticket-item.scss ---
|
|
|
|
|
|
--- File: scss/vendor/_bulma-tools.scss ---
|
|
@use "../../node_modules/bulma/sass/utilities/" as bulma-utils;
|
|
@use "../../node_modules/bulma/sass/helpers/" as bulma-helpers;
|
|
@use "../../node_modules/bulma/sass/elements/" as bulma-elements;
|
|
--- End File: scss/vendor/_bulma-tools.scss ---
|
|
|
|
|
|
--- File: scss/vendor/_bulma.scss ---
|
|
@forward "../../node_modules/bulma/bulma";
|
|
--- End File: scss/vendor/_bulma.scss ---
|
|
|
|
|
|
--- File: ui/partials/ticket_item.html ---
|
|
<div class="ticket-item">
|
|
<div class="ticket-icon">
|
|
<div class="field">
|
|
<label class="b-checkbox checkbox">
|
|
<input type="checkbox" value="false" />
|
|
<span class="check"></span>
|
|
<span class="control-label"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<span class="icon is-medium"><icons type="status">{{@ticket.status_name}}</icons></span>
|
|
</div>
|
|
<div class="ticket-content">
|
|
<div class="ticket-header">
|
|
<span class="ticket-title">
|
|
<a href="/ticket/{{ @ticket.id }}">{{ @ticket.title }}</a>
|
|
</span>
|
|
<div class="tags ml-2">
|
|
<repeat group="{{ @ticket.tags }}" value="{{ @tag }}">
|
|
<span class="tag is-{{@tag.color}}">{{ @tag.name }}</span>
|
|
</repeat>
|
|
</div>
|
|
</div>
|
|
<div class="ticket-meta">
|
|
<p>#{{ @ticket.id }} opened {{ @ticket.created_at }} by {{ @ticket.display_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/partials/ticket_item.html ---
|
|
|
|
|
|
--- File: ui/parts/clipboard.html ---
|
|
<div class="block">
|
|
<style>
|
|
#upload-area {
|
|
height: 250px;
|
|
border: 2px dashed #aaa;
|
|
display: flex;
|
|
align-items: center;
|
|
margin-top: 10px;
|
|
color: #555;
|
|
font-family: sans-serif;
|
|
text-align: center;
|
|
}
|
|
|
|
#upload-area.hover {
|
|
background-color: #f0f0f0;
|
|
border-color: #444;
|
|
}
|
|
|
|
#preview {
|
|
max-width:100%;
|
|
margin-top: 10px;
|
|
}
|
|
</style>
|
|
|
|
<div id="upload-area" contenteditable="true">
|
|
Paste or drag an image here
|
|
</div>
|
|
<img id="preview" alt="image preview" hidden>
|
|
<p id="status"></p>
|
|
|
|
<script>
|
|
const area = document.getElementById('upload-area');
|
|
const preview = document.getElementById('preview');
|
|
const status = document.getElementById('status');
|
|
|
|
async function uploadImage(file){
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
preview.src = reader.result;
|
|
preview.hidden = false;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
const formData = new FormData();
|
|
formData.append('attachment', file);
|
|
|
|
try {
|
|
const res = await fetch('/ticket/{{@ticket->id}}/attachments/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const text = await res.text();
|
|
status.textContent = text;
|
|
} catch (e) {
|
|
status.textContent = 'upload failed.'
|
|
}
|
|
}
|
|
|
|
// paste
|
|
area.addEventListener('paste', (e) => {
|
|
for(let item of e.clipboardData.items){
|
|
if(item.type.startsWith('image/')){
|
|
uploadImage(item.getAsFile());
|
|
}
|
|
}
|
|
});
|
|
|
|
// drag and drop
|
|
area.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
area.classList.add('hover');
|
|
});
|
|
|
|
area.addEventListener('dragLeave', ()=> {
|
|
area.classList.remove('hover');
|
|
});
|
|
|
|
area.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
area.classList.remove('hover');
|
|
const files = e.dataTransfer.files;
|
|
for(let file of files){
|
|
if(file.type.startsWith('image/')){
|
|
uploadImage(file);
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
</div>
|
|
--- End File: ui/parts/clipboard.html ---
|
|
|
|
|
|
--- File: ui/session/error.html ---
|
|
<check if="{{isset(@SESSION.error)}}">
|
|
<div class="notification is-warning">
|
|
{{ @SESSION.error }}
|
|
</div>
|
|
</check>
|
|
--- End File: ui/session/error.html ---
|
|
|
|
|
|
--- File: ui/templates/layout.html ---
|
|
<!DOCTYPE html>
|
|
<html data-theme="{{ @SESSION.theme ?? 'light' }}" lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>TP ServiceDesk</title>
|
|
<link rel="stylesheet" href="/style.css">
|
|
<link rel="stylesheet" href="/css/main.min.css">
|
|
<!-- bulma.io-->
|
|
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"> -->
|
|
<!-- bulma helpers -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma-helpers/0.4.3/css/bulma-helpers.min.css"
|
|
integrity="sha512-U6ELnUi7oqVEjkLmFw5r5UR5LEtvpImS/jUykBKneVhD0lxZxfJZ3k3pe003ktrtNZYungd9u3Urp2X09wKwXg=="
|
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
<!-- bulma-checkbox -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma-checkbox@1.2.1/css/main.min.css" integrity="sha256-wvxLpriInkhouxrLZ5oo74cJpCtZJkR9bRJwFDvdd4w=" crossorigin="anonymous">
|
|
<!-- font awesome -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
|
|
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
|
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
<!-- additional JS -->
|
|
<check if="{{ isset(@js) }}">
|
|
<script src="/js/{{ @js}}"></script>
|
|
</check>
|
|
</head>
|
|
|
|
<body>
|
|
<!-- Navigation Bar -->
|
|
<nav class="navbar" role="navigation" aria-label="main navigation">
|
|
<div class="navbar-brand">
|
|
<a class="navbar-item" href="/">
|
|
<!-- Your logo or app name -->
|
|
<img src="/logo.svg" alt="App Logo">
|
|
</a>
|
|
<!-- Burger menu for mobile -->
|
|
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="mainNavbar">
|
|
<span aria-hidden="true"></span>
|
|
<span aria-hidden="true"></span>
|
|
<span aria-hidden="true"></span>
|
|
</a>
|
|
</div>
|
|
|
|
<div id="mainNavbar" class="navbar-menu">
|
|
<div class="navbar-start">
|
|
<a class="navbar-item" href="/dashboard">Dashboard</a>
|
|
<a class="navbar-item" href="/kb">Knowledge Base</a>
|
|
<a class="navbar-item" href="/projects">Projects</a>
|
|
<a class="navbar-item" href="/tickets">Tickets</a>
|
|
<a class="navbar-item" href="/tags">Tags</a>
|
|
<check if="{{ isset(@SESSION.user) && @SESSION.user.is_admin }}">
|
|
<a class="navbar-item" href="/admin">Admin</a>
|
|
</check>
|
|
</div>
|
|
<div class="navbar-end">
|
|
<div class="navbar-item">
|
|
<div class="buttons">
|
|
<form id="theme-toggle-form" method="post" action="/toggle-theme" style="display:inline">
|
|
<button type="submit" id="theme-toggle-button" class="button is-small" aria-label="Toggle Theme">
|
|
<span class="icon">
|
|
<i class="fas fa-circle-half-stroke" id="theme-icon"></i>
|
|
</span>
|
|
</button>
|
|
</form>
|
|
<check if="{{ isset(@SESSION.user) }}">
|
|
<true>
|
|
<a class="button is-primary" href="/logout">Log Out</a>
|
|
</true>
|
|
<false>
|
|
<a class="button is-primary" href="/login">Log In</a>
|
|
</false>
|
|
</check>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- breadcrumbs TODO: NOT YET
|
|
<div class="container">
|
|
|
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
|
<ul>
|
|
<li><a href="#">Bulma</a></li>
|
|
<li><a href="#">Documentation</a></li>
|
|
<li><a href="#">Components</a></li>
|
|
<li class="is-active"><a href="#" aria-current="page">Breadcrumb</a></li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
-->
|
|
|
|
<!-- Main Content Area -->
|
|
<div class="container">
|
|
<include href="ui/session/error.html">
|
|
</div>
|
|
<main class="section" id="page">
|
|
<div class="container">
|
|
<!-- Fat-Free Framework content injection -->
|
|
<include href="{{@content}}" />
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Footer -->
|
|
<footer class="footer">
|
|
<div class="content has-text-centered">
|
|
<p>©
|
|
<?php echo date('Y'); ?> Terry Probert
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
|
|
<!-- JavaScript for Bulma navbar burger (mobile) -->
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const burgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
|
|
if (burgers.length > 0) {
|
|
burgers.forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const target = document.getElementById(el.dataset.target);
|
|
el.classList.toggle('is-active');
|
|
target.classList.toggle('is-active');
|
|
});
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|
|
--- End File: ui/templates/layout.html ---
|
|
|
|
|
|
--- File: ui/views/dashboard.html ---
|
|
<h1 class="title">Dashboard</h1>
|
|
--- End File: ui/views/dashboard.html ---
|
|
|
|
|
|
--- File: ui/views/home.html ---
|
|
|
|
<!-- hero -->
|
|
<section class="hero is-info is-medium">
|
|
<div class="hero-body">
|
|
<div class="container">
|
|
<h1 class="title">TP ServiceDesk</h1>
|
|
<h2 class="subtitle">One place to manage requests, store knowledge, and collaborate on projects</h2>
|
|
<p class="buttons">
|
|
<a href="/login" class="button is-primary">
|
|
<span class="icon"><i class="fas fa-sign fa-sign-in-alt"></i></span>
|
|
<span>Get Started</span>
|
|
</a>
|
|
<a href="/kb" class="button is-light">
|
|
<span class="icon"><i class="fas fa-book"></i></span>
|
|
<span>Browse Knowledge Base</span>
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- features -->
|
|
<section class="section">
|
|
<div class="container">
|
|
<div class="columns is-multiline">
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Ticketing System</p>
|
|
<ul>
|
|
<li><i class="fas fa-check-circle"></i> Create & Track tickets</li>
|
|
<li><i class="fas fa-check-circle"></i> Assign priorities & statuses</li>
|
|
<li><i class="fas fa-check-circle"></i> Link child/parent tickets</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Knowledge Base</p>
|
|
<ul>
|
|
<li><i class="fas fa-book"></i> Markdown-powered articles</li>
|
|
<li><i class="fas fa-tags"></i> Tagging and filtering</li>
|
|
<li><i class="fas fa-search"></i> Fast searching</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Projects</p>
|
|
<ul>
|
|
<li><i class="fas fa-stream"></i> Track ongoing projects</li>
|
|
<li><i class="fas fa-tasks"></i> Integreate tasks and tickets</li>
|
|
<li><i class="fas fa-chart-line"></i> Monitor progress</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Collaboration</p>
|
|
<ul>
|
|
<li><i class="fas fa-comments"></i> Comment threads</li>
|
|
<li><i class="fas fa-paperclip"></i> File attachments</li>
|
|
<li><i class="fas fa-user-friends"></i> Role-based user access</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Custom fields</p>
|
|
<ul>
|
|
<li><i class="fas fa-database"></i> Define ticket meta data</li>
|
|
<li><i class="fas fa-sitemap"></i> Configure and store extra info</li>
|
|
<li><i class="fas fa-pencil-alt"></i> Easily editable in forms</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Administration</p>
|
|
<ul>
|
|
<li><i class="fas fa-book"></i> Manage user roles</li>
|
|
<li><i class="fas fa-tags"></i> Create new account</li>
|
|
<li><i class="fas fa-search"></i> Edit existing users</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
</div>
|
|
</section>
|
|
--- End File: ui/views/home.html ---
|
|
|
|
|
|
--- File: ui/views/login.html ---
|
|
<h1 class="title">Please Log In</h1>
|
|
|
|
<check if="{{ @error}}">
|
|
<div class="notification is-danger is-light">
|
|
<p style="color: red;">{{ @error }}</p>
|
|
</div>
|
|
</check>
|
|
|
|
<form action="/login" method="POST">
|
|
|
|
<div class="field">
|
|
<p class="control has-icons-left has-icons-right">
|
|
<input name="username" class="input" type="text" placeholder="Username">
|
|
<span class="icon is-small is-left">
|
|
<i class="fas fa-user"></i>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<p class="control has-icons-left">
|
|
<input name="password" class="input" type="password" placeholder="Password">
|
|
<span class="icon is-small is-left">
|
|
<i class="fas fa-lock"></i>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<p class="control">
|
|
<button class="button is-success">
|
|
Login
|
|
</button>
|
|
</p>
|
|
</div>
|
|
|
|
</form>
|
|
--- End File: ui/views/login.html ---
|
|
|
|
|
|
--- File: ui/views/admin/index.html ---
|
|
<h1 class="title">Admin</h1>
|
|
<hr>
|
|
|
|
<section>
|
|
<p><a href="/admin/priority">Ticket > Priorities</a></p>
|
|
<p><a href="/admin/status">Ticket > Statuses</a></p>
|
|
</section>
|
|
--- End File: ui/views/admin/index.html ---
|
|
|
|
|
|
--- File: ui/views/admin/priorities/create.html ---
|
|
<h1 class="title">Create Ticket Priority</h1>
|
|
<p>TODO:</p>
|
|
--- End File: ui/views/admin/priorities/create.html ---
|
|
|
|
|
|
--- File: ui/views/admin/priorities/index.html ---
|
|
<h1 class="title">Admin: Ticket Priorities</h1>
|
|
<p><a class="button" href="/admin/priority/create">create priority</a></p>
|
|
<hr>
|
|
|
|
<table class="table is-fullwidth is-bordered">
|
|
<thead>
|
|
<tr class="has-background-grey">
|
|
<th class="has-text-light">id</th>
|
|
<th class="has-text-light">name</th>
|
|
<th class="has-text-light">sort_order</th>
|
|
<th class="has-text-light"></th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
<repeat group="{{@priorities}}" value="{{@priority}}">
|
|
<tr>
|
|
<td>{{@priority.id}}</td>
|
|
<td><a href="/admin/priority/{{@priority.id}}">{{@priority.name}}</a></td>
|
|
<td>{{@priority.sort_order}}</td>
|
|
<td>
|
|
<a class="button is-link is-small" href="/admin/priority/{{@priority.id}}/edit">
|
|
<i class="fa fa-edit"></i></a>
|
|
<a class="button is-danger is-small"
|
|
href="/admin/priority/{{@priority.id}}/delete"
|
|
onclick="return confirm('Are you sure you want to delete this ticket?');">
|
|
<i class="fa fa-trash-can"></i></a>
|
|
</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
--- End File: ui/views/admin/priorities/index.html ---
|
|
|
|
|
|
--- File: ui/views/attachment/index.html ---
|
|
<div class="box">
|
|
<div class="content">
|
|
<h4 class="title is-4">Attachments</h4>
|
|
<div class="block">
|
|
<check if="isset( {{@attachments }})">
|
|
<check if="count({{@attachments}}) > 0">
|
|
<table class="table is-fullwidth is-narrow is-striped is-hoverable">
|
|
<thead>
|
|
<tr>
|
|
<th class="th-icon"></th>
|
|
<th>File Name</th>
|
|
<th>Uploaded By</th>
|
|
<th>Created At</th>
|
|
<th>Version</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @attachments }}" value="{{ @attach }}">
|
|
<tr>
|
|
<td>
|
|
<span class="icon"><i class="fas fa-file"></i></span>
|
|
</td>
|
|
<td><a href="/attachment/{{@attach.id}}/download">{{ @attach.file_name }}</a></td>
|
|
<td>{{ @attach.username }}</td>
|
|
<td>{{ @attach.created_at }}</td>
|
|
<td>{{ @attach.version_number }}</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
<hr>
|
|
<div>
|
|
<repeat group="{{ @attachments }}" value="{{ @attach }}">
|
|
<img src="/attachment/{{@attach.id}}/view">
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
</check>
|
|
<div class="block">
|
|
<form action="/ticket/{{@PARAMS.id}}/attachments/upload" method="POST" enctype="multipart/form-data">
|
|
<div class="field has-addons">
|
|
<div class="control has-icons-left"><!-- is-expanded -->
|
|
<input class="input" type="file" name="attachment" required>
|
|
<span class="icon is-small is-left">
|
|
<i class="fas fa-file"></i>
|
|
</span>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button" type="submit">Upload</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/attachment/index.html ---
|
|
|
|
|
|
--- File: ui/views/comments/view.html ---
|
|
<hr>
|
|
<div class="box" id="comments">
|
|
<h3 class="title is-3">Comments</h3>
|
|
<check if="{{ !empty(@comments) }}">
|
|
<div class="list">
|
|
<repeat group="{{ @comments }}" value="{{ @comment}}">
|
|
<div class="list-item">
|
|
<div class="list-item-image">
|
|
<figure class="image is-48x48">
|
|
<img class="is-rounded"
|
|
src="https://placehold.co/200x200/66d1ff/FFF?text=TP">
|
|
<!-- <img class="is-rounded"
|
|
src="https://loremflickr.com/200/200/dog?{{ (int)rand()}}">-->
|
|
</figure>
|
|
</div>
|
|
<div class="list-item-content">
|
|
<div class="list-item-title is-flex is-justify-content-space-between">
|
|
<span>{{ @comment.author_name}}</span>
|
|
<span class="has-text-weight-normal has-text-grey">{{ @comment.created_at }}</span>
|
|
</div>
|
|
<div class="list-item-description">
|
|
<parsedown>{{ @comment.comment | raw }}</parsedown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
<div class="block">
|
|
<form action="/ticket/{{@PARAMS.id}}/comment" method="POST">
|
|
<div class="field">
|
|
<label class="label">Add comment:</label>
|
|
<div class="control">
|
|
<textarea class="textarea" name="comment" rows="4" cols="50"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="field is-clearfix">
|
|
<button class="button is-primary is-pulled-right" type="submit">Submit Comment</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/comments/view.html ---
|
|
|
|
|
|
--- File: ui/views/kb/create.html ---
|
|
<h1 class="title">Create Knowledge Base Article</h1>
|
|
|
|
|
|
<div class="content">
|
|
<form action="/kb/create" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>
|
|
|
|
<div id="editor" class="block">
|
|
<div class="tabs">
|
|
<ul>
|
|
<li class="is-active"><a>Write</a></li>
|
|
<li class=""><a>Preview</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="block">
|
|
<div class="tab-content">
|
|
<bulma type="H_FIELD_TEXTAREA" label="Content:" name="content" value=""></bulma>
|
|
</div>
|
|
<div class="tab-content">
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TODO: tags -->
|
|
|
|
<!-- buttons -->
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/kb">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Create KB Article</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
--- End File: ui/views/kb/create.html ---
|
|
|
|
|
|
--- File: ui/views/kb/edit.html ---
|
|
<h1 class="title">Edit Knowledge Base Article</h1>
|
|
|
|
|
|
<form action="/kb/{{@article.id}}/update" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@article.title}}"></bulma>
|
|
|
|
<div id="editor" class="block">
|
|
<div class="tabs">
|
|
<ul>
|
|
<li class="is-active"><a data-target="pane-editor">Write</a></li>
|
|
<li class=""><a data-target="pane-preview">Preview</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="tab-content">
|
|
<div class="tab-pane" id="pane-editor">
|
|
<div class="block">
|
|
<bulma type="H_FIELD_TEXTAREA" label="Content:" name="content" value="{{@article.content}}"></bulma>
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane" id="pane-preview">
|
|
<div class="block content">
|
|
<div class="preview" data-source="content" data-handler="/parsedown/preview" data-method="post"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TODO: tags -->
|
|
|
|
<button class="button is-primary" type="submit">Update Article</button>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/kb/edit.html ---
|
|
|
|
|
|
--- File: ui/views/kb/index.html ---
|
|
<h1 class="title">Knowledge Base</h1>
|
|
<p><a class="button" href="/kb/create">create kb article</a></p>
|
|
<hr>
|
|
|
|
<div class="block">
|
|
<form method="GET" action="/kb">
|
|
<div class="level">
|
|
<div class="level-item">
|
|
<check if="{{ isset(@GET.search)}}">
|
|
<true>
|
|
<input class="input" type="text" name="search" placeholder="Search by title..."
|
|
value="{{ HTMLPurifier::instance()->purify( @GET.search) }}">
|
|
</true>
|
|
<false>
|
|
<input class="input" type="text" name="search" placeholder="Search by title...">
|
|
</false>
|
|
</check>
|
|
</div>
|
|
<div class="level-right">
|
|
<div class="select">
|
|
<select name="tag">
|
|
<option value="">--Filter by Tag</option>
|
|
<!-- TODO: load list of all tags-->
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="level-right">
|
|
<button class="button is-primary" type="submit">Search</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<check if="{{@articles}}">
|
|
<table class="table is-fullwidth is-bordered">
|
|
<thead>
|
|
<tr class="has-background-info">
|
|
<th>id</th><th>title</th><th>created_at</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
<repeat group="{{@articles}}" value="{{@article}}">
|
|
<tr>
|
|
<td>{{@article.id}}</td>
|
|
<td><a href="/kb/{{@article.id}}">{{@article.title}}</a></td>
|
|
<td>{{@article.created_at}}</td>
|
|
<td>
|
|
<a href="/kb/{{@article.id}}/edit"><i class="fa fa-edit"></i></a>
|
|
</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</check>
|
|
|
|
<check if="!@articles">
|
|
<div class="notification is-info is-light">
|
|
<p>No articles found.</p>
|
|
</div>
|
|
</check>
|
|
--- End File: ui/views/kb/index.html ---
|
|
|
|
|
|
--- File: ui/views/kb/view.html ---
|
|
<h1 class="title">{{@article.title}}</h1>
|
|
<p><a href="/kb/{{ @article.id}}/edit">edit article</a></p>
|
|
<hr>
|
|
|
|
<div class="content">
|
|
<parsedown>{{ @article.content | raw }}</parsedown>
|
|
</div>
|
|
|
|
|
|
|
|
<check if="{{ isset(@tags)}}">
|
|
<div class="box">
|
|
<table class="table is-bordered is-fullwidth">
|
|
<thead>
|
|
<tr><th class="has-width-200">Property</th><th>Value</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
|
|
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</check>
|
|
|
|
--- End File: ui/views/kb/view.html ---
|
|
|
|
|
|
--- File: ui/views/project/create.html ---
|
|
<pre>
|
|
TODO: create form.
|
|
</pre>
|
|
--- End File: ui/views/project/create.html ---
|
|
|
|
|
|
--- File: ui/views/project/edit.html ---
|
|
<pre>
|
|
TODO: edit form
|
|
</pre>
|
|
--- End File: ui/views/project/edit.html ---
|
|
|
|
|
|
--- File: ui/views/project/index.html ---
|
|
<h1 class="title">Projects</h1>
|
|
<p><a class="button" href="/project/create">create project</a></p>
|
|
<hr>
|
|
|
|
<table class="table is-fullwidth is-hoverable is-bordered">
|
|
<thead>
|
|
<tr class="has-background-primary">
|
|
<th>ID</th>
|
|
<th>Title</th>
|
|
<th>Requester</th>
|
|
<th>Created By</th>
|
|
<th>Created At</th>
|
|
<th>Start Date</th>
|
|
<th>End Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @projects }}" value="{{ @p }}">
|
|
<tr>
|
|
<td>{{ @p.id }}</td>
|
|
<td><a href="/project/{{@p.id}}">{{ @p.title }}</a></td>
|
|
<td>{{ @p.requester }}</td>
|
|
<td>{{ @p.created_by }}</td>
|
|
<td>{{ @p.created_at }}</td>
|
|
<td>{{ @p.start_date }}</td>
|
|
<td>{{ @p.end_date }}</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
|
|
--- End File: ui/views/project/index.html ---
|
|
|
|
|
|
--- File: ui/views/project/view.html ---
|
|
<h3 class="title">{{ @project.title }}</h3>
|
|
<p><a href="/project/{{ @project.id}}/edit">edit project</a></p>
|
|
<hr>
|
|
<div class="columns">
|
|
<div class="column is-two-thirds">
|
|
<h3 class="title subtitle">Overview</h3>
|
|
<div class="box">
|
|
<parsedown>{{ @project.description }}</parsedown>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<div class="block">
|
|
<h3 class="title subtitle">Links</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<div class="columns">
|
|
<div class="column">
|
|
<h3 class="title subtitle">Tickets</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<h3 class="title subtitle">Tasks</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<h3 class="title subtitle">Events</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<div class="columns">
|
|
<div class="column">
|
|
<h3 class="title">Timeline</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<parsedown inline="true">
|
|
---
|
|
## View project
|
|
|
|
A central place to see everything for this project:
|
|
|
|
- Overview: (title, description, links, start/end dates).
|
|
- related tickets (with status and priorities)
|
|
- events
|
|
- tasks
|
|
- timeline combining events, tickets, milestone dates
|
|
|
|
## Example Workflow
|
|
|
|
- create a project - `team manager overview`
|
|
-- attach relevant links
|
|
- add tickets - each new request or issue can be a ticket referencing this project
|
|
- add events - quick notes about management meetings, or verbal discussions that don't need ticket overhead
|
|
-- meeting on 01 jan to discuss layout
|
|
-- teams message on 28 jan clarifying data requirements
|
|
- project tasks - for smaller to do items that don't warrant a full ticket
|
|
-- identify location of required data, create initial pq connections, build a mockup layout
|
|
|
|
## Reporting Timelines
|
|
- timeline view - merge ticket data with project_events sorted by date - chronological Overview
|
|
- status summaries - how many tickets open, on hold, completed
|
|
- progress tracking - sumarries or gantt style charts
|
|
</parsedown>
|
|
--- End File: ui/views/project/view.html ---
|
|
|
|
|
|
--- File: ui/views/tag/create.html ---
|
|
<h1 class="title">Create Tag</h1>
|
|
|
|
<div class="content">
|
|
<form action="/tag/create" method="POST">
|
|
|
|
<div class="field">
|
|
<label class="label">Name</label>
|
|
<div class="control">
|
|
<input name="name" class="input" type="text" placeholder="Tag Name">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="label">Color</label>
|
|
<div class="control">
|
|
<input name="color" class="input" type="text" placeholder="Tag Color">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/tags">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Create Tag</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
--- End File: ui/views/tag/create.html ---
|
|
|
|
|
|
--- File: ui/views/tag/index.html ---
|
|
<h1 class="title">Tags</h1>
|
|
<p><a class="button" href="/tag/create">create tag</a></p>
|
|
<hr>
|
|
|
|
<check if="{{ count(@tags) > 1 }}">
|
|
<true>
|
|
<div class="tags are-medium">
|
|
<repeat group="{{ @tags }}" value="{{ @tag }}">
|
|
<a class="tag {{ (@tag.color ? 'is-' . @tag.color : '') }}">{{ @tag.name }}</a>
|
|
</repeat>
|
|
</div>
|
|
</true>
|
|
<false>
|
|
<div class="notification is-size-5">
|
|
<span><i class="fa fa-2xl fa-face-sad-tear"></i> </span>No tags found
|
|
</div>
|
|
</false>
|
|
</check>
|
|
|
|
|
|
<div class="box has-background-dark">
|
|
<div class="block">
|
|
<h3 class="subtitle has-text-white"><i class="fa-solid fa-lightbulb has-text-warning"></i> Color Examples</h3>
|
|
<p class="has-text-white">The following color names can be used for tags</p>
|
|
</div>
|
|
<div class="block">
|
|
<span class="tag is-black">Black</span>
|
|
<span class="tag is-dark">Dark</span>
|
|
<span class="tag is-light">Light</span>
|
|
<span class="tag is-white">White</span>
|
|
<span class="tag is-primary">Primary</span>
|
|
<span class="tag is-link">Link</span>
|
|
<span class="tag is-info">Info</span>
|
|
<span class="tag is-success">Success</span>
|
|
<span class="tag is-warning">Warning</span>
|
|
<span class="tag is-danger">Danger</span>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/tag/index.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/create.html ---
|
|
<h1 class="title">Create Ticket Form</h1>
|
|
|
|
<form action="/ticket/create" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>
|
|
<bulma type="H_FIELD_INPUT" label="Created At:" name="created_at" value=""></bulma>
|
|
<bulma type="H_FIELD_TEXTAREA" label="Description:" name="description" value=""></bulma>
|
|
|
|
<!-- priority and status -->
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Priority:" name="priority_id"
|
|
options="priorities" option_value="id" option_name="name"
|
|
selected="2"></bulma>
|
|
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Status:" name="status_id"
|
|
options="statuses" option_value="id" option_name="name"
|
|
selected="1"></bulma>
|
|
|
|
<!-- custom fields -->
|
|
<hr>
|
|
<div class="block">
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]" placeholder="eg. Department">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]" placeholder="eg. Finance">
|
|
</div>
|
|
</div>
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]" placeholder="eg. Category">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]" placeholder="eg. Urgent">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/tickets">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Create Ticket</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/ticket/create.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/edit.html ---
|
|
<!-- Ticket - Edit -->
|
|
<!-- made to look more in line with view-->
|
|
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
|
|
<div class="is-flex">
|
|
<div class="is-flex-grow-1">
|
|
<bulma type="FIELD_INPUT" name="title" value="{{@ticket.title}}" class="mr-3"></bulma>
|
|
</div>
|
|
<div class="field is-grouped">
|
|
<!-- <p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
|
|
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>-->
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/ticket/{{ @ticket.id }}">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
<div class="block">
|
|
|
|
<div class="columns">
|
|
<div class="column is-two-thirds">
|
|
|
|
<div class="block">
|
|
<bulma type="FIELD_INPUT" label="Created At:" name="created_at" value="{{@ticket.created_at}}"></bulma>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<ul>
|
|
<li class="is-active">
|
|
<a data-tab="write"><span class="icon is-small">
|
|
<i class="fas fa-pen" aria-hidden="true"></i></span><span>Write</span>
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-tab="preview"><span class="icon is-small">
|
|
<i class="fas fa-magnifying-glass" aria-hidden="true"></i></span><span>Preview</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div id="tab-write" class="tab-content block">
|
|
<bulma type="FIELD_TEXTAREA" name="description" value="{{@ticket.description}}" rows="20"></bulma>
|
|
</div>
|
|
<div id="tab-preview" class="tab-content content">
|
|
<div id="preview-output"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="column">
|
|
<div class="block">
|
|
<!-- priority and status -->
|
|
<bulma type="FIELD_SELECT" label="Priority:" name="priority_id"
|
|
options="{{@priorities}}" option_value="id" option_name="name"
|
|
selected="{{@ticket.priority_id}}"></bulma>
|
|
|
|
<bulma type="FIELD_SELECT" label="Status:" name="status_id"
|
|
options="{{@statuses}}" option_value="id" option_name="name"
|
|
selected="{{@ticket.status_id}}"></bulma>
|
|
</div>
|
|
<!-- meta data -->
|
|
<div class="block">
|
|
<table class="table is-bordered is-fullwidth">
|
|
<thead>
|
|
<tr><th class="has-width-100">Property</th><th>Value</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
|
|
<check if="{{ @key !== 'description'}}">
|
|
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
|
|
</check>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- form to add child ticket relationships -->
|
|
<div class="box">
|
|
<h4 class="title is-4">Linked Tickets</h4>
|
|
<!-- parent -->
|
|
<?php
|
|
/*
|
|
<check if="{{ @parent_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Parent Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
|
|
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
|
|
</repeat>
|
|
</ul>
|
|
</div>
|
|
</check>
|
|
<!-- child tickets -->
|
|
<check if="{{ @child_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Child Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
|
|
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
|
|
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
|
|
<div class="field">
|
|
<label class="label">Add existing ticket as child ticket (ID):</label>
|
|
<div class="control">
|
|
<input class="input" type="number" placeholder="Child Ticket ID" required
|
|
name="child_ticket_id">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<button class="button is-link" type="submit">Link</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
*/ ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<hr>
|
|
|
|
<div>
|
|
|
|
|
|
|
|
<include href="../ui/views/attachment/index.html">
|
|
<include href="../ui/views/comments/view.html">
|
|
|
|
|
|
<!--
|
|
<div class="block" id="attachments"></div>
|
|
<div class="block" id="comments"></div>
|
|
-->
|
|
|
|
</div>
|
|
|
|
--- End File: ui/views/ticket/edit.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/edit.html.v1 ---
|
|
<h1 class="title">Edit Ticket Form</h1>
|
|
|
|
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@ticket.title}}"></bulma>
|
|
<bulma type="H_FIELD_INPUT" label="Created At:" name="created_at" value="{{@ticket.created_at}}"></bulma>
|
|
<bulma type="H_FIELD_TEXTAREA" label="Description:" name="description" value="{{@ticket.description}}"></bulma>
|
|
|
|
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Priority:" name="priority_id"
|
|
options="priorities" option_value="id" option_name="name"
|
|
selected="{{@ticket.priority_id}}"></bulma>
|
|
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Status:" name="status_id"
|
|
options="statuses" option_value="id" option_name="name"
|
|
selected="{{@ticket.status_id}}"></bulma>
|
|
|
|
<include href="/ui/parts/clipboard.html"></include>
|
|
|
|
<div class="block">
|
|
<h3 class="title is-5">Custom Fields</h3>
|
|
<!-- existing fields-->
|
|
<div class="block">
|
|
<repeat group="{{ @ticket_meta }}" value="{{ @m }}">
|
|
<div class="field is-grouped is-grouped-right">
|
|
<input type="hidden" name="meta_id[]" value=" {{ @m.id }}">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]" value="{{ @m.meta_key }}"
|
|
placeholder="eg. Department">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]" value="{{ @m.meta_value }}"
|
|
placeholder="eg. Finance">
|
|
</div>
|
|
</div>
|
|
</repeat>
|
|
</div>
|
|
<hr>
|
|
<!-- adding new custom meta -->
|
|
<div class="block">
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]"
|
|
placeholder="eg. Department">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]"
|
|
placeholder="eg. Finance">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="button is-primary" type="submit">Save Ticket</button>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/ticket/edit.html.v1 ---
|
|
|
|
|
|
--- File: ui/views/ticket/index.html ---
|
|
<h1 class="title">Tickets</h1>
|
|
<!-- updated design -- inspiration gitea -->
|
|
<div class="block">
|
|
<div class="field is-grouped">
|
|
<div class="control is-expanded">
|
|
<div class="field has-addons is-expanded">
|
|
<div class="control is-expanded">
|
|
<input class="input" type="text" placeholder="Find a ticket">
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-info"><span class="icon"><i
|
|
class="fas fa-magnifying-glass"></i></span></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<p><a class="button is-primary" href="/ticket/create">create ticket</a></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="block">
|
|
<div class="field has-addons">
|
|
<!-- TODO: move this into a template -->
|
|
<repeat group="{{ IconsHelper::$status_icons}}" key="{{ @k }}" value="{{ @icon }}">
|
|
<p class="control">
|
|
<a href="{{ @PATH }}/?status={{ @k }}" class="button">
|
|
<span class="icon is-small"><i class="fas fa-{{ @icon[0] }}"></i></span>
|
|
<span>{{ IconsHelper::$status_names[@k] }}</span>
|
|
</a>
|
|
</p>
|
|
</repeat>
|
|
</div>
|
|
</div>
|
|
<hr>
|
|
|
|
<div id="ticket_list">
|
|
<repeat group="{{@tickets}}" value="{{@ticket}}">
|
|
<include href="/ui/partials/ticket_item.html"></include>
|
|
</repeat>
|
|
</div>
|
|
|
|
<?php
|
|
/*
|
|
<div id="ticket_list">
|
|
<repeat group="{{@tickets}}" value="{{@ticket}}">
|
|
<include href="/ui/views/ticket/index_row.html"></include>
|
|
</repeat>
|
|
</div>
|
|
*/
|
|
?>
|
|
--- End File: ui/views/ticket/index.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/index_row.html ---
|
|
<div class="g-flex-item is-flex is-align-items-flex-start mb-1 pt-1 pb-2">
|
|
<div class="g-flex-item-icon is-align-self-baseline mr-2">
|
|
<label class="checkbox mr-2"><input type="checkbox"></label>
|
|
<span class="icon is-medium"><icons type="status">{{@ticket.status_name}}</icons></span>
|
|
</div>
|
|
<div class="g-flex-item-main is-flex is-flex-direction-column is-flex-grow-1 is-align-self-baseline">
|
|
<div class="g-flex-item-header is-flex is-justify-content-flex-start is-flex-wrap-wrap">
|
|
<div class="">
|
|
<span class="title is-5 has-text-weight-normal"><a href="/ticket/{{@ticket.id}}">{{@ticket.title}}</a></span>
|
|
</div>
|
|
<div class="tags ml-2">
|
|
<!-- TODO: get tags -->
|
|
<span class="tag is-link">tag</span>
|
|
</div>
|
|
</div>
|
|
<div class="g-flex-item-body is-flex is-align-items-centre flex-wrap gap ">
|
|
<p class="subtitle is-6 has-text-weight-light">#{{@ticket.id}} opened 2025-03-25 by {{@ticket.display_name}}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/ticket/index_row.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/view.html ---
|
|
<!-- Ticket - View -->
|
|
<div class="is-flex">
|
|
<h1 class="title is-flex-grow-1">{{ @ticket.title }}</h1>
|
|
<div class="field is-grouped">
|
|
<p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
|
|
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<div class="content">
|
|
|
|
<div class="columns">
|
|
<div class="column is-two-thirds">
|
|
<div class="block">
|
|
<p>{{ @ticket.created_at }}</p>
|
|
<div class="content">
|
|
<parsedown>{{ @ticket.description | raw }}</parsedown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="column">
|
|
<!-- meta data -->
|
|
<div class="block">
|
|
<div class="tags">
|
|
<repeat group="{{ @ticket.tags }}" value="{{@tag}}">
|
|
<span class="tag is-{{@tag.color}}">{{@tag.name}}</span>
|
|
</repeat>
|
|
</div>
|
|
<table class="table is-bordered is-fullwidth">
|
|
<thead>
|
|
<tr><th class="has-width-100">Property</th><th>Value</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
|
|
<check if="{{ @key !== 'description'}}">
|
|
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
|
|
</check>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- form to add child ticket relationships -->
|
|
<div class="box">
|
|
<h3>Linked Tickets</h3>
|
|
<!-- parent -->
|
|
<check if="{{ @parent_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Parent Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
|
|
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
|
|
</repeat>
|
|
</ul>
|
|
</div>
|
|
</check>
|
|
<!-- child tickets -->
|
|
<check if="{{ @child_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Child Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
|
|
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
|
|
<div class="field">
|
|
<label class="label">Add existing ticket as child ticket (ID):</label>
|
|
<div class="control">
|
|
<input class="input" type="number" placeholder="Child Ticket ID" required
|
|
name="child_ticket_id">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<button class="button is-link" type="submit">Link</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<hr>
|
|
<include href="../ui/views/attachment/index.html">
|
|
<include href="../ui/views/comments/view.html">
|
|
|
|
|
|
<!--
|
|
<div class="block" id="attachments"></div>
|
|
<div class="block" id="comments"></div>
|
|
-->
|
|
|
|
</div>
|
|
--- End File: ui/views/ticket/view.html ---
|
|
|
|
|
|
--- File: ui/views/user/edit.html ---
|
|
|
|
|
|
<form method="POST" action="/user/{{@edit_user.id}}/update">
|
|
<div class="field">
|
|
<label class="label">Username</label>
|
|
<div class="control">
|
|
<input class="input" name="username" type="text" value="{{ @edit_user.username}}">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label class="label">Role</label>
|
|
<div class="select">
|
|
<select class="select" name="role">
|
|
<option value="user">User</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Edit User</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/user/edit.html ---
|
|
|
|
|
|
--- File: ui/views/user/index.html ---
|
|
<div class="block">
|
|
<h1 class="title">All Users</h1>
|
|
|
|
<table class="table table-bordered is-fullwidth">
|
|
<tr>
|
|
<th>ID</th><th>Username</th><th>Role</th><th>Actions</th>
|
|
</tr>
|
|
|
|
<repeat group="{{ @users }}" value="{{ @u }}">
|
|
<tr>
|
|
<td>{{ @u.id }}</td>
|
|
<td>{{ @u.username }}</td>
|
|
<td>{{ @u.role_name }} ( {{ @u.role }} )</td>
|
|
</tr>
|
|
</repeat>
|
|
|
|
</table>
|
|
</div>
|
|
--- End File: ui/views/user/index.html ---
|
|
|
|
============================================================
|
|
End of Codebase
|
|
============================================================
|
|
--- End File: _codebase_output.txt ---
|
|
|
|
|
|
--- File: _codebase_schemafile.sql ---
|
|
|
|
--- End File: _codebase_schemafile.sql ---
|
|
|
|
|
|
--- File: composer.json ---
|
|
{
|
|
"name": "tp/tp_servicedesk",
|
|
"description": "",
|
|
"config": {
|
|
"vendor-dir": "lib"
|
|
},
|
|
"require": {
|
|
"bcosca/fatfree-core": "^3.9",
|
|
"erusev/parsedown": "^1.7",
|
|
"ezyang/htmlpurifier": "^4.18",
|
|
"erusev/parsedown-extra": "^0.8.1",
|
|
"singular-it/parsedown-checkbox": "^0.3.5"
|
|
}
|
|
}
|
|
|
|
--- End File: composer.json ---
|
|
|
|
|
|
--- File: package.json ---
|
|
{
|
|
"dependencies": {
|
|
"bulma": "^1.0.3"
|
|
},
|
|
"scripts": {
|
|
"sass": "sass scss/main.scss public/css/main.css",
|
|
"sass:min": "sass scss/main.scss public/css/main.min.css --style compressed",
|
|
"sass:watch": "sass --watch scss/main.scss:public/css/main.css"
|
|
}
|
|
}
|
|
|
|
--- End File: package.json ---
|
|
|
|
|
|
--- File: app/config/routes.ini ---
|
|
[routes]
|
|
|
|
; home
|
|
GET /=HomeController->display
|
|
|
|
; auth
|
|
GET /login=AuthController->showLoginForm
|
|
POST /login=AuthController->login
|
|
GET /logout=AuthController->logout
|
|
|
|
; tickets - CRUD (CREATE, READ, UPDATE, DELETE)
|
|
GET /tickets=TicketController->index
|
|
GET /ticket/@id=TicketController->view
|
|
GET /ticket/create=TicketController->createForm
|
|
POST /ticket/create=TicketController->create
|
|
GET /ticket/@id/edit=TicketController->editForm
|
|
POST /ticket/@id/update=TicketController->update
|
|
GET /ticket/@id/delete=TicketController->delete
|
|
; additional routes - comments
|
|
POST /ticket/@id/comment=CommentController->create
|
|
GET /ticket/@id/comment/@comment_id/delete=CommentController->delete
|
|
GET /ticket/@id/comments=CommentController->index
|
|
; route for linking a child to a parent
|
|
POST /ticket/@id/add-subtask=TicketController->addSubtask
|
|
|
|
; attachments
|
|
GET /ticket/@id/attachments=AttachmentController->index
|
|
POST /ticket/@id/attachments/upload=AttachmentController->upload
|
|
GET /attachment/@id/download=AttachmentController->download
|
|
GET /attachment/@id/delete=AttachmentController->delete
|
|
GET /attachment/@id/view=AttachmentController->view
|
|
|
|
; knowledgebase
|
|
GET /kb=KBController->index
|
|
GET /kb/@id=KBController->view
|
|
GET /kb/create=KBController->createForm
|
|
POST /kb/create=KBController->create
|
|
GET /kb/@id/edit=KBController->editForm
|
|
POST /kb/@id/update=KBController->update
|
|
|
|
; tags
|
|
GET /tags=TagController->index
|
|
GET /tag/create=TagController->createForm
|
|
POST /tag/create=TagController->create
|
|
|
|
; parsedown preview
|
|
POST /parsedown/preview=ParsedownPreview->view
|
|
|
|
; toggle-theme
|
|
POST /toggle-theme = ThemeController->toggle
|
|
|
|
; dashboard
|
|
GET /dashboard=DashboardController->index
|
|
|
|
; projects
|
|
GET /projects=ProjectController->index
|
|
GET /project/@id=ProjectController->view
|
|
GET /project/create=ProjectController->createForm
|
|
POST /project/create=ProjectController->create
|
|
GET /project/@id/edit=ProjectController->editForm
|
|
POST /project/@id/update=ProjectController->update
|
|
|
|
; additional routes - user
|
|
GET /users=UserController->index
|
|
GET /user/@id/edit=UserController->editForm
|
|
POST /user/@id/update=UserController->update
|
|
|
|
|
|
|
|
; admin
|
|
GET /admin=Admin\HomeController->index
|
|
; admin/priority
|
|
GET /admin/priority=Admin\TicketOptionsController->listPriorities
|
|
GET /admin/priority/create=Admin\TicketOptionsController->createPriorityForm
|
|
POST /admin/priority/create=Admin\TicketOptionsController->createPriority
|
|
GET /admin/priority/@id/edit=Admin\TicketController->editPriorityForm
|
|
POST /admin/priority/@id/update=Admin\TicketController->updatePriority
|
|
GET /admin/priority/@id/delete=Admin\TicketController->deletePriority
|
|
; admin/status
|
|
GET /admin/status=Admin\TicketOptionsController->listStatuses
|
|
GET /admin/status/create=Admin\TicketOptionsController->createStatusForm
|
|
POST /admin/status/create=Admin\TicketOptionsController->createStatus
|
|
GET /admin/status/@id/edit=Admin\TicketController->editStatusForm
|
|
POST /admin/status/@id/update=Admin\TicketController->updateStatus
|
|
GET /admin/status/@id/delete=Admin\TicketController->deleteStatus
|
|
--- End File: app/config/routes.ini ---
|
|
|
|
|
|
--- File: app/controllers/AttachmentController.php ---
|
|
<?php
|
|
|
|
class AttachmentController {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list attachments
|
|
public function index($f3){
|
|
$this->check_access($f3);
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
// fetch attachments
|
|
|
|
$attachments = $db->exec(
|
|
'SELECT a.*, u.username
|
|
FROM attachments a
|
|
LEFT JOIN users u ON u.id = a.uploaded_by
|
|
WHERE a.ticket_id = ?
|
|
ORDER BY a.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
|
|
$f3->set('ticket_id', $ticket_id);
|
|
$f3->set('attachments', $attachments);
|
|
|
|
$f3->set('content', '../ui/views/attachment/index.html');
|
|
// echo \Template::instance()->render('../ui/templates/layout.html');
|
|
echo \Template::instance()->render($f3->get('content'));
|
|
}
|
|
|
|
// handle file upload
|
|
public function upload($f3){
|
|
$this->check_access($f3);
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$uploaded_by = $f3->get('SESSION.user.id');
|
|
|
|
if(!isset($_FILES['attachment']) || $_FILES['attachment']['error'] !== UPLOAD_ERR_OK){
|
|
$f3->reroute('/ticket/'.$ticket_id.'/attachments');
|
|
}
|
|
|
|
$file_info = $_FILES['attachment'];
|
|
$original_name = $file_info['name'];
|
|
$tmp_path = $file_info['tmp_name'];
|
|
|
|
// create a unique file path
|
|
$upload_dir = '../storage/attachments/tickets/'.$ticket_id.'/';
|
|
if(!is_dir($upload_dir)){
|
|
mkdir($upload_dir, 0777, true);
|
|
}
|
|
|
|
// if file exists increment version
|
|
$db = $f3->get('DB');
|
|
$existing = $db->exec(
|
|
'SELECT * FROM attachments
|
|
WHERE ticket_id =? AND file_name = ?
|
|
ORDER BY version_number DESC
|
|
LIMIT 1',
|
|
[$ticket_id, $original_name]
|
|
);
|
|
|
|
$new_version = 1;
|
|
if($existing){
|
|
$new_version = $existing[0]['version_number'] + 1;
|
|
}
|
|
|
|
$final_path = $upload_dir.$new_version.'_'.$original_name;
|
|
|
|
// move file
|
|
move_uploaded_file($tmp_path, $final_path);
|
|
|
|
// store meta data in DB
|
|
$db->exec(
|
|
'INSERT INTO attachments
|
|
(ticket_id, path, file_name, version_number, uploaded_by, created_at)
|
|
VALUES (?,?,?,?,?,NOW())',
|
|
[$ticket_id, $final_path, $original_name, $new_version, $uploaded_by]
|
|
);
|
|
|
|
$f3->reroute('/ticket/'.$ticket_id.'');
|
|
}
|
|
|
|
// download attachment
|
|
public function download($f3){
|
|
$this->check_access($f3);
|
|
|
|
$attachment_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec('SELECT * FROM attachments WHERE id = ?', [$attachment_id]);
|
|
|
|
if(!$rows){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
$attachment = $rows[0];
|
|
$file_path = $attachment['path'];
|
|
$file_name = $attachment['file_name'];
|
|
|
|
// validate file exists
|
|
if(!file_exists($file_path)){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
// output headers for download
|
|
header('Content-Description: File Transfer');
|
|
header('Content-Type: application/octet-stream');
|
|
header('Content-Disposition: attachment; filename="'.basename($file_name).'"');
|
|
header('Content-Length: '. filesize($file_path));
|
|
|
|
// flush headers
|
|
flush();
|
|
|
|
// read file
|
|
readfile($file_path);
|
|
exit;
|
|
}
|
|
|
|
// delete an attachment
|
|
public function delete($f3){
|
|
$this->check_access($f3);
|
|
|
|
$attachment_id = (int) $f3->get('PARAMS.id');
|
|
$current_user = $f3->get('SESSION.user');
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec('SELECT * FROM attachments WHERE id =? LIMIT 1', [$attachment_id]);
|
|
if(!$rows){
|
|
$f3->error(404, "Attachment not found");
|
|
return;
|
|
}
|
|
|
|
$attachment = $rows[0];
|
|
// TODO: role or ownership
|
|
|
|
if(file_exists($attachment['path'])){
|
|
unlink($attachment['path']);
|
|
}
|
|
|
|
// remove DB row
|
|
$db->exec('DELETE FROM attachments WHERE id =?', [$attachment_id]);
|
|
}
|
|
|
|
// view attachment
|
|
public function view($f3){
|
|
$this->check_access($f3);
|
|
|
|
$attachment_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec('SELECt * FROM attachments WHERE id = ?', [$attachment_id]);
|
|
|
|
if(!$rows){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
$attachment = $rows[0];
|
|
$file_path = $attachment['path'];
|
|
$file_name = $attachment['file_name'];
|
|
|
|
if(!file_exists($file_path)){
|
|
$f3->error(404, "File not found");
|
|
return;
|
|
}
|
|
|
|
// detect mime type
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mime_type = finfo_file($finfo, $file_path);
|
|
finfo_close($finfo);
|
|
|
|
header('Content-Type: ' . $mime_type);
|
|
header('Content-Disposition: inline; filename="' . basename($file_name) . '"');
|
|
header('Content-Length: ' . filesize($file_path));
|
|
|
|
flush();
|
|
readfile($file_path);
|
|
exit;
|
|
}
|
|
}
|
|
--- End File: app/controllers/AttachmentController.php ---
|
|
|
|
|
|
--- File: app/controllers/AuthController.php ---
|
|
<?php
|
|
|
|
class AuthController {
|
|
|
|
|
|
public function showLoginForm($f3){
|
|
|
|
// store session errors or messages, then clear
|
|
$f3->set('error', $f3->get('SESSION.login_error'));
|
|
$f3->clear('SESSION.login_error');
|
|
|
|
// this can be in our controller base
|
|
$f3->set('content', '../ui/views/login.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('error');
|
|
}
|
|
|
|
public function login($f3){
|
|
$username = $f3->get('POST.username');
|
|
$password = $f3->get('POST.password');
|
|
|
|
$db = $f3->get('DB');
|
|
// query for user
|
|
$result = $db->exec(
|
|
'SELECT u.id, u.username, u.password, u.role, u.is_admin, r.role as role_name
|
|
FROM users u
|
|
LEFT JOIN roles r ON r.id = u.role
|
|
WHERE username =?
|
|
LIMIT 1', $username
|
|
);
|
|
|
|
// verifiy password
|
|
if($result){
|
|
$user = $result[0]; // first row
|
|
if(password_verify($password, $user['password'])){
|
|
// valid
|
|
$f3->set('SESSION.user', [
|
|
'id'=> $user['id'],
|
|
'username' => $user['username'],
|
|
'role' => $user['role'],
|
|
'role_name' => $user['role_name'],
|
|
'is_admin' => $user['is_admin']
|
|
]);
|
|
|
|
if($f3->exists('SESSION.redirect')){
|
|
$redirect = $f3->get('SESSION.redirect');
|
|
$f3->clear('SESSION.redirect');
|
|
$f3->reroute($redirect);
|
|
}
|
|
|
|
$f3->reroute('/dashboard');
|
|
} else {
|
|
$f3->set('SESSION.login_error', 'Invalid password');
|
|
}
|
|
} else {
|
|
// if here, login failed.
|
|
$f3->set('SESSION.login_error', 'Invalid username');
|
|
}
|
|
|
|
$f3->reroute('/login');
|
|
|
|
}
|
|
|
|
public function logout($f3){
|
|
$f3->clear('SESSION');
|
|
$f3->reroute('/');
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/AuthController.php ---
|
|
|
|
|
|
--- File: app/controllers/BaseController.php ---
|
|
<?php
|
|
|
|
abstract class BaseController
|
|
{
|
|
|
|
use RequiresAuth;
|
|
|
|
protected $f3;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->f3 = \Base::instance();
|
|
|
|
}
|
|
|
|
|
|
// helper function
|
|
|
|
protected function getDB()
|
|
{
|
|
return $this->f3->get('DB');
|
|
}
|
|
|
|
/**
|
|
* Enforce that the user is logged in before proceeding.
|
|
*/
|
|
protected function requireLogin()
|
|
{
|
|
// using trait
|
|
$this->check_access($this->f3);
|
|
return;
|
|
|
|
// abstract
|
|
if(!$this->f3->exists('SESSION.user')){
|
|
$this->f3->set('SESSION.redirect', $this->f3->get('PATH'));
|
|
$this->f3->reroute('/login');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enforce that the user is logged in AND is an admin before proceeding.
|
|
*/
|
|
protected function requireAdmin()
|
|
{
|
|
$this->requireLogin(); // First, ensure the user is logged in
|
|
|
|
// Check if the user is an admin (assuming 'is_admin' property in session)
|
|
if (!$this->f3->get('SESSION.user.is_admin')) {
|
|
// Optionally set an error message
|
|
$this->f3->set('SESSION.error', 'Admin access required.');
|
|
$this->f3->reroute('/'); // Redirect non-admins to home page
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up a main layout template and inject the specified view path
|
|
* optional $data to pass variables down to template
|
|
*/
|
|
protected function renderView(string $viewPath, array $data = []):void
|
|
{
|
|
foreach($data as $key => $value){
|
|
$this->f3->set($key, $value);
|
|
}
|
|
|
|
// set {{content}}
|
|
$this->f3->set('content', $viewPath);
|
|
|
|
// render tempalte
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
// clear SESSION.error
|
|
$this->f3->clear('SESSION.error');
|
|
}
|
|
}
|
|
--- End File: app/controllers/BaseController.php ---
|
|
|
|
|
|
--- File: app/controllers/CommentController.php ---
|
|
<?php
|
|
|
|
class CommentController {
|
|
|
|
/**
|
|
* Add a new comment to a ticket.
|
|
* Expects POST data: comment (text)
|
|
* Route: POST /ticket/@id/comment
|
|
*/
|
|
public function create($f3){
|
|
// check logged in
|
|
if(!$f3->exists('SESSION.user')){
|
|
$f3->reroute('/login');
|
|
}
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$comment_text = $f3->get('POST.comment');
|
|
$current_user_id = $f3->get('SESSION.user.id');
|
|
|
|
if(empty($comment_text)){
|
|
$f3->set('SESSION.error', 'ticket not updated. No content');
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
// insert comment
|
|
$db = $f3->get('DB');
|
|
$db->exec(
|
|
'INSERT INTO ticket_comments (ticket_id, comment, created_by, created_at)
|
|
VALUES (?, ?, ?, NOW())',
|
|
[$ticket_id, $comment_text, $current_user_id]
|
|
);
|
|
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
/**
|
|
* Delete an existing comment
|
|
* Route: GET /tickey/@id/comment/@comment_id/delete
|
|
*/
|
|
public function delete($f3){
|
|
if(!$f3->exists('SESSION.user')){
|
|
$f3->reroute('/login');
|
|
}
|
|
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$comment_id = (int) $f3->get('PARAMS.comment_id');
|
|
$current_user = $f3->get('SESSION.user');
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
//optional: check if user is allowed to delete comment.
|
|
// fetch who created the comment
|
|
$comment_row = $db->exec(
|
|
'SELECT created_by FROM ticket_comments WHERE id = ? AND ticket_id = ? LIMIT 1',
|
|
[$comment_id, $ticket_id]
|
|
);
|
|
if(!$comment_row){
|
|
$f3->set('SESSION.error', 'Error: Ticket comment ID not found.');
|
|
$f3->reroute('/ticket/'.$ticket_id);
|
|
}
|
|
$comment_owner = $comment_row[0]['created_by'];
|
|
// TODO: $is_admin = ()
|
|
if($current_user['id'] !== $comment_owner){
|
|
// no permission
|
|
$f3->set('SESSION.error', 'You do not have permission to delete this ticket');
|
|
$f3->reroute('/ticket/'. $ticket_id);
|
|
}
|
|
|
|
// Delete - addition, rather than delete, we set a delete flag
|
|
$db->exec('UPDATE ticket_comments SET deleted = 1 WHERE id = ?', [$comment_id]);
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
// view comments
|
|
public function index($f3){
|
|
$ticket_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
$results = $db->exec('
|
|
SELECT c.*, u.username AS author_name
|
|
FROM ticket_comments c
|
|
LEFT JOIN users u ON c.created_by = u.id
|
|
WHERE c.ticket_id = ?
|
|
ORDER BY c.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
$comments = $results;
|
|
$f3->set('comments', $comments);
|
|
|
|
echo \Template::instance()->render('../ui/views/comments/view.html');
|
|
}
|
|
}
|
|
--- End File: app/controllers/CommentController.php ---
|
|
|
|
|
|
--- File: app/controllers/DashboardController.php ---
|
|
<?php
|
|
|
|
class DashboardController extends BaseController {
|
|
|
|
function index($f3){
|
|
|
|
$this->requireLogin();
|
|
|
|
$this->renderView('/ui/views/dashboard.html');
|
|
}
|
|
}
|
|
--- End File: app/controllers/DashboardController.php ---
|
|
|
|
|
|
--- File: app/controllers/HomeController.php ---
|
|
<?php
|
|
|
|
class HomeController extends BaseController {
|
|
|
|
public function display($f3){
|
|
|
|
$this->renderView('/ui/views/home.html');
|
|
|
|
}
|
|
// ...
|
|
}
|
|
--- End File: app/controllers/HomeController.php ---
|
|
|
|
|
|
--- File: app/controllers/KBController.php ---
|
|
<?php
|
|
|
|
class KBController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
public function index($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$search_term = $f3->get('GET.search');
|
|
$tag_param = $f3->get('GET.tag');
|
|
|
|
// base query
|
|
$sql = 'SELECT a.* FROM kb a';
|
|
$args = [];
|
|
|
|
if($tag_param){
|
|
$sql .= '
|
|
JOIN kb_tags AS at ON a.id = at.article_id
|
|
JOIN tags t ON at.tag_id = t.id
|
|
WHERE t.name = ?
|
|
';
|
|
$args[] = $tag_param;
|
|
|
|
if($search_term){
|
|
$sql .= ' AND LOWER(a.title) LIKE LOWER(?)';
|
|
$args[] = '%' . $search_term . '%';
|
|
}
|
|
} else if ($search_term){
|
|
$sql .= ' WHERE LOWER(a.title) LIKE LOWER(?)';
|
|
$args[] = '%' . $search_term . '%';
|
|
}
|
|
|
|
$sql .= ' ORDER BY a.created_at DESC';
|
|
|
|
$articles = $db->exec($sql, $args);
|
|
|
|
// render
|
|
$f3->set('articles', $articles);
|
|
$f3->set('content', '../ui/views/kb/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
|
|
}
|
|
|
|
/**
|
|
* Form to create new article
|
|
*/
|
|
public function createForm($f3){
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$all_tags = $db->exec('SELECT * FROM tags ORDER BY name ASC');
|
|
$f3->set('all_tags', $all_tags);
|
|
|
|
// render
|
|
$f3->set('content', '../ui/views/kb/create.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
|
|
}
|
|
|
|
// handle POST
|
|
public function create($f3){
|
|
$this->check_access($f3);
|
|
|
|
$title = $f3->get('POST.title');
|
|
$content = $f3->get('POST.content');
|
|
$created_by = $f3->get('SESSION.user.id');
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
// insert
|
|
|
|
$db->exec(
|
|
'INSERT INTO kb (title, content, created_by, updated_by, created_at, updated_at)
|
|
VALUES (?,?,?,?, NOW(), NOW())',
|
|
[$title, $content, $created_by, $created_by]
|
|
);
|
|
|
|
$article_id = $db->lastInsertId();
|
|
|
|
// TODO: tags
|
|
|
|
$f3->reroute('/kb');
|
|
}
|
|
|
|
//
|
|
|
|
protected function check_kb_exists($article_id, $db, $f3){
|
|
$articles = $db->exec(
|
|
'SELECT * FROM kb WHERE id = ? LIMIT 1', [$article_id]
|
|
);
|
|
if(!$articles){
|
|
$f3->set('SESSION.error', 'Article not found');
|
|
$f3->reroute('/kb');
|
|
}
|
|
return $articles;
|
|
}
|
|
|
|
// view a single
|
|
public function view($f3){
|
|
$this->check_access($f3);
|
|
$article_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$articles = $this->check_kb_exists($article_id, $db, $f3);
|
|
|
|
$article = $articles[0];
|
|
$f3->set('article', $article);
|
|
|
|
// TODO: tags
|
|
$tags = $db->exec(
|
|
'SELECT t.* FROM tags AS t
|
|
JOIN kb_tags AS at ON t.id = at.tag_id
|
|
WHERE at.kb_id = ?',
|
|
[$article_id]
|
|
);
|
|
|
|
// render
|
|
$f3->set('content', '../ui/views/kb/view.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
}
|
|
|
|
/**
|
|
* Form to edit existing kb article
|
|
*/
|
|
public function editForm($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$article_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$articles = $this->check_kb_exists($article_id, $db, $f3);
|
|
|
|
$article = $articles[0];
|
|
$f3->set('article', $article);
|
|
|
|
// fetch current tags
|
|
$current_tag_ids = $db->exec(
|
|
'SELECT tag_id FROM kb_tags WHERE kb_id = ?', [$article_id]
|
|
);
|
|
|
|
$article_tag_ids = array_column($current_tag_ids, 'tag_id');
|
|
$f3->set('article_tag_ids', $article_tag_ids);
|
|
|
|
// render
|
|
$f3->set('js', 'kb_edit.js');
|
|
$f3->set('content', '../ui/views/kb/edit.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
$f3->clear('SESSION.error');
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle POST to edit existing article
|
|
*/
|
|
public function update($f3){
|
|
$this->check_access($f3);
|
|
$article_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$articles = $this->check_kb_exists($article_id, $db, $f3);
|
|
$article = $articles[0];
|
|
|
|
$title = $f3->get('POST.title');
|
|
$content = $f3->get('POST.content');
|
|
$updated_by = $f3->get('SESSION.user.id');
|
|
|
|
$db->exec(
|
|
'UPDATE kb
|
|
SET title=?, content=?, updated_by =?, updated_at = NOW()
|
|
WHERE id = ?',
|
|
[$title, $content, $updated_by, $article_id]
|
|
);
|
|
|
|
// update tags - first delete
|
|
$db->exec('DELETE FROM kb_tags WHERE kb_id = ?', [$article_id]);
|
|
|
|
$tags_id = $f3->get('POST.tags');
|
|
if(!empty($tags_id) && is_array($tags_id)){
|
|
foreach($tags_id as $tag_id){
|
|
$db->exec(
|
|
'INSERT IGNORE INTO kb_tags (article_id, tag_id) VALUES (?,?)',
|
|
[$article_id, $tag_id]
|
|
);
|
|
}
|
|
}
|
|
|
|
$f3->reroute('/kb/'.$article_id);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
--- End File: app/controllers/KBController.php ---
|
|
|
|
|
|
--- File: app/controllers/ParsedownPreview.php ---
|
|
<?php
|
|
|
|
class ParsedownPreview {
|
|
|
|
public function view($f3){
|
|
|
|
$preview_text = $f3->get('POST.content');
|
|
echo Parsedown::instance()->text($preview_text);
|
|
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/ParsedownPreview.php ---
|
|
|
|
|
|
--- File: app/controllers/ProjectController.php ---
|
|
<?php
|
|
|
|
class ProjectController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list all projects
|
|
public function index($f3){
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
|
|
// retrieve projects
|
|
$projects = $db->exec('SELECT * FROM projects ORDER BY created_at DESC');
|
|
|
|
$f3->set('projects', $projects);
|
|
|
|
|
|
$f3->set('content', '../ui/views/project/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
$f3->clear('SESSION.error');
|
|
}
|
|
|
|
// create a new project
|
|
public function createForm($f3){
|
|
$this->check_access($f3);
|
|
$f3->set('content', '../ui/views/project/create.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
}
|
|
|
|
public function create($f3){
|
|
|
|
}
|
|
|
|
// show project details including links, tickets, events, tasks
|
|
public function view($f3){
|
|
$this->check_access($f3);
|
|
|
|
$project_id = $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$result = $db->exec(
|
|
'SELECT * FROM projects WHERE id = ? LIMIT 1', [$project_id]
|
|
);
|
|
$project = $result[0];
|
|
$f3->set('project', $project);
|
|
|
|
$f3->set('content', '../ui/views/project/view.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
// update project details
|
|
public function editForm($f3){
|
|
|
|
$this->check_access($f3);
|
|
$f3->set('content', '../ui/views/project/edit.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
|
|
}
|
|
|
|
public function update($f3){}
|
|
|
|
}
|
|
--- End File: app/controllers/ProjectController.php ---
|
|
|
|
|
|
--- File: app/controllers/TagController.php ---
|
|
<?php
|
|
|
|
class TagController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
/**
|
|
* List all tags
|
|
*/
|
|
public function index($f3){
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$tags = $db->exec('SELECT * FROM tags ORDER BY name ASC');
|
|
$f3->set('tags', $tags);
|
|
|
|
$f3->set('content', '../ui/views/tag/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function createForm($f3){
|
|
$this->check_access($f3);
|
|
|
|
$f3->set('content', '../ui/views/tag/create.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function create($f3){
|
|
$this->check_access($f3);
|
|
|
|
$name = $f3->get('POST.name');
|
|
$color = $f3->get('POST.color');
|
|
$db = $f3->get('DB');
|
|
|
|
// insert new tag
|
|
$db->exec('INSERT IGNORE INTO tags (name, color) VALUES (?, ?)', [$name, $color]);
|
|
$f3->reroute('/tags');
|
|
}
|
|
|
|
public function view($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function editForm($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function update($f3)
|
|
{
|
|
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/TagController.php ---
|
|
|
|
|
|
--- File: app/controllers/ThemeController.php ---
|
|
<?php
|
|
|
|
class ThemeController
|
|
{
|
|
function toggle($f3)
|
|
{
|
|
$current = $f3->get('SESSION.theme') ?: 'light';
|
|
$new_theme = ($current === 'light') ? 'dark' : 'light';
|
|
$f3->set('SESSION.theme', $new_theme);
|
|
|
|
$f3->reroute($f3->get('HEADERS.Referer') ?: '/');
|
|
}
|
|
}
|
|
--- End File: app/controllers/ThemeController.php ---
|
|
|
|
|
|
--- File: app/controllers/TicketController.php ---
|
|
<?php
|
|
|
|
class TicketController extends BaseController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list all tickts
|
|
public function index($f3){
|
|
|
|
$this->requireLogin();
|
|
|
|
$filter = $f3->get('GET.status');
|
|
|
|
// retrieve tickets
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
|
|
if($filter){
|
|
$tickets = $ticket_mapper->findFiltered($filter);
|
|
} else {
|
|
$tickets = $ticket_mapper->findAll();
|
|
}
|
|
|
|
// render
|
|
$this->renderView('../ui/views/ticket/index.html',
|
|
['tickets' => $tickets]
|
|
);
|
|
|
|
$f3->clear('SESSION.error');
|
|
}
|
|
|
|
// view a single ticket
|
|
// TODO_PROJECTS: show a link back to the related project
|
|
public function view($f3){
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = $f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
// render
|
|
$this->renderView('../ui/views/ticket/view.html', [
|
|
'ticket' => $ticket,
|
|
'attachments' => $ticket->attachments(),
|
|
'comments' => $ticket->comments(),
|
|
'parent_tickets' => $ticket->getParentTickets(),
|
|
'child_tickets' => $ticket->getChildTickets(),
|
|
'ticket_meta' => $ticket->getMetaAssoc()
|
|
]);
|
|
|
|
}
|
|
|
|
// show create form
|
|
// TODO_PROJECTS: dropdown to associate ticket with project
|
|
public function createForm($f3){
|
|
$db = $this->getDB();
|
|
$priorities = (new TicketPriority($db))->findAll();
|
|
$statuses = (new TicketStatus($db))->findAll();
|
|
|
|
$this->requireLogin();
|
|
$this->renderView('../ui/views/ticket/create.html',[
|
|
'priorities' => $priorities,
|
|
'statuses' => $statuses
|
|
]);
|
|
}
|
|
|
|
// handle POST
|
|
// including custom forms
|
|
public function create($f3){
|
|
|
|
$this->requireLogin();
|
|
|
|
$data = [
|
|
'title' => $this->f3->get('POST.title'),
|
|
'created_at' => $this->f3->get('POST.created_at'),
|
|
'description' => $this->f3->get('POST.description'),
|
|
'priority_id' => $this->f3->get('POST.priority_id'),
|
|
'status_id' => $this->f3->get('POST.status_id'),
|
|
'created_by' => $this->f3->get('SESSION.user.id')
|
|
];
|
|
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$new_ticket_id = $ticket_mapper->createTicket($data);
|
|
|
|
// custom field
|
|
$meta_keys = $this->f3->get('POST.meta_key');
|
|
$meta_values = $this->f3->get('POST.meta_value');
|
|
$meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values);
|
|
$ticket_mapper->setCustomFields($meta_assoc);
|
|
|
|
$this->f3->reroute('/ticket/' . $new_ticket_id);
|
|
}
|
|
|
|
// show edit form
|
|
// including custom forms
|
|
// TODO_PROJECTS: allow reasssigning or removing a project association
|
|
public function editForm($f3)
|
|
{
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = $f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Ticket not found.');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
//
|
|
$f3->set('js', 'markdown_preview.js');
|
|
|
|
// dropdowns
|
|
$priorities = (new TicketPriority($this->getDB()))->findAll();
|
|
$statuses = (new TicketStatus($this->getDB()))->findAll();
|
|
|
|
$this->renderView('../ui/views/ticket/edit.html',[
|
|
'ticket' => $ticket,
|
|
'ticket_meta' => $ticket->getMeta(),
|
|
'priorities' => $priorities,
|
|
'statuses' => $statuses
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
|
|
// process edit POST TODO: if assigned or admin
|
|
public function update($f3)
|
|
{
|
|
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = $this->f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Ticket not found.');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
$data = [
|
|
'title' => $this->f3->get('POST.title'),
|
|
'created_at' => $this->f3->get('POST.created_at'),
|
|
'description' => $this->f3->get('POST.description'),
|
|
'priority_id' => $this->f3->get('POST.priority_id'),
|
|
'status_id' => $this->f3->get('POST.status_id'),
|
|
'updated_by' => $this->f3->get('SESSION.user.id')
|
|
];
|
|
$ticket->updateTicket($data);
|
|
|
|
// deal with meta data / custom fields
|
|
$meta_keys = $this->f3->get('POST.meta_key');
|
|
$meta_values = $this->f3->get('POST.meta_value');
|
|
$meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values);
|
|
$ticket->setCustomFields($meta_assoc);
|
|
|
|
$f3->reroute('/ticket/' . $ticket_id);
|
|
}
|
|
|
|
// subtask
|
|
public function addSubtask($f3){
|
|
$this->requireLogin();
|
|
|
|
$parent_id = (int) $f3->get('PARAMS.id');
|
|
$child_id = (int) $f3->get('POST.child_ticket_id');
|
|
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($parent_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Parent Ticket not found');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
$ticket->addChildTicket($child_id);
|
|
$this->f3->reroute('/ticket/' . $parent_id);
|
|
}
|
|
|
|
public function delete(): void
|
|
{
|
|
$this->requireLogin();
|
|
|
|
$ticket_id = (int)$this->f3->get('PARAMS.id');
|
|
$ticket_mapper = new Ticket($this->getDB());
|
|
$ticket = $ticket_mapper->findById($ticket_id);
|
|
|
|
if(!$ticket){
|
|
$this->f3->set('SESSION.error', 'Ticket not found');
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
$ticket->softDelete();
|
|
$this->f3->reroute('/tickets');
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/TicketController.php ---
|
|
|
|
|
|
--- File: app/controllers/UserController.php ---
|
|
<?php
|
|
|
|
class UserController implements CRUD {
|
|
|
|
use RequiresAuth;
|
|
|
|
// list all users (admin only)
|
|
|
|
public function index($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$db = $f3->get('DB');
|
|
$users = $db->exec(
|
|
'SELECT u.*, r.role AS role_name
|
|
FROM users u
|
|
LEFT JOIN roles r ON r.id = u.role
|
|
ORDER BY id ASC'
|
|
);
|
|
$f3->set('users', $users);
|
|
|
|
$f3->set('content', '../ui/views/user/index.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function editForm($f3){
|
|
$this->check_access($f3);
|
|
|
|
$user_id = (int) $f3->get('PARAMS.id');
|
|
$db = $f3->get('DB');
|
|
|
|
$rows = $db->exec(
|
|
'SELECt * FROM users WHERE id = ? LIMIT 1',
|
|
[$user_id]
|
|
);
|
|
if(!$rows){
|
|
$f3->reroute('/users');
|
|
}
|
|
$f3->set('edit_user', $rows[0]);
|
|
$f3->set('content', '../ui/views/user/edit.html');
|
|
echo \Template::instance()->render('../ui/templates/layout.html');
|
|
}
|
|
|
|
public function update($f3){
|
|
|
|
$this->check_access($f3);
|
|
|
|
$user_id = (int) $f3->get('PARAMS.id');
|
|
$new_username = $f3->get('POST.username');
|
|
// $new_role = $f3->get('POST.role_name')
|
|
$db = $f3->get('DB');
|
|
$db->exec(
|
|
'UPDATE users SET username = ? WHERE id =? LIMIT 1',
|
|
[$new_username, $user_id]);
|
|
$f3->reroute('/users');
|
|
}
|
|
|
|
public function createForm($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function create($f3)
|
|
{
|
|
|
|
}
|
|
|
|
public function view($f3)
|
|
{
|
|
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/UserController.php ---
|
|
|
|
|
|
--- File: app/controllers/Admin/HomeController.php ---
|
|
<?php
|
|
|
|
namespace Admin;
|
|
|
|
class HomeController extends \BaseController
|
|
{
|
|
public function index($f3)
|
|
{
|
|
$this->renderView('/ui/views/admin/index.html');
|
|
}
|
|
}
|
|
--- End File: app/controllers/Admin/HomeController.php ---
|
|
|
|
|
|
--- File: app/controllers/Admin/TicketOptionsController.php ---
|
|
<?php
|
|
|
|
namespace Admin;
|
|
|
|
class TicketOptionsController extends \BaseController
|
|
{
|
|
public function listPriorities()
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin(); // Added admin check
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priorities = $model->findAll();
|
|
|
|
$this->renderView('/ui/views/admin/priorities/index.html', [
|
|
'priorities' => $priorities
|
|
]);
|
|
}
|
|
|
|
public function createPriorityForm()
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin(); // Added admin check
|
|
$this->renderView('/ui/views/admin/priorities/create.html');
|
|
}
|
|
|
|
public function createPriority()
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin(); // Added admin check
|
|
$p = new \TicketPriority($this->getDB());
|
|
$p->name = $this->f3->get('POST.name');
|
|
$p->sort_order = $this->f3->get('POST.sort_order');
|
|
$p->save();
|
|
|
|
// Redirect after save
|
|
$this->f3->reroute('/admin/priorities');
|
|
}
|
|
|
|
public function editPriorityForm($f3, $params)
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin();
|
|
$priorityId = $params['id'];
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priority = $model->load(['id = ?', $priorityId]);
|
|
|
|
if (!$priority) {
|
|
$f3->error(404, 'Priority not found');
|
|
return;
|
|
}
|
|
|
|
$this->renderView('/ui/views/admin/priorities/edit.html', [
|
|
'priority' => $priority
|
|
]);
|
|
}
|
|
|
|
public function updatePriority($f3, $params)
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin();
|
|
$priorityId = $params['id'];
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priority = $model->load(['id = ?', $priorityId]);
|
|
|
|
if (!$priority) {
|
|
$f3->error(404, 'Priority not found');
|
|
return;
|
|
}
|
|
|
|
$priority->name = $this->f3->get('POST.name');
|
|
$priority->sort_order = $this->f3->get('POST.sort_order');
|
|
$priority->save();
|
|
|
|
// Redirect after update
|
|
$this->f3->reroute('/admin/priorities');
|
|
}
|
|
|
|
public function deletePriority($f3, $params)
|
|
{
|
|
$this->requireLogin();
|
|
$this->requireAdmin();
|
|
$priorityId = $params['id'];
|
|
|
|
$model = new \TicketPriority($this->getDB());
|
|
$priority = $model->load(['id = ?', $priorityId]);
|
|
|
|
if (!$priority) {
|
|
// Optionally show an error message or just redirect
|
|
$this->f3->reroute('/admin/priorities');
|
|
return;
|
|
}
|
|
|
|
$priority->erase();
|
|
|
|
// Redirect after delete
|
|
$this->f3->reroute('/admin/priorities');
|
|
}
|
|
|
|
}
|
|
--- End File: app/controllers/Admin/TicketOptionsController.php ---
|
|
|
|
|
|
--- File: app/controllers/Admin/UserController.php ---
|
|
<?php
|
|
|
|
namespace Admin;
|
|
|
|
class UserController extends \BaseController implements \CRUD
|
|
{
|
|
public function index($f3)
|
|
{
|
|
// TODO: Implement index() method.
|
|
}
|
|
|
|
public function createForm($f3)
|
|
{
|
|
// TODO: Implement createForm() method.
|
|
}
|
|
|
|
public function create($f3)
|
|
{
|
|
// TODO: Implement create() method.
|
|
}
|
|
|
|
public function view($id)
|
|
{
|
|
// TODO: Implement view() method.
|
|
}
|
|
|
|
public function editForm($id)
|
|
{
|
|
// TODO: Implement editForm() method.
|
|
}
|
|
|
|
public function update($id)
|
|
{
|
|
// TODO: Implement update() method.
|
|
}
|
|
|
|
|
|
|
|
}
|
|
--- End File: app/controllers/Admin/UserController.php ---
|
|
|
|
|
|
--- File: app/extensions/BulmaFormHelper.php ---
|
|
<?php
|
|
|
|
class BulmaFormHelper extends \Prefab {
|
|
|
|
const H_FIELD_INPUT = 1;
|
|
const H_FIELD_TEXTAREA = 2;
|
|
const H_FIELD_SELECT = 3;
|
|
const H_FIELD_SELECT_NEW = 4;
|
|
|
|
const FIELD_INPUT = 10;
|
|
const FIELD_TEXTAREA = 11;
|
|
const FIELD_SELECT = 13;
|
|
|
|
static public function render($node) {
|
|
|
|
$attr = $node['@attrib'] ?? [];
|
|
$type = strtoupper($attr['type']) ?? null;
|
|
|
|
// all *
|
|
$label = $attr['label'] ?? '';
|
|
$name = $attr['name'] ?? '';
|
|
$value = $attr['value'] ?? '';
|
|
$class = $attr['class'] ?? '';
|
|
// select
|
|
$options = $attr['options'] ?? [];
|
|
$selected = $attr['selected'] ?? [];
|
|
// textarea
|
|
$rows = $attr['rows'] ?? '';
|
|
|
|
$label = \Template::instance()->build($label);
|
|
$name = \Template::instance()->build($name);
|
|
$value = \Template::instance()->build($value);
|
|
$selected = \Template::instance()->build($selected);
|
|
|
|
if(defined("BulmaFormHelper::$type")){
|
|
|
|
$type_const = constant("BulmaFormHelper::$type");
|
|
|
|
switch( $type_const ){
|
|
case BulmaFormHelper::H_FIELD_INPUT:
|
|
return BulmaFormHelper::build_h_field_input($label, $name, $value);
|
|
break;
|
|
case BulmaFormHelper::H_FIELD_TEXTAREA:
|
|
return BulmaFormHelper::build_h_field_textarea($label, $name, $value);
|
|
break;
|
|
case BulmaFormHelper::H_FIELD_SELECT:
|
|
return BulmaFormHelper::build_h_field_select($label, $name, $options, $selected);
|
|
break;
|
|
case BulmaFormHelper::H_FIELD_SELECT_NEW:
|
|
return BulmaFormHelper::build_h_field_select_new($attr);
|
|
break;
|
|
case BulmaFormHelper::FIELD_INPUT:
|
|
return BulmaFormHelper::build_field_input($label, $name, $value, $class);
|
|
break;
|
|
case BulmaFormHelper::FIELD_TEXTAREA:
|
|
return BulmaFormHelper::build_field_textarea($label, $name, $value, $class, $rows);
|
|
break;
|
|
case BulmaFormHelper::FIELD_SELECT:
|
|
return BulmaFormHelper::build_field_select($attr);
|
|
break;
|
|
default:
|
|
return '<div class="notification is-danger">Error: Bulma CSS Form TYPE ('.$type.') not defined.</div>';
|
|
break;
|
|
}
|
|
|
|
} else {
|
|
return '<div class="notification is-danger">Error: Bulma CSS Form TYPE not defined.</div>';
|
|
}
|
|
|
|
}
|
|
|
|
static function build_field_input($label, $name, $value, $class, $rows=10){
|
|
|
|
$string_label = $label !== '' ? sprintf('<label class="label">%1$s</label>', $label) : '';
|
|
$string = '
|
|
<div class="field %4$s">
|
|
%1$s
|
|
<div class="control">
|
|
<input class="input" id="%2$s" name="%2$s" type="text" placeholder="" value="%3$s">
|
|
</div>
|
|
</div>
|
|
';
|
|
return sprintf($string, $string_label, $name, $value, $class, $rows);
|
|
}
|
|
|
|
static function build_field_textarea($label, $name, $value, $class, $rows=10)
|
|
{
|
|
$string_label = $label !== '' ? sprintf('<label class="label">%1$s</label>', $label) : '';
|
|
$string = '
|
|
<div class="field %4$s">
|
|
%1$s
|
|
<div class="control">
|
|
<textarea class="textarea" id="%2$s" name="%2$s" rows="%5$s">%3$s</textarea>
|
|
</div>
|
|
</div>
|
|
';
|
|
return sprintf($string, $string_label, $name, $value, $class,$rows);
|
|
}
|
|
|
|
static function build_h_field_textarea($label, $name, $value){
|
|
$string = '
|
|
<div class="field is-horizontal">
|
|
<div class="field-label is-normal">
|
|
<label class="label">'.$label.'</label>
|
|
</div>
|
|
<div class="field-body">
|
|
<div class="field">
|
|
<div class="control">
|
|
<textarea class="textarea" id="'.$name.'" name="'.
|
|
$name.'">'.$value.'</textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
return $string;
|
|
}
|
|
|
|
static function build_h_field_input($label, $name, $value){
|
|
$string = '
|
|
<div class="field is-horizontal">
|
|
<div class="field-label is-normal">
|
|
<label class="label">'.$label.'</label>
|
|
</div>
|
|
<div class="field-body">
|
|
<div class="field">
|
|
<div class="control">
|
|
<input class="input" type="text" id="'.$name.'" name="'.
|
|
$name.'" value="'.
|
|
$value.'">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
return $string;
|
|
}
|
|
|
|
|
|
/**
|
|
* build_field_select_new
|
|
*
|
|
* `<bulma type="H_FIELD_SELECT" label="Priority:" name="priority_id"
|
|
* options="priorities" option_value="id" option_name="name"
|
|
* selected="{{@ticket.priority_id}}"></bulma>`
|
|
*
|
|
* @param mixed $attr
|
|
* @return void
|
|
*/
|
|
static function build_field_select($attr)
|
|
{
|
|
$f3 = \Base::instance();
|
|
|
|
$class = $attr['class'] ?? '';
|
|
$label = $attr['label'] ?? '';
|
|
$name = $attr['name'] ?? '';
|
|
// $options_arr = $attr['options'] ?? [];
|
|
$option_value = $attr['option_value'] ?? 'id';
|
|
$option_name = $attr['option_name'] ?? 'name';
|
|
|
|
$options = \Template::instance()->token($attr['options']);
|
|
$selected = \Template::instance()->token($attr['selected']);
|
|
|
|
// TODO: label - this could be moved into a seperate function
|
|
$html_label = $label !== '' ? sprintf('<label class="label">%1$s</label>', $label) : '';
|
|
|
|
$tmp_options = '<?php echo \BulmaFormHelper::instance()->field_select('.
|
|
$options.', '.$selected.', "'.$option_value.'", "'.$option_name.'"); ?>';
|
|
|
|
$html = '
|
|
<div class="field %4$s">
|
|
%1$s
|
|
<div class="control">
|
|
<div class="select">
|
|
<select name="%3$s" id="%3$s">
|
|
%2$s
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
|
|
return sprintf($html, $html_label, $tmp_options, $name, $class);
|
|
}
|
|
|
|
function field_select($options, $selected, $option_value, $option_name){
|
|
$html_options = '';
|
|
foreach ($options as $option) {
|
|
$value = $option[$option_value] ?? '';
|
|
$text = $option[$option_name] ?? '';
|
|
$html_selected = ((string)$value === (string)$selected) ? ' selected="selected"' : '';
|
|
$html_option = '<option value="%s"%s>%s</option>';
|
|
$html_options .= sprintf($html_option, $value, $html_selected, $text);
|
|
}
|
|
echo $html_options;
|
|
}
|
|
|
|
static function build_h_field_select_new($attr)
|
|
{
|
|
$f3 = \Base::instance();
|
|
|
|
$label = $attr['label'] ?? '';
|
|
$name = $attr['name'] ?? '';
|
|
$options_arr = $attr['options'] ?? [];
|
|
$optionValue = $attr['option_value'] ?? 'id';
|
|
$optionName = $attr['option_name'] ?? 'name';
|
|
$selected = $attr['selected'] ?? '';
|
|
|
|
$options = $f3->get($options_arr);
|
|
|
|
$html = '<div class="field is-horizontal"><div class="field-label is-normal">';
|
|
if (!empty($label)) {
|
|
$html .= '<label class="label">'.$label.'</label>';
|
|
}
|
|
$html .= '</div><div class="field-body"><div class="field">';
|
|
$html .= '<div class="select">';
|
|
$html .= '<select name="'.$name.'">';
|
|
|
|
foreach ($options as $option) {
|
|
$value = $option[$optionValue] ?? '';
|
|
$text = $option[$optionName] ?? '';
|
|
$sel = ((string)$value === (string)$selected) ? ' selected="selected"' : '';
|
|
$html .= '<option value="'.$value.'"'.$sel.'>'.$text.'</option>';
|
|
}
|
|
|
|
$html .= '</select>';
|
|
$html .= '</div></div></div></div>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
|
|
|
|
static function build_h_field_select($label, $name, $options, $selected){
|
|
$opts = json_decode(str_replace("'", '"', $options));
|
|
$opts_string = "";
|
|
foreach($opts as $k => $v){
|
|
if($v == $selected){
|
|
$selected_str = " selected";
|
|
} else {
|
|
$selected_str = "";
|
|
}
|
|
$opts_string .= '<option'.$selected_str.'>'.$v.'</option>';
|
|
}
|
|
|
|
$string =
|
|
'<div class="field is-horizontal">
|
|
<div class="field-label is-normal">
|
|
<label class="label">'.$label.'</label>
|
|
</div>
|
|
<div class="field-body">
|
|
<div class="field">
|
|
<div class="select">
|
|
<select id="'.$name.'" name="'.$name.'">
|
|
'.$opts_string.'
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
';
|
|
return $string;
|
|
}
|
|
|
|
}
|
|
|
|
\Template::instance()->extend('bulma', 'BulmaFormHelper::render');
|
|
--- End File: app/extensions/BulmaFormHelper.php ---
|
|
|
|
|
|
--- File: app/extensions/IconsHelper.php ---
|
|
<?php
|
|
|
|
class IconsHelper extends \Prefab {
|
|
|
|
static public $status_icons = [
|
|
'open' => ['fas fa-circle-dot has-text-success', "new"],
|
|
'in_progress' => ['fas fa-circle-play has-text-link', "reload"],
|
|
'on_hold' => ['fas fa-pause-circle has-text-warning',"pause"],
|
|
'completed' => ['fas fa-check has-text-danger', "check"]
|
|
];
|
|
|
|
static public $status_names = [
|
|
'open' => 'Open',
|
|
'in_progress' => 'In Progress',
|
|
'on_hold' => 'On Hold',
|
|
'completed' => 'Completed'
|
|
];
|
|
|
|
static public $priority_icons = [
|
|
'Low' => ['fas fa-circle-down',"green"],
|
|
'Medium' => ['fas fa-circle-dot', "yellow"],
|
|
'High' => ['fas fa-circle-up', "red"]
|
|
];
|
|
|
|
static public $priority_colors = [
|
|
'Low' => 'success',
|
|
'Medium' => 'warning',
|
|
'High' => 'danger',
|
|
'' => 'info'
|
|
];
|
|
|
|
static public function icons($node){
|
|
|
|
$attr = $node['@attrib'];
|
|
|
|
$tpl = Template::instance();
|
|
$f3 = Base::instance();
|
|
|
|
$context = $f3->hive();
|
|
$inner = $tpl->token($node[0], $context);
|
|
|
|
return '<?php echo IconsHelper::do_the_switch("' . $attr['type'] . '", ' . $inner . '); ?>';
|
|
|
|
}
|
|
|
|
static function do_the_switch($type, $value){
|
|
|
|
if($value !== null) {
|
|
$value = str_replace(' ', '_', strtolower($value));
|
|
}
|
|
$icon_class = '';
|
|
switch(strtolower($type)){
|
|
case 'status':
|
|
$icon_class = IconsHelper::$status_icons[$value] ?? ['fas fa-question-circle has-text-info', "🔲"];
|
|
break;
|
|
case 'priority':
|
|
$icon_class = IconsHelper::$priority_icons[$value] ?? ['fas fa-question-circle', "🔲"];
|
|
$icon_color = IconsHelper::$priority_colors[$value] ?? 'info';
|
|
break;
|
|
default:
|
|
$icon_class = 'fas fa-question-circle';
|
|
}
|
|
|
|
if($type == 'priority'){
|
|
// return '<p class="button"><span class="icon is-small">'
|
|
return '<span class="icon is-medium">
|
|
<i class="'.$icon_class[0].' fa-lg has-text-'.$icon_color.'"></i>
|
|
</span>';
|
|
} else {
|
|
return '<span class="icon is-medium"><i class="'.$icon_class[0].' fa-lg"></i></span>';
|
|
}
|
|
return '<span class="is-size-5">'.$icon_class[1].'</span>';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
\Template::instance()->extend('icons', 'IconsHelper::icons');
|
|
--- End File: app/extensions/IconsHelper.php ---
|
|
|
|
|
|
--- File: app/extensions/ParsedownHelper.php ---
|
|
<?php
|
|
|
|
class ParsedownHelper extends \Prefab {
|
|
|
|
static public function render($args) {
|
|
|
|
if(isset($args['@attrib']) && $args['@attrib']['inline'] === 'true'){
|
|
|
|
$return = \Parsedown::instance()->text($args[0]);
|
|
|
|
return '<!-- tableextension -->
|
|
<div class="content">
|
|
<parsedown_rendered>'.$return.'</parsedown_rendered>
|
|
</div>
|
|
';
|
|
|
|
}
|
|
|
|
// return '<pre>'.print_r($args,1).'</pre>';
|
|
|
|
$content = $args[0];
|
|
$content_token = \Template::instance()->token($content);
|
|
|
|
return '
|
|
<parsedown_rendered class="content">
|
|
<?php echo \ParsedownHelper::instance()->build('.$content_token.'); ?>
|
|
</parsedown_rendered>';
|
|
}
|
|
|
|
function build($content){
|
|
return \ParsedownTableExtension::instance()->text($content);
|
|
}
|
|
}
|
|
|
|
\Template::instance()->extend('parsedown', 'ParsedownHelper::render');
|
|
--- End File: app/extensions/ParsedownHelper.php ---
|
|
|
|
|
|
--- File: app/extensions/ParsedownTableExtension.php ---
|
|
<?php
|
|
|
|
class ParsedownTableExtension extends ParsedownCheckbox
|
|
{
|
|
protected function blockTable($Line, array $Block = null)
|
|
{
|
|
// Let Parsedown do its normal 'start-of-table' parsing.
|
|
$Block = parent::blockTable($Line, $Block);
|
|
|
|
// If this line didn't create or start a table, do nothing.
|
|
if (!isset($Block)) {
|
|
return null;
|
|
}
|
|
|
|
// Flag it so we know in blockTableComplete that this is a table block.
|
|
$Block['isMyTable'] = true;
|
|
|
|
return $Block;
|
|
}
|
|
|
|
protected function blockTableContinue($Line, array $Block)
|
|
{
|
|
// Continue letting Parsedown do its normal table parsing.
|
|
$Block = parent::blockTableContinue($Line, $Block);
|
|
return $Block;
|
|
}
|
|
|
|
protected function blockTableComplete(array $Block)
|
|
{
|
|
// Let Parsedown finalize the table structure.
|
|
// $Block = parent::blockTableComplete($Block);
|
|
// If we flagged this as our table block, wrap it now.
|
|
if (!empty($Block['isMyTable'])) {
|
|
// $Block['element'] should now be fully formed, e.g.:
|
|
// [
|
|
// 'name' => 'table',
|
|
// 'handler' => 'elements',
|
|
// 'text' => [ ... ],
|
|
// 'attributes' => [...],
|
|
// ]
|
|
|
|
// Add your custom class to the <table> itself:
|
|
if (!isset($Block['element']['attributes'])) {
|
|
$Block['element']['attributes'] = [];
|
|
}
|
|
$Block['element']['attributes']['class'] = 'table is-bordered';
|
|
|
|
// Wrap the <table> in a <div class="table-container">:
|
|
$wrapped = [
|
|
'name' => 'div',
|
|
'attributes' => [
|
|
'class' => 'table-container',
|
|
],
|
|
'handler' => 'elements',
|
|
'text' => [
|
|
$Block['element'], // the <table> itself
|
|
],
|
|
];
|
|
|
|
// Replace the original element with our wrapped version:
|
|
$Block['element'] = $wrapped;
|
|
}
|
|
|
|
return $Block;
|
|
}
|
|
}
|
|
--- End File: app/extensions/ParsedownTableExtension.php ---
|
|
|
|
|
|
--- File: app/interfaces/CRUD.php ---
|
|
<?php
|
|
|
|
interface CRUD {
|
|
|
|
|
|
// list all
|
|
public function index($f3);
|
|
|
|
// show form
|
|
public function createForm($f3);
|
|
|
|
// handle POST
|
|
public function create($f3);
|
|
|
|
// show single
|
|
public function view($f3);
|
|
|
|
// show edit form
|
|
public function editForm($f3);
|
|
|
|
// handle post
|
|
public function update($f3);
|
|
|
|
|
|
}
|
|
--- End File: app/interfaces/CRUD.php ---
|
|
|
|
|
|
--- File: app/models/Attachment.php ---
|
|
<?php
|
|
|
|
class Attachment extends \DB\SQL\Mapper {
|
|
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'attachments');
|
|
}
|
|
|
|
public function findWithUserByTicketId($ticket_id){
|
|
return $this->db->exec(
|
|
'SELECT a.*, u.username
|
|
FROM attachments a
|
|
LEFT JOIN users u ON u.id = a.uploaded_by
|
|
WHERE a.ticket_id = ?
|
|
ORDER BY a.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
}
|
|
|
|
}
|
|
--- End File: app/models/Attachment.php ---
|
|
|
|
|
|
--- File: app/models/Comment.php ---
|
|
<?php
|
|
|
|
class Comment extends \DB\SQL\Mapper {
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'ticket_comments');
|
|
}
|
|
|
|
public function findWithUserByTicketId($ticket_id){
|
|
return $this->db->exec(
|
|
'SELECT c.*, u.username AS author_name
|
|
FROM ticket_comments c
|
|
LEFT JOIN users u ON c.created_by = u.id
|
|
WHERE c.ticket_id = ?
|
|
ORDER BY c.created_at DESC',
|
|
[$ticket_id]
|
|
);
|
|
}
|
|
}
|
|
--- End File: app/models/Comment.php ---
|
|
|
|
|
|
--- File: app/models/Tag.php ---
|
|
<?php
|
|
|
|
class Tag extends \DB\SQL\Mapper
|
|
{
|
|
|
|
protected $tag_table, $tag_table_id;
|
|
|
|
function __construct($db, $type = null)
|
|
{
|
|
if($type == null){
|
|
// do tag mapping
|
|
parent::__construct($db, 'tags');
|
|
} else {
|
|
$this->tag_table = $type . '_tags';
|
|
$this->tag_table_id = $type . '_id';
|
|
parent::__construct($db, $this->tag_table);
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
// VERIFY: possible issue with this?
|
|
public function getTagsFor($objects, $id_key = 'id')
|
|
{
|
|
// echo $this->get('_type_id'); exit;
|
|
// printf('<pre>%s</pre>', print_r($this,1)); exit;
|
|
if(empty($objects)) return [];
|
|
$ids = array_column($objects, $id_key);
|
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
$sql =
|
|
'SELECT tt.%1$s, t.id, t.name, t.color
|
|
FROM %2$s tt
|
|
INNER JOIN tags t ON tt.tag_id = t.id
|
|
WHERE tt.%1$s IN (%3$s)';
|
|
$sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table, $placeholders);
|
|
$rows = $this->db->exec($sql_sprintf, $ids);
|
|
|
|
$tags_map = [];
|
|
foreach($rows as $row)
|
|
{
|
|
$tags_map[$row[$this->tag_table_id]][] = $row;
|
|
}
|
|
|
|
foreach($objects as &$object)
|
|
{
|
|
$object['tags'] = $tags_map[$object[$id_key]] ?? [];
|
|
}
|
|
return $objects;
|
|
|
|
}
|
|
|
|
public function getTagsForID($id, $id_key = 'id')
|
|
{
|
|
$sql = 'SELECT tt.%1$s, t.id, t.name, t.color
|
|
FROM %2$s tt
|
|
INNER JOIN tags t ON tt.tag_id = t.id
|
|
WHERE tt.%1$s = ?';
|
|
$sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table);
|
|
$rows = $this->db->exec($sql_sprintf, $id);
|
|
return $rows;
|
|
}
|
|
|
|
public function findLinkedTags($id = '')
|
|
{
|
|
$sql = '
|
|
SELECT t.name, t.color
|
|
FROM `?` tt
|
|
LEFT JOIN `tags` t ON t.id = tt.id
|
|
WHERE tt.`?` = ?
|
|
';
|
|
$params = [
|
|
$this->_type,
|
|
$this->_type_id,
|
|
$id
|
|
];
|
|
return $this->db->exec($sql, $params);
|
|
}
|
|
|
|
|
|
}
|
|
--- End File: app/models/Tag.php ---
|
|
|
|
|
|
--- File: app/models/Ticket.php ---
|
|
<?php
|
|
|
|
class Ticket extends \DB\SQL\Mapper {
|
|
function __construct($db){
|
|
parent::__construct($db, 'tickets');
|
|
|
|
}
|
|
|
|
/**
|
|
* Return all tickets in descending order of creation
|
|
*/
|
|
public function findAll(): array
|
|
{
|
|
$tickets = $this->db->exec(
|
|
'SELECT t.id, t.title, t.created_at,
|
|
tp.name AS priority_name, ts.name AS status_name, u.display_name
|
|
FROM tickets t
|
|
LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id
|
|
LEFT JOIN ticket_statuses ts ON t.status_id = ts.id
|
|
LEFT JOIN users u ON t.created_by = u.id
|
|
WHERE t.recycled = 0
|
|
ORDER BY t.created_at DESC'
|
|
);
|
|
$result = $this->getTagsForTickets($tickets);
|
|
return $result;
|
|
}
|
|
|
|
public function findFiltered(string $filter): array
|
|
{
|
|
$sql = '
|
|
SELECT t.*, tp.name AS priority_name, ts.name AS status_name, u.display_name
|
|
FROM tickets t
|
|
LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id
|
|
LEFT JOIN ticket_statuses ts ON t.status_id = ts.id
|
|
LEFT JOIN users u ON t.created_by = u.id
|
|
WHERE t.recycled = 0
|
|
';
|
|
$params = [];
|
|
switch($filter){
|
|
case 'open':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 1;
|
|
break;
|
|
case 'in_progress':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 2;
|
|
break;
|
|
case 'on_hold':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 3;
|
|
break;
|
|
case 'completed':
|
|
$sql .= ' AND status_id = ?';
|
|
$params[] = 4;
|
|
break;
|
|
}
|
|
|
|
$sql .= ' ORDER BY t.created_at DESC';
|
|
$tickets = $this->db->exec($sql, $params);
|
|
$result = $this->getTagsForTickets($tickets);
|
|
return $result;
|
|
}
|
|
|
|
public function getTagsForTickets(array $tickets)
|
|
{
|
|
$tag_mapper = new Tag($this->db, 'ticket');
|
|
$tickets = $tag_mapper->getTagsFor($tickets);
|
|
|
|
return $tickets;
|
|
}
|
|
|
|
public function findById($id): ?Ticket
|
|
{
|
|
$this->status_name = 'SELECT name FROM ticket_statuses WHERE tickets.status_id = ticket_statuses.id';
|
|
$this->priority_name = 'SELECT name FROM ticket_priorities WHERE tickets.priority_id = ticket_priorities.id';
|
|
$this->load(['id = ?', $id]);
|
|
$this->tags = (new Tag($this->db,'ticket'))->getTagsForID($id, 'ticket_id');
|
|
return $this->dry() ? null : $this;
|
|
}
|
|
|
|
public function createTicket(array $data): int
|
|
{
|
|
$this->reset();
|
|
|
|
$this->title = $data['title'] ?? '';
|
|
$this->description = $data['description'] ?? '';
|
|
//
|
|
$this->priority_id = $data['priority_id'] ?? null;
|
|
$this->status_id = $data['status_id'] ?? null;
|
|
//
|
|
$this->created_by = $data['created_by'] ?? null;
|
|
$this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s');
|
|
$this->updated_at = date('Y-m-d H:i:s');
|
|
|
|
$this->save();
|
|
return (int)$this->id;
|
|
}
|
|
|
|
public function updateTicket(array $data): void
|
|
{
|
|
if(isset($data['title'])){ $this->title = $data['title']; }
|
|
if(isset($data['description'])) { $this->description = $data['description']; }
|
|
if(isset($data['priority_id'])) { $this->priority_id = $data['priority_id']; }
|
|
if(isset($data['status_id'])) { $this->status_id = $data['status_id']; }
|
|
if(isset($data['updated_by'])) { $this->updated_by = $data['updated_by']; }
|
|
$this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s');
|
|
$this->updated_at = date('Y-m-d H:i:s');
|
|
$this->save();
|
|
}
|
|
|
|
public function softDelete():void {
|
|
$this->recycled = 1;
|
|
$this->save();
|
|
}
|
|
|
|
public function attachments(){
|
|
$attachment = new Attachment($this->db);
|
|
return $attachment->findWithUserByTicketId($this->id);
|
|
}
|
|
|
|
public function comments(){
|
|
$comment = new Comment($this->db);
|
|
return $comment->findWithUserByTicketId($this->id);
|
|
}
|
|
|
|
public function getParentTickets()
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT p.*
|
|
FROM ticket_relations r
|
|
INNER JOIN tickets p ON r.parent_ticket_id = p.id
|
|
WHERE r.child_ticket_id = ?',
|
|
[$this->id]
|
|
);
|
|
}
|
|
|
|
public function getChildTickets()
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT c.*
|
|
FROM ticket_relations r
|
|
INNER JOIN tickets c ON r.child_ticket_id = c.id
|
|
WHERE r.parent_ticket_id = ?',
|
|
[$this->id]
|
|
);
|
|
}
|
|
|
|
public function addChildTicket(int $childId)
|
|
{
|
|
$this->db->exec(
|
|
'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id)
|
|
VALUES (?, ?)',
|
|
[$this->id, $childId]
|
|
);
|
|
}
|
|
|
|
// meta data
|
|
public function getMeta()
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT id, meta_key, meta_value
|
|
FROM ticket_meta
|
|
WHERE ticket_id = ?',
|
|
[$this->id]
|
|
);
|
|
}
|
|
|
|
public function getMetaAssoc()
|
|
{
|
|
$rows = $this->getMeta();
|
|
$assoc = [];
|
|
foreach($rows as $row){
|
|
$assoc[$row['meta_key']] = $row['meta_value'];
|
|
}
|
|
return $assoc;
|
|
}
|
|
|
|
public function assocExistingMeta($meta_ids, $meta_keys, $meta_values){
|
|
if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){
|
|
$field_assoc = [];
|
|
foreach($meta_ids as $i => $m_id){
|
|
$key = $meta_keys[$i] ?? '';
|
|
$value = $meta_values[$i] ?? '';
|
|
if(!empty($key) && $value !== ''){
|
|
$field_assoc[$key] = $value;
|
|
}
|
|
}
|
|
return $field_assoc;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
public function assocMetaFromKeyValue($meta_keys, $meta_values)
|
|
{
|
|
if(is_array($meta_keys) && is_array($meta_values)){
|
|
$field_assoc = [];
|
|
foreach($meta_keys as $i => $key){
|
|
$val = $meta_values[$i] ?? '';
|
|
if(!empty($key) && $val != ''){
|
|
$field_assoc[$key] = $val;
|
|
}
|
|
}
|
|
return $field_assoc;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
public function setCustomFields(array $fields)
|
|
{
|
|
$this->db->exec(
|
|
'DELETE FROM ticket_meta WHERE ticket_id = ?', [$this->id]
|
|
);
|
|
|
|
foreach($fields as $key => $value){
|
|
$this->db->exec(
|
|
'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value)
|
|
VALUES (?, ?, ?)',
|
|
[$this->id, $key, $value]
|
|
);
|
|
}
|
|
}
|
|
|
|
}
|
|
--- End File: app/models/Ticket.php ---
|
|
|
|
|
|
--- File: app/models/TicketPriority.php ---
|
|
<?php
|
|
|
|
class TicketPriority extends \DB\SQL\Mapper
|
|
{
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'ticket_priorities');
|
|
}
|
|
|
|
public function findAll(): array
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT * FROM ticket_priorities ORDER BY sort_order ASC'
|
|
);
|
|
}
|
|
}
|
|
--- End File: app/models/TicketPriority.php ---
|
|
|
|
|
|
--- File: app/models/TicketStatus.php ---
|
|
<?php
|
|
|
|
class TicketStatus extends \DB\SQL\Mapper
|
|
{
|
|
function __construct($db)
|
|
{
|
|
parent::__construct($db, 'ticket_statuses');
|
|
}
|
|
|
|
public function findAll(): array
|
|
{
|
|
return $this->db->exec(
|
|
'SELECT * FROM ticket_statuses ORDER BY sort_order ASC'
|
|
);
|
|
}
|
|
}
|
|
--- End File: app/models/TicketStatus.php ---
|
|
|
|
|
|
--- File: app/traits/RequiresAuth.php ---
|
|
<?php
|
|
|
|
trait RequiresAuth {
|
|
|
|
public function check_access($f3){
|
|
if(!$f3->exists('SESSION.user')){
|
|
// $f3->set('SESSION.error', 'You don\'t have permission for this ticket.');
|
|
$f3->set('SESSION.redirect', $f3->get('PATH'));
|
|
$f3->reroute('/login');
|
|
}
|
|
}
|
|
|
|
}
|
|
--- End File: app/traits/RequiresAuth.php ---
|
|
|
|
|
|
--- File: public/index.php ---
|
|
<?php
|
|
|
|
require '../lib/autoload.php';
|
|
|
|
$f3 = \Base::instance();
|
|
$f3->config('../app/config/.env.cfg');
|
|
$f3->set('DEBUG', 3); // development debug
|
|
$f3->set('CACHE', FALSE);
|
|
|
|
/**
|
|
* Not required yet
|
|
*/
|
|
$htmlpurifier = \HTMLPurifier::instance();
|
|
// $htmlpurifier->purify($input);
|
|
$md = \ParsedownTableExtension::instance();
|
|
$md->setSafeMode(true);
|
|
|
|
$f3->set('EXT', [new ParsedownHelper, new BulmaFormHelper, new IconsHelper]);
|
|
|
|
$f3->set('DB', new \DB\SQL(
|
|
'mysql:host=localhost;port=3306;dbname=' . $f3->get('database.db_name'),
|
|
$f3->get('database.username'),
|
|
$f3->get('database.password')
|
|
));
|
|
|
|
new \DB\SQL\Session($f3->get('DB'));
|
|
$f3->set('SESSION.status', 'running');
|
|
|
|
$f3->run();
|
|
--- End File: public/index.php ---
|
|
|
|
|
|
--- File: public/logo.svg ---
|
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="300.000000pt" height="300.000000pt" viewBox="0 0 300.000000 300.000000" preserveAspectRatio="xMidYMid meet"> <g transform="translate(0.000000,300.000000) scale(0.100000,-0.100000)" fill="#03045e" stroke="none"> <path d="M465 2639 c-88 -12 -236 -51 -312 -83 l-58 -24 0 -1054 c0 -880 2 -1053 14 -1050 177 37 258 45 461 46 187 1 230 -2 328 -22 145 -29 257 -69 414 -148 70 -35 130 -64 133 -64 3 0 5 470 5 1044 l0 1044 -26 31 c-14 17 -55 50 -92 74 -256 170 -569 244 -867 206z m397 -313 c85 -17 147 -37 241 -79 l67 -30 0 -318 c0 -176 -2 -319 -5 -319 -4 0 -46 14 -95 31 l-88 31 -4 97 c-3 90 -5 101 -32 135 -16 22 -50 48 -80 62 -33 16 -43 24 -29 24 12 0 37 15 56 33 46 43 56 98 29 161 -26 59 -66 80 -139 74 -77 -7 -114 -45 -121 -122 -6 -68 21 -119 74 -138 l34 -12 -51 -24 c-76 -36 -103 -84 -108 -189 -3 -82 -3 -83 -30 -84 -14 0 -65 -10 -114 -23 -48 -12 -90 -20 -92 -17 -3 2 -5 156 -5 342 l0 339 23 5 c148 36 343 45 469 21z m26 -990 c99 -22 209 -59 285 -97 34 -17 64 -33 66 -35 1 -1 -7 -20 -19 -42 l-22 -40 -86 40 c-244 113 -543 132 -803 52 -12 -3 -19 6 -27 38 -6 24 -8 46 -4 49 8 8 123 35 202 49 78 13 322 5 408 -14z m-5 -281 c110 -23 321 -100 355 -130 2 -2 -6 -22 -18 -44 l-20 -41 -48 24 c-74 37 -213 83 -302 100 -154 30 -381 17 -536 -29 -18 -6 -23 -1 -32 37 -6 23 -8 46 -4 50 7 7 175 43 242 52 67 8 285 -3 363 -19z m-33 -286 c88 -13 219 -54 313 -96 42 -20 77 -38 77 -41 0 -11 -44 -82 -49 -79 -105 58 -268 111 -397 129 l-94 12 0 41 c0 23 3 44 6 48 6 6 30 3 144 -14z m-334 -23 c3 -19 4 -40 2 -47 -2 -7 -42 -20 -89 -29 -46 -10 -96 -21 -110 -25 -23 -6 -27 -3 -37 33 -7 22 -12 42 -12 45 0 7 62 25 145 43 92 19 93 18 101 -20z"/> <path d="M2180 2634 c-218 -35 -517 -171 -605 -275 l-25 -31 0 -1044 c0 -574 2 -1044 5 -1044 3 0 63 29 133 64 157 79 269 119 414 148 98 20 141 23 328 22 203 -1 284 -9 461 -46 12 -3 14 170 17 1049 l2 1052 -47 21 c-188 83 -472 118 -683 84z m118 -246 l3 -48 -34 0 c-92 0 -268 -47 -393 -105 l-72 -33 -22 40 c-12 22 -20 41 -19 42 9 8 110 55 159 74 81 31 165 53 255 68 118 18 119 18 123 -38z m352 15 c36 -9 68 -19 72 -22 11 -10 -13 -93 -26 -88 -10 4 -81 19 -193 43 -24 5 -29 26 -16 72 5 22 9 23 52 17 25 -4 75 -14 111 -22z m-180 -253 c93 -10 231 -38 250 -50 9 -6 9 -17 -1 -49 -7 -22 -13 -41 -15 -41 -1 0 -42 10 -91 22 -75 19 -114 23 -263 22 -156 0 -186 -3 -272 -27 -53 -14 -136 -44 -184 -66 -48 -23 -88 -41 -90 -41 -2 0 -13 18 -24 39 l-19 39 22 14 c47 31 197 88 279 108 155 37 274 45 408 30z m-15 -280 c94 -8 259 -41 272 -54 3 -2 -1 -23 -8 -46 -11 -38 -15 -41 -38 -36 -14 3 -55 13 -91 21 -103 25 -298 30 -411 11 -100 -16 -239 -61 -321 -102 -26 -13 -50 -24 -52 -24 -3 0 -15 18 -26 40 l-20 40 57 29 c129 64 282 108 428 121 44 4 85 8 90 8 6 1 60 -3 120 -8z m130 -398 l40 -7 3 -340 c1 -187 0 -345 -2 -351 -3 -8 -22 -7 -68 2 -168 36 -398 27 -581 -22 -51 -13 -104 -27 -120 -30 l-27 -7 2 328 3 328 95 34 c103 37 190 60 285 73 65 10 305 4 370 -8z"/> <path d="M2174 1248 c-15 -29 -27 -54 -29 -56 -1 -1 -30 -7 -64 -13 l-63 -11 47 -48 47 -47 -11 -58 c-6 -32 -11 -63 -11 -68 0 -4 25 6 56 24 l57 32 59 -33 c51 -28 59 -30 55 -14 -3 11 -9 41 -13 67 -6 48 -6 49 41 97 l48 48 -49 8 c-77 11 -89 19 -113 73 -12 28 -24 51 -26 51 -3 0 -17 -24 -31 -52z"/> </g> </svg>
|
|
--- End File: public/logo.svg ---
|
|
|
|
|
|
--- File: public/style.css ---
|
|
html, body {padding:0; margin:0;}
|
|
html, body, #sidebar, #page,#base_body {
|
|
min-height: 100%
|
|
}
|
|
|
|
#page { min-height: calc(100vh - 170px - 52px) }
|
|
i.fa { font-weight: 100 !important ; }
|
|
|
|
.table th.th-icon { width: 2rem; }
|
|
|
|
#ticket_list .g-flex-item { border-bottom: 1px solid var(--bulma-text-soft); }
|
|
|
|
a { word-break: break-word; }
|
|
|
|
/* parsedown check-checkbox */
|
|
li.parsedown-task-list {
|
|
list-style: none;
|
|
}
|
|
|
|
/* List Component */
|
|
.list{
|
|
--be-list-color:var(--bulma-text);
|
|
--be-list-item-description-color:var(--bulma-text-50);
|
|
--be-list-item-divider-color:var(--bulma-border);
|
|
--be-list-item-hover-color:var(--bulma-scheme-main-bis);
|
|
--be-list-item-image-margin:.75em;
|
|
--be-list-item-padding:.75em;
|
|
--be-list-item-title-color:var(--bulma-text-strong);
|
|
--be-list-item-title-weight:var(--bulma-weight-semibold);
|
|
color:var(--be-list-color);
|
|
flex-direction:column;
|
|
display:flex
|
|
}
|
|
.list.has-hidden-images .list-item-image{
|
|
display:none
|
|
}
|
|
.list.has-hoverable-list-items .list-item:hover{
|
|
background-color:var(--be-list-item-hover-color)
|
|
}
|
|
.list.has-overflow-ellipsis .list-item-content{
|
|
min-inline-size:0;
|
|
max-inline-size:calc(var(--length)*1ch)
|
|
}
|
|
.list.has-overflow-ellipsis .list-item-content>*{
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
overflow:hidden
|
|
}
|
|
@media (hover:hover){
|
|
.list:not(.has-visible-pointer-controls) .list-item-controls{
|
|
opacity:0;
|
|
visibility:hidden
|
|
}
|
|
}
|
|
.list .list-item{
|
|
align-items:center;
|
|
transition:background-color .125s ease-out;
|
|
display:flex;
|
|
position:relative;
|
|
/* TP: update + align top */
|
|
align-items: flex-start;
|
|
}
|
|
@media (hover:hover){
|
|
.list .list-item:hover .list-item-controls,.list .list-item:focus-within .list-item-controls{
|
|
opacity:initial;
|
|
visibility:initial
|
|
}
|
|
}
|
|
.list .list-item:not(.box){
|
|
padding-block:var(--be-list-item-padding);
|
|
padding-inline:var(--be-list-item-padding)
|
|
}
|
|
.list .list-item:not(:last-child):not(.box){
|
|
border-block-end:1px solid var(--be-list-item-divider-color)
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list:not(.has-overflow-ellipsis) .list .list-item{
|
|
flex-wrap:wrap
|
|
}
|
|
}
|
|
.list .list-item-image{
|
|
flex-shrink:0;
|
|
margin-inline-end:var(--be-list-item-image-margin);
|
|
/* TP: update + add margin-top */
|
|
margin-top: 0.5rem;
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list .list-item-image{
|
|
padding-block:.5rem;
|
|
padding-inline:0
|
|
}
|
|
}
|
|
.list .list-item-content{
|
|
flex-direction:column;
|
|
flex-grow:1;
|
|
display:flex
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list .list-item-content{
|
|
padding-block:.5rem;
|
|
padding-inline:0
|
|
}
|
|
}
|
|
.list .list-item-title{
|
|
color:var(--be-list-item-title-color);
|
|
font-weight:var(--be-list-item-title-weight);
|
|
margin-bottom: .25rem;
|
|
}
|
|
.list .list-item-description{
|
|
color:var(--be-list-item-description-color)
|
|
}
|
|
.list .list-item-controls{
|
|
flex-shrink:0;
|
|
transition:opacity .125s ease-out
|
|
}
|
|
@media screen and (width<=768px){
|
|
.list .list-item-controls{
|
|
flex-wrap:wrap;
|
|
padding-block:.5rem;
|
|
padding-inline:0
|
|
}
|
|
}
|
|
@media screen and (width>=769px),print{
|
|
.list .list-item-controls{
|
|
padding-inline-start:var(--be-list-item-padding)
|
|
}
|
|
.list:not(.has-visible-pointer-controls) .list .list-item-controls{
|
|
block-size:100%;
|
|
align-items:center;
|
|
padding-block-end:var(--be-list-item-padding);
|
|
display:flex;
|
|
position:absolute;
|
|
inset-inline-end:0
|
|
}
|
|
}
|
|
--- End File: public/style.css ---
|
|
|
|
|
|
--- File: public/test.md.php ---
|
|
<?php
|
|
?>
|
|
|
|
<head>
|
|
<script src="js/tp_md_editor.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
tp_md_editor.init({
|
|
groups: [
|
|
['h1', 'h2', 'h3'],
|
|
['bold', 'italic', 'quote'],
|
|
['link', 'code'],
|
|
['bullet', 'number', 'task'],
|
|
['hr', 'table']
|
|
]
|
|
});
|
|
});
|
|
</script>
|
|
<!-- font awesome -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
|
|
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
|
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
|
|
<style>
|
|
html { font-family: sans-serif; }
|
|
tp-md-editor { width: 50%; display: block; padding: .2em; }
|
|
tp-md-toolbar, tp-md-toolbar-group { display: flex; gap: .5em; }
|
|
tp-md-toolbar-group:not(:last-child) { border-right:1px solid #ccc; padding-right: .5em; }
|
|
tp-md-toolbar-button { width: 32px; height: 32px; align-content:center;}
|
|
tp-md-toolbar-button { display:inline-block; text-align:center; border: none; cursor: pointer; }
|
|
tp-md-toolbar-button:hover { color:royalblue; }
|
|
tp-md-editor textarea { border: 1px solid #ccc; padding: 1em; margin-top: 1em; width: 100%; }
|
|
tp-md-editor textarea:focus-visible { outline: 0; }
|
|
|
|
.fa-stack-100 { width: 2em; line-height: 2em; }
|
|
.fa-stack-offset { right: 10%; position: absolute; bottom: 15%; font-size: .5em; }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<h1>MD Testing</h1>
|
|
<tp-md-editor name="description">
|
|
1. MD CONTENT
|
|
2. list item two
|
|
|
|
and something else
|
|
|
|
- and then
|
|
- and then
|
|
- and then
|
|
</tp-md-editor>
|
|
</body>
|
|
--- End File: public/test.md.php ---
|
|
|
|
|
|
--- File: public/js/kb_edit.js ---
|
|
|
|
|
|
// switch to target tab pane
|
|
function switchTab(targetId){
|
|
var panes = document.querySelectorAll('.tab-content .tab-pane');
|
|
for (var i=0; i< panes.length; i++){
|
|
panes[i].style.display = 'none';
|
|
}
|
|
var targetPane = document.getElementById(targetId);
|
|
if(targetPane){
|
|
targetPane.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// send ajax post request with content to specified url
|
|
function ajaxPost(content, url, callback){
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', url, true);
|
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
|
xhr.onreadystatechange = function(){
|
|
if(xhr.readyState === XMLHttpRequest.DONE){
|
|
if(xhr.status === 200){
|
|
callback(xhr.responseText);
|
|
} else {
|
|
console.error("AJAX error: " + xhr.status);
|
|
}
|
|
}
|
|
};
|
|
var params = 'content=' + encodeURIComponent(content);
|
|
xhr.send(params);
|
|
}
|
|
|
|
// load preview via ajax into preview element
|
|
function loadPreview(previewElement){
|
|
var sourceId = previewElement.getAttribute('data-source');
|
|
var handlerUrl = previewElement.getAttribute('data-handler');
|
|
var method = previewElement.getAttribute('data-method');
|
|
var sourceElement = document.getElementById(sourceId);
|
|
if(sourceElement){
|
|
var content = sourceElement.value;
|
|
if(method && method.toLowerCase() == 'post'){
|
|
ajaxPost(content, handlerUrl, function (response){
|
|
previewElement.innerHTML = response;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// initialise tab links to handle tab switching
|
|
function initTabs(){
|
|
var tabLinks = document.querySelectorAll('.tabs a[data-target]');
|
|
for(var i=0; i<tabLinks.length; i++){
|
|
tabLinks[i].addEventListener('click', function(e){
|
|
e.preventDefault();
|
|
var target = this.getAttribute('data-target');
|
|
switchTab(target);
|
|
|
|
// if the new tab has a preview element, load the preview
|
|
var pane = document.getElementById(target);
|
|
if(pane){
|
|
var previewElement = pane.querySelector('.preview');
|
|
if(previewElement){
|
|
console.log('pane has preview el')
|
|
loadPreview(previewElement);
|
|
} else {
|
|
console.log('pane doesnt have preview el');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
initTabs();
|
|
});
|
|
--- End File: public/js/kb_edit.js ---
|
|
|
|
|
|
--- File: public/js/markdown_preview.js ---
|
|
class TabSwitcherController {
|
|
constructor({ tabSelector, contentPrefix, textareaSelector, previewUrl }) {
|
|
this.tabSelector = tabSelector;
|
|
this.contentPrefix = contentPrefix;
|
|
this.textareaSelector = textareaSelector;
|
|
this.previewUrl = previewUrl;
|
|
|
|
this.tabParent = document.querySelector(tabSelector);
|
|
this.tabLinks = this.tabParent.querySelectorAll('a');
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.tabLinks.forEach(link => {
|
|
link.addEventListener('click', (e) => this.handleTabClick(e, link));
|
|
});
|
|
}
|
|
|
|
async handleTabClick(e, link) {
|
|
e.preventDefault();
|
|
const selectedTab = link.getAttribute('data-tab');
|
|
|
|
// Update active tab
|
|
this.tabParent.querySelectorAll('li').forEach(li => li.classList.remove('is-active'));
|
|
link.parentElement.classList.add('is-active');
|
|
|
|
// Show active content
|
|
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
|
|
const activeContent = document.getElementById(`${this.contentPrefix}-${selectedTab}`);
|
|
if (activeContent) activeContent.style.display = '';
|
|
|
|
if (selectedTab === 'preview') {
|
|
await this.loadPreview();
|
|
}
|
|
}
|
|
|
|
async loadPreview() {
|
|
const previewTarget = document.getElementById('preview-output');
|
|
if (!previewTarget) return;
|
|
|
|
previewTarget.innerHTML = `
|
|
<div class="skeleton-lines">
|
|
<div></div><div></div><div></div><div></div><div></div>
|
|
</div>
|
|
`;
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
const textarea = document.querySelector(this.textareaSelector);
|
|
const markdown = textarea ? textarea.value : '';
|
|
|
|
const res = await fetch(this.previewUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `content=${encodeURIComponent(markdown)}`
|
|
});
|
|
|
|
const html = await res.text();
|
|
previewTarget.innerHTML = html;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new TabSwitcherController({
|
|
tabSelector: '.tabs',
|
|
contentPrefix: 'tab',
|
|
textareaSelector: '#description',
|
|
previewUrl: '/parsedown/preview'
|
|
});
|
|
});
|
|
|
|
--- End File: public/js/markdown_preview.js ---
|
|
|
|
|
|
--- File: public/js/ticket_view.js ---
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
const ticket_id = window.location.pathname.split('/')[2];
|
|
const comments_url = `/ticket/${ticket_id}/comments`;
|
|
const attachments_url = `/ticket/${ticket_id}/attachments`;
|
|
|
|
function ajax(url, containerID){
|
|
fetch(url)
|
|
.then(response => {
|
|
if(!response.ok){
|
|
throw new Error('Network response was not ok.');
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(html => {
|
|
const container_el = document.getElementById(containerID);
|
|
if(container_el){
|
|
container_el.innerHTML += html;
|
|
} else {
|
|
throw new Error('Coments container does not exist');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.log('Error fetching comments', error);
|
|
});
|
|
}
|
|
|
|
ajax(attachments_url, 'attachments')
|
|
ajax(comments_url, 'comments')
|
|
});
|
|
|
|
|
|
--- End File: public/js/ticket_view.js ---
|
|
|
|
|
|
--- File: public/js/tp_md_editor.js ---
|
|
/**
|
|
* tp_md_editor.js
|
|
* Self-contained Markdown Editor with Toolbar Buttons
|
|
* Usage: tp_md_editor.init(config)
|
|
*/
|
|
|
|
class TPMarkdownEditor {
|
|
static init(config = {}) {
|
|
document.querySelectorAll('tp-md-editor').forEach(editor => {
|
|
new TPMarkdownEditor(editor, config);
|
|
});
|
|
}
|
|
|
|
constructor(wrapper, config) {
|
|
this.wrapper = wrapper;
|
|
this.name = wrapper.getAttribute('name');
|
|
this.config = config;
|
|
this.textarea = document.createElement('textarea');
|
|
this.textarea.name = this.name;
|
|
this.textarea.rows = 25;
|
|
this.toolbar = document.createElement('tp-md-toolbar');
|
|
|
|
this.undoStack = []
|
|
this.redoStack = []
|
|
this.localStorageKey = this.getStorageKey();
|
|
this.unsaved = false;
|
|
this.autosaveInterval = null;
|
|
|
|
// this.loadInitialContent();
|
|
this.captureInitialContent();
|
|
this.createUnsavedBanner();
|
|
this.loadFromLocalStorage();
|
|
|
|
this.wrapper.appendChild(this.unsavedBanner);
|
|
this.wrapper.appendChild(this.toolbar);
|
|
this.wrapper.appendChild(this.textarea);
|
|
|
|
this.buttonClasses = TPMarkdownEditor.defaultButtons();
|
|
this.buildToolbar();
|
|
this.setupAutoList();
|
|
this.setupUndoRedo();
|
|
this.setupPersistence();
|
|
this.setupAutoSave();
|
|
}
|
|
|
|
getStorageKey(){
|
|
const path = window.location.pathname;
|
|
return `tp_md_editor:${path}:${this.name}`;
|
|
}
|
|
|
|
createUnsavedBanner(){
|
|
const container = document.createElement('div');
|
|
container.style.cssText = 'background: #fff3cd; color: #856404; padding: 5px 10px; font-size: 0.9em; display: flex; justify-content: space-between; align-items: center; display: none;';
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = 'You have unsaved changes.';
|
|
|
|
const discardBtn = document.createElement('button');
|
|
discardBtn.textContent = 'Discard';
|
|
discardBtn.style.cssText = 'margin-left: auto; background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer;';
|
|
discardBtn.addEventListener('click', () => {
|
|
localStorage.removeItem(this.localStorageKey);
|
|
const hidden = this.wrapper.querySelector('.tp-md-initial');
|
|
if(hidden){
|
|
this.textarea.value = hidden.textContent;
|
|
}
|
|
this.clearUnsaved();
|
|
});
|
|
|
|
container.appendChild(text);
|
|
container.appendChild(discardBtn);
|
|
this.unsavedBanner = container
|
|
}
|
|
|
|
markUnsaved(){
|
|
this.unsaved = true;
|
|
this.unsavedBanner.style.display = 'block';
|
|
}
|
|
|
|
clearUnsaved(){
|
|
this.unsaved = false;
|
|
this.unsavedBanner.style.display = 'none';
|
|
}
|
|
|
|
setupPersistence(){
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if(this.unsaved){
|
|
localStorage.setItem(this.localStorageKey, this.textarea.value);
|
|
}
|
|
});
|
|
}
|
|
|
|
setupAutoSave(){
|
|
this.autosaveInterval = setInterval(() => {
|
|
if(this.unsaved){
|
|
localStorage.setItem(this.localStorageKey, this.textarea.value);
|
|
}
|
|
}, 5000); // save every 5 sec
|
|
}
|
|
|
|
loadFromLocalStorage(){
|
|
const saved = localStorage.getItem(this.localStorageKey);
|
|
if(saved){
|
|
this.textarea.value = saved;
|
|
this.markUnsaved();
|
|
}
|
|
}
|
|
|
|
buildToolbar() {
|
|
const groups = this.config.groups || [Object.keys(this.buttonClasses)];
|
|
groups.forEach(group => {
|
|
const groupEl = document.createElement('tp-md-toolbar-group');
|
|
group.forEach(btnType => {
|
|
const BtnClass = this.buttonClasses[btnType];
|
|
if (BtnClass) {
|
|
const btn = new BtnClass(this.textarea);
|
|
groupEl.appendChild(btn.element);
|
|
}
|
|
});
|
|
this.toolbar.appendChild(groupEl);
|
|
});
|
|
}
|
|
|
|
setupUndoRedo(){
|
|
this.textarea.addEventListener('input', ()=>{
|
|
this.undoStack.push(this.textarea.value);
|
|
if(this.undoStack > 100) this.undoStack.shift();
|
|
this.redoStack = [];
|
|
this.markUnsaved();
|
|
});
|
|
|
|
this.textarea.addEventListener('keydown', (e) => {
|
|
if(e.ctrlKey && e.key === 'z'){
|
|
e.preventDefault();
|
|
if(this.undoStack.length > 0 ){
|
|
this.redoStack.push(this.textarea.value);
|
|
this.textarea.value = this.undoStack.pop();
|
|
this.markUnsaved();
|
|
}
|
|
} else if (e.ctrlKey && (e.key === 'y')) { // || e.shiftkey && e.key === 'z'
|
|
e.preventDefault();
|
|
if(this.redoStack.length > 0){
|
|
this.undoStack.push(this.textarea.value);
|
|
this.textarea.value = this.redoStack.pop();
|
|
this.markUnsaved();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
setupAutoList() {
|
|
this.textarea.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
const pos = this.textarea.selectionStart;
|
|
const before = this.textarea.value.slice(0, pos);
|
|
const after = this.textarea.value.slice(pos);
|
|
const lines = before.split('\n');
|
|
const lastLine = lines[lines.length - 1];
|
|
|
|
// need order of task > ul
|
|
let match;
|
|
if ((match = lastLine.match(/^(\s*)- \[( |x)\] /))) {
|
|
// task
|
|
e.preventDefault();
|
|
if (lastLine.trim() === '- [ ]' || lastLine.trim() === '- [x]') {
|
|
this.removeLastLine(pos, lastLine.length);
|
|
} else {
|
|
const insert = '\n' + match[1] + '- [ ] ';
|
|
this.insertAtCursor(insert);
|
|
}
|
|
} else if ((match = lastLine.match(/^(\s*)([-*+] )/))) {
|
|
// ul
|
|
e.preventDefault();
|
|
if (lastLine.trim() === match[2].trim()) {
|
|
this.removeLastLine(pos, lastLine.length);
|
|
} else {
|
|
const insert = '\n' + match[1] + match[2];
|
|
this.insertAtCursor(insert);
|
|
}
|
|
} else if ((match = lastLine.match(/^(\s*)(\d+)\. /))) {
|
|
// ol
|
|
e.preventDefault();
|
|
if (lastLine.trim() === `${match[2]}.`) {
|
|
this.removeLastLine(pos, lastLine.length);
|
|
} else {
|
|
const nextNum = parseInt(match[2]) + 1;
|
|
const insert = `\n${match[1]}${nextNum}. `;
|
|
this.insertAtCursor(insert);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
removeLastLine(cursorPos, lengthToRemove) {
|
|
const start = cursorPos - lengthToRemove;
|
|
this.textarea.setRangeText('', start, cursorPos, 'start');
|
|
this.textarea.setSelectionRange(start, start);
|
|
}
|
|
|
|
insertAtCursor(text) {
|
|
const start = this.textarea.selectionStart;
|
|
const end = this.textarea.selectionEnd;
|
|
this.textarea.setRangeText(text, start, end, 'end');
|
|
const newPos = start + text.length;
|
|
this.textarea.setSelectionRange(newPos, newPos)
|
|
this.markUnsaved();
|
|
}
|
|
|
|
captureInitialContent() {
|
|
const hidden = document.createElement('script');
|
|
hidden.type = 'text/plain';
|
|
// hidden.style.display = 'none';
|
|
hidden.classList.add('tp-md-initial');
|
|
hidden.textContent = this.wrapper.textContent.trim();
|
|
// clear inner content so it's not visible twice
|
|
this.wrapper.textContent = '';
|
|
|
|
this.wrapper.appendChild(hidden);
|
|
this.textarea.value = hidden.textContent;
|
|
}
|
|
|
|
static defaultButtons() {
|
|
return {
|
|
h1: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '# ', 'H1', 'fas fa-heading', '', 'fas fa-1'); }
|
|
},
|
|
h2: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '## ', 'H2', 'fas fa-heading', '', 'fas fa-2'); }
|
|
},
|
|
h3: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '### ', 'H3', 'fas fa-heading', '', 'fas fa-3'); }
|
|
},
|
|
bold: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '**', 'Bold', 'fas fa-bold', '**'); }
|
|
},
|
|
italic: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '_', 'Italic', 'fas fa-italic', '_'); }
|
|
},
|
|
quote: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '> ', 'Quote', 'fas fa-quote-right'); }
|
|
},
|
|
code: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '`', 'Code', 'fas fa-code', '`'); }
|
|
formatSelection(sel) {
|
|
if (!sel || !sel.includes('\n')) {
|
|
return '`' + (sel || 'code') + '`';
|
|
}
|
|
return '```\n' + sel + '\n```';
|
|
}
|
|
},
|
|
link: class extends TPMarkdownButton {
|
|
constructor(textarea) {
|
|
super(textarea, '[', 'Link', 'fas fa-link', '](url)');
|
|
}
|
|
formatSelection(sel) {
|
|
return `[${sel || 'text'}](url)`;
|
|
}
|
|
},
|
|
bullet: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '- ', 'Bullet', 'fas fa-list-ul'); }
|
|
formatSelection(sel){
|
|
return sel.split('\n').map(line => '- ' + line).join('\n');
|
|
}
|
|
},
|
|
number: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '1. ', 'Numbered', 'fas fa-list-ol'); }
|
|
formatSelection(sel){
|
|
return sel.split('\n').map((line, i) => `${i+1}. ${line}`).join('\n');
|
|
}
|
|
},
|
|
task: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '- [ ] ', 'Task', 'fas fa-tasks'); }
|
|
formatSelection(sel){
|
|
return sel.split('\n').map(line => ' - [ ]' + line).join('\n')
|
|
}
|
|
},
|
|
hr: class extends TPMarkdownButton {
|
|
constructor(textarea) { super(textarea, '---\n', 'HR', 'fas fa-minus'); }
|
|
},
|
|
table: class extends TPMarkdownButton {
|
|
constructor(textarea) {
|
|
super(textarea, '', 'Table', 'fas fa-table');
|
|
}
|
|
formatSelection(_) {
|
|
return '| Col1 | Col2 |\n|------|------|\n| Val1 | Val2 |';
|
|
}
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
class TPMarkdownButton {
|
|
constructor(textarea, prefix = '', title = '', icon = '', suffix = '', icon_offset = '') {
|
|
this.textarea = textarea;
|
|
this.prefix = prefix;
|
|
this.suffix = suffix;
|
|
this.element = document.createElement('tp-md-toolbar-button');
|
|
this.element.title = title;
|
|
if (icon_offset == '') {
|
|
this.element.innerHTML = `<i class="${icon}"></i>`;
|
|
} else {
|
|
this.element.innerHTML = `<span class="fa-stack fa-stack-100"><i class="fa-stack-1x ${icon}"></i><i class="fa-stack-offset ${icon_offset}"></i></span>`;
|
|
}
|
|
this.element.addEventListener('click', () => this.apply());
|
|
}
|
|
|
|
formatSelection(sel) {
|
|
return this.prefix + (sel || 'text') + this.suffix;
|
|
}
|
|
|
|
apply() {
|
|
const textarea = this.textarea;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const text = textarea.value;
|
|
this.previousValue = textarea.value;
|
|
const selected = text.substring(start, end);
|
|
const formatted = this.formatSelection(selected);
|
|
textarea.setRangeText(formatted, start, end, 'end');
|
|
|
|
if(this.previousValue !== textarea.value){
|
|
if(!textarea.undoStack) textarea.undoStack = [];
|
|
textarea.undoStack.push(this.previousValue);
|
|
}
|
|
|
|
textarea.focus();
|
|
}
|
|
}
|
|
|
|
// Export as global
|
|
window.tp_md_editor = TPMarkdownEditor;
|
|
--- End File: public/js/tp_md_editor.js ---
|
|
|
|
|
|
--- File: scss/main.scss ---
|
|
// import bulma
|
|
@use "vendor/bulma";
|
|
|
|
// import custom components
|
|
@use "components/ticket-item";
|
|
--- End File: scss/main.scss ---
|
|
|
|
|
|
--- File: scss/components/_ticket-item.scss ---
|
|
@use "../vendor/bulma";
|
|
|
|
.ticket-item {
|
|
@extend .is-flex;
|
|
@extend .mb-1;
|
|
@extend .pt-1;
|
|
@extend .pb-2;
|
|
@extend .is-align-items-flex-start;
|
|
border-bottom: 1px solid var(--bulma-text-90);
|
|
|
|
.ticket-icon {
|
|
@extend .mr-2;
|
|
display: flex;
|
|
align-items: baseline;
|
|
|
|
.checkbox {
|
|
margin-right: 0.5rem;
|
|
}
|
|
}
|
|
|
|
.ticket-content {
|
|
@extend .is-flex;
|
|
@extend .is-flex-direction-column;
|
|
@extend .is-flex-grow-1;
|
|
align-self: baseline;
|
|
|
|
.ticket-header {
|
|
@extend .is-flex;
|
|
@extend .is-justify-content-flex-start;
|
|
@extend .is-flex-wrap-wrap;
|
|
@extend .mb-1;
|
|
align-items: center;
|
|
|
|
.ticket-title {
|
|
@extend .title;
|
|
@extend .mb-0;
|
|
@extend .is-5;
|
|
font-weight: normal;
|
|
}
|
|
|
|
.tags {
|
|
@extend .ml-2;
|
|
}
|
|
}
|
|
|
|
.ticket-meta {
|
|
@extend .is-flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
|
|
p {
|
|
@extend .subtitle;
|
|
@extend .is-6;
|
|
font-weight: 300;
|
|
margin: 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
--- End File: scss/components/_ticket-item.scss ---
|
|
|
|
|
|
--- File: scss/vendor/_bulma-tools.scss ---
|
|
@use "../../node_modules/bulma/sass/utilities/" as bulma-utils;
|
|
@use "../../node_modules/bulma/sass/helpers/" as bulma-helpers;
|
|
@use "../../node_modules/bulma/sass/elements/" as bulma-elements;
|
|
--- End File: scss/vendor/_bulma-tools.scss ---
|
|
|
|
|
|
--- File: scss/vendor/_bulma.scss ---
|
|
@forward "../../node_modules/bulma/bulma";
|
|
--- End File: scss/vendor/_bulma.scss ---
|
|
|
|
|
|
--- File: ui/partials/ticket_item.html ---
|
|
<div class="ticket-item">
|
|
<div class="ticket-icon">
|
|
<div class="field">
|
|
<label class="b-checkbox checkbox">
|
|
<input type="checkbox" value="false" />
|
|
<span class="check"></span>
|
|
<span class="control-label"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<span class="icon is-medium"><icons type="status">{{@ticket.status_name}}</icons></span>
|
|
</div>
|
|
<div class="ticket-content">
|
|
<div class="ticket-header">
|
|
<span class="ticket-title">
|
|
<a href="/ticket/{{ @ticket.id }}">{{ @ticket.title }}</a>
|
|
</span>
|
|
<div class="tags ml-2">
|
|
<repeat group="{{ @ticket.tags }}" value="{{ @tag }}">
|
|
<span class="tag is-{{@tag.color}}">{{ @tag.name }}</span>
|
|
</repeat>
|
|
</div>
|
|
</div>
|
|
<div class="ticket-meta">
|
|
<p>#{{ @ticket.id }} opened {{ @ticket.created_at }} by {{ @ticket.display_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/partials/ticket_item.html ---
|
|
|
|
|
|
--- File: ui/parts/clipboard.html ---
|
|
<div class="block">
|
|
<style>
|
|
#upload-area {
|
|
height: 250px;
|
|
border: 2px dashed #aaa;
|
|
display: flex;
|
|
align-items: center;
|
|
margin-top: 10px;
|
|
color: #555;
|
|
font-family: sans-serif;
|
|
text-align: center;
|
|
}
|
|
|
|
#upload-area.hover {
|
|
background-color: #f0f0f0;
|
|
border-color: #444;
|
|
}
|
|
|
|
#preview {
|
|
max-width:100%;
|
|
margin-top: 10px;
|
|
}
|
|
</style>
|
|
|
|
<div id="upload-area" contenteditable="true">
|
|
Paste or drag an image here
|
|
</div>
|
|
<img id="preview" alt="image preview" hidden>
|
|
<p id="status"></p>
|
|
|
|
<script>
|
|
const area = document.getElementById('upload-area');
|
|
const preview = document.getElementById('preview');
|
|
const status = document.getElementById('status');
|
|
|
|
async function uploadImage(file){
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
preview.src = reader.result;
|
|
preview.hidden = false;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
const formData = new FormData();
|
|
formData.append('attachment', file);
|
|
|
|
try {
|
|
const res = await fetch('/ticket/{{@ticket->id}}/attachments/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const text = await res.text();
|
|
status.textContent = text;
|
|
} catch (e) {
|
|
status.textContent = 'upload failed.'
|
|
}
|
|
}
|
|
|
|
// paste
|
|
area.addEventListener('paste', (e) => {
|
|
for(let item of e.clipboardData.items){
|
|
if(item.type.startsWith('image/')){
|
|
uploadImage(item.getAsFile());
|
|
}
|
|
}
|
|
});
|
|
|
|
// drag and drop
|
|
area.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
area.classList.add('hover');
|
|
});
|
|
|
|
area.addEventListener('dragLeave', ()=> {
|
|
area.classList.remove('hover');
|
|
});
|
|
|
|
area.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
area.classList.remove('hover');
|
|
const files = e.dataTransfer.files;
|
|
for(let file of files){
|
|
if(file.type.startsWith('image/')){
|
|
uploadImage(file);
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
</div>
|
|
--- End File: ui/parts/clipboard.html ---
|
|
|
|
|
|
--- File: ui/session/error.html ---
|
|
<check if="{{isset(@SESSION.error)}}">
|
|
<div class="notification is-warning">
|
|
{{ @SESSION.error }}
|
|
</div>
|
|
</check>
|
|
--- End File: ui/session/error.html ---
|
|
|
|
|
|
--- File: ui/templates/layout.html ---
|
|
<!DOCTYPE html>
|
|
<html data-theme="{{ @SESSION.theme ?? 'light' }}" lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>TP ServiceDesk</title>
|
|
<link rel="stylesheet" href="/style.css">
|
|
<link rel="stylesheet" href="/css/main.min.css">
|
|
<!-- bulma.io-->
|
|
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"> -->
|
|
<!-- bulma helpers -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma-helpers/0.4.3/css/bulma-helpers.min.css"
|
|
integrity="sha512-U6ELnUi7oqVEjkLmFw5r5UR5LEtvpImS/jUykBKneVhD0lxZxfJZ3k3pe003ktrtNZYungd9u3Urp2X09wKwXg=="
|
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
<!-- bulma-checkbox -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma-checkbox@1.2.1/css/main.min.css" integrity="sha256-wvxLpriInkhouxrLZ5oo74cJpCtZJkR9bRJwFDvdd4w=" crossorigin="anonymous">
|
|
<!-- font awesome -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
|
|
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
|
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
<!-- additional JS -->
|
|
<check if="{{ isset(@js) }}">
|
|
<script src="/js/{{ @js}}"></script>
|
|
</check>
|
|
</head>
|
|
|
|
<body>
|
|
<!-- Navigation Bar -->
|
|
<nav class="navbar" role="navigation" aria-label="main navigation">
|
|
<div class="navbar-brand">
|
|
<a class="navbar-item" href="/">
|
|
<!-- Your logo or app name -->
|
|
<img src="/logo.svg" alt="App Logo">
|
|
</a>
|
|
<!-- Burger menu for mobile -->
|
|
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="mainNavbar">
|
|
<span aria-hidden="true"></span>
|
|
<span aria-hidden="true"></span>
|
|
<span aria-hidden="true"></span>
|
|
</a>
|
|
</div>
|
|
|
|
<div id="mainNavbar" class="navbar-menu">
|
|
<div class="navbar-start">
|
|
<a class="navbar-item" href="/dashboard">Dashboard</a>
|
|
<a class="navbar-item" href="/kb">Knowledge Base</a>
|
|
<a class="navbar-item" href="/projects">Projects</a>
|
|
<a class="navbar-item" href="/tickets">Tickets</a>
|
|
<a class="navbar-item" href="/tags">Tags</a>
|
|
<check if="{{ isset(@SESSION.user) && @SESSION.user.is_admin }}">
|
|
<a class="navbar-item" href="/admin">Admin</a>
|
|
</check>
|
|
</div>
|
|
<div class="navbar-end">
|
|
<div class="navbar-item">
|
|
<div class="buttons">
|
|
<form id="theme-toggle-form" method="post" action="/toggle-theme" style="display:inline">
|
|
<button type="submit" id="theme-toggle-button" class="button is-small" aria-label="Toggle Theme">
|
|
<span class="icon">
|
|
<i class="fas fa-circle-half-stroke" id="theme-icon"></i>
|
|
</span>
|
|
</button>
|
|
</form>
|
|
<check if="{{ isset(@SESSION.user) }}">
|
|
<true>
|
|
<a class="button is-primary" href="/logout">Log Out</a>
|
|
</true>
|
|
<false>
|
|
<a class="button is-primary" href="/login">Log In</a>
|
|
</false>
|
|
</check>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- breadcrumbs TODO: NOT YET
|
|
<div class="container">
|
|
|
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
|
<ul>
|
|
<li><a href="#">Bulma</a></li>
|
|
<li><a href="#">Documentation</a></li>
|
|
<li><a href="#">Components</a></li>
|
|
<li class="is-active"><a href="#" aria-current="page">Breadcrumb</a></li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
-->
|
|
|
|
<!-- Main Content Area -->
|
|
<div class="container">
|
|
<include href="ui/session/error.html">
|
|
</div>
|
|
<main class="section" id="page">
|
|
<div class="container">
|
|
<!-- Fat-Free Framework content injection -->
|
|
<include href="{{@content}}" />
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Footer -->
|
|
<footer class="footer">
|
|
<div class="content has-text-centered">
|
|
<p>©
|
|
<?php echo date('Y'); ?> Terry Probert
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
|
|
<!-- JavaScript for Bulma navbar burger (mobile) -->
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const burgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
|
|
if (burgers.length > 0) {
|
|
burgers.forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const target = document.getElementById(el.dataset.target);
|
|
el.classList.toggle('is-active');
|
|
target.classList.toggle('is-active');
|
|
});
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|
|
--- End File: ui/templates/layout.html ---
|
|
|
|
|
|
--- File: ui/views/dashboard.html ---
|
|
<h1 class="title">Dashboard</h1>
|
|
--- End File: ui/views/dashboard.html ---
|
|
|
|
|
|
--- File: ui/views/home.html ---
|
|
|
|
<!-- hero -->
|
|
<section class="hero is-info is-medium">
|
|
<div class="hero-body">
|
|
<div class="container">
|
|
<h1 class="title">TP ServiceDesk</h1>
|
|
<h2 class="subtitle">One place to manage requests, store knowledge, and collaborate on projects</h2>
|
|
<p class="buttons">
|
|
<a href="/login" class="button is-primary">
|
|
<span class="icon"><i class="fas fa-sign fa-sign-in-alt"></i></span>
|
|
<span>Get Started</span>
|
|
</a>
|
|
<a href="/kb" class="button is-light">
|
|
<span class="icon"><i class="fas fa-book"></i></span>
|
|
<span>Browse Knowledge Base</span>
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- features -->
|
|
<section class="section">
|
|
<div class="container">
|
|
<div class="columns is-multiline">
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Ticketing System</p>
|
|
<ul>
|
|
<li><i class="fas fa-check-circle"></i> Create & Track tickets</li>
|
|
<li><i class="fas fa-check-circle"></i> Assign priorities & statuses</li>
|
|
<li><i class="fas fa-check-circle"></i> Link child/parent tickets</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Knowledge Base</p>
|
|
<ul>
|
|
<li><i class="fas fa-book"></i> Markdown-powered articles</li>
|
|
<li><i class="fas fa-tags"></i> Tagging and filtering</li>
|
|
<li><i class="fas fa-search"></i> Fast searching</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Projects</p>
|
|
<ul>
|
|
<li><i class="fas fa-stream"></i> Track ongoing projects</li>
|
|
<li><i class="fas fa-tasks"></i> Integreate tasks and tickets</li>
|
|
<li><i class="fas fa-chart-line"></i> Monitor progress</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Collaboration</p>
|
|
<ul>
|
|
<li><i class="fas fa-comments"></i> Comment threads</li>
|
|
<li><i class="fas fa-paperclip"></i> File attachments</li>
|
|
<li><i class="fas fa-user-friends"></i> Role-based user access</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Custom fields</p>
|
|
<ul>
|
|
<li><i class="fas fa-database"></i> Define ticket meta data</li>
|
|
<li><i class="fas fa-sitemap"></i> Configure and store extra info</li>
|
|
<li><i class="fas fa-pencil-alt"></i> Easily editable in forms</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- card-->
|
|
<div class="column is-4">
|
|
<div class="card">
|
|
<div class="card-content">
|
|
<p class="title is-5">Administration</p>
|
|
<ul>
|
|
<li><i class="fas fa-book"></i> Manage user roles</li>
|
|
<li><i class="fas fa-tags"></i> Create new account</li>
|
|
<li><i class="fas fa-search"></i> Edit existing users</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
</div>
|
|
</section>
|
|
--- End File: ui/views/home.html ---
|
|
|
|
|
|
--- File: ui/views/login.html ---
|
|
<h1 class="title">Please Log In</h1>
|
|
|
|
<check if="{{ @error}}">
|
|
<div class="notification is-danger is-light">
|
|
<p style="color: red;">{{ @error }}</p>
|
|
</div>
|
|
</check>
|
|
|
|
<form action="/login" method="POST">
|
|
|
|
<div class="field">
|
|
<p class="control has-icons-left has-icons-right">
|
|
<input name="username" class="input" type="text" placeholder="Username">
|
|
<span class="icon is-small is-left">
|
|
<i class="fas fa-user"></i>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<p class="control has-icons-left">
|
|
<input name="password" class="input" type="password" placeholder="Password">
|
|
<span class="icon is-small is-left">
|
|
<i class="fas fa-lock"></i>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<p class="control">
|
|
<button class="button is-success">
|
|
Login
|
|
</button>
|
|
</p>
|
|
</div>
|
|
|
|
</form>
|
|
--- End File: ui/views/login.html ---
|
|
|
|
|
|
--- File: ui/views/admin/index.html ---
|
|
<h1 class="title">Admin</h1>
|
|
<hr>
|
|
|
|
<section>
|
|
<p><a href="/admin/priority">Ticket > Priorities</a></p>
|
|
<p><a href="/admin/status">Ticket > Statuses</a></p>
|
|
</section>
|
|
--- End File: ui/views/admin/index.html ---
|
|
|
|
|
|
--- File: ui/views/admin/priorities/create.html ---
|
|
<h1 class="title">Create Ticket Priority</h1>
|
|
<p>TODO:</p>
|
|
--- End File: ui/views/admin/priorities/create.html ---
|
|
|
|
|
|
--- File: ui/views/admin/priorities/index.html ---
|
|
<h1 class="title">Admin: Ticket Priorities</h1>
|
|
<p><a class="button" href="/admin/priority/create">create priority</a></p>
|
|
<hr>
|
|
|
|
<table class="table is-fullwidth is-bordered">
|
|
<thead>
|
|
<tr class="has-background-grey">
|
|
<th class="has-text-light">id</th>
|
|
<th class="has-text-light">name</th>
|
|
<th class="has-text-light">sort_order</th>
|
|
<th class="has-text-light"></th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
<repeat group="{{@priorities}}" value="{{@priority}}">
|
|
<tr>
|
|
<td>{{@priority.id}}</td>
|
|
<td><a href="/admin/priority/{{@priority.id}}">{{@priority.name}}</a></td>
|
|
<td>{{@priority.sort_order}}</td>
|
|
<td>
|
|
<a class="button is-link is-small" href="/admin/priority/{{@priority.id}}/edit">
|
|
<i class="fa fa-edit"></i></a>
|
|
<a class="button is-danger is-small"
|
|
href="/admin/priority/{{@priority.id}}/delete"
|
|
onclick="return confirm('Are you sure you want to delete this ticket?');">
|
|
<i class="fa fa-trash-can"></i></a>
|
|
</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
--- End File: ui/views/admin/priorities/index.html ---
|
|
|
|
|
|
--- File: ui/views/attachment/index.html ---
|
|
<div class="box">
|
|
<div class="content">
|
|
<h4 class="title is-4">Attachments</h4>
|
|
<div class="block">
|
|
<check if="isset( {{@attachments }})">
|
|
<check if="count({{@attachments}}) > 0">
|
|
<table class="table is-fullwidth is-narrow is-striped is-hoverable">
|
|
<thead>
|
|
<tr>
|
|
<th class="th-icon"></th>
|
|
<th>File Name</th>
|
|
<th>Uploaded By</th>
|
|
<th>Created At</th>
|
|
<th>Version</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @attachments }}" value="{{ @attach }}">
|
|
<tr>
|
|
<td>
|
|
<span class="icon"><i class="fas fa-file"></i></span>
|
|
</td>
|
|
<td><a href="/attachment/{{@attach.id}}/download">{{ @attach.file_name }}</a></td>
|
|
<td>{{ @attach.username }}</td>
|
|
<td>{{ @attach.created_at }}</td>
|
|
<td>{{ @attach.version_number }}</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
<hr>
|
|
<div>
|
|
<repeat group="{{ @attachments }}" value="{{ @attach }}">
|
|
<img src="/attachment/{{@attach.id}}/view">
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
</check>
|
|
<div class="block">
|
|
<form action="/ticket/{{@PARAMS.id}}/attachments/upload" method="POST" enctype="multipart/form-data">
|
|
<div class="field has-addons">
|
|
<div class="control has-icons-left"><!-- is-expanded -->
|
|
<input class="input" type="file" name="attachment" required>
|
|
<span class="icon is-small is-left">
|
|
<i class="fas fa-file"></i>
|
|
</span>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button" type="submit">Upload</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/attachment/index.html ---
|
|
|
|
|
|
--- File: ui/views/comments/view.html ---
|
|
<hr>
|
|
<div class="box" id="comments">
|
|
<h3 class="title is-3">Comments</h3>
|
|
<check if="{{ !empty(@comments) }}">
|
|
<div class="list">
|
|
<repeat group="{{ @comments }}" value="{{ @comment}}">
|
|
<div class="list-item">
|
|
<div class="list-item-image">
|
|
<figure class="image is-48x48">
|
|
<img class="is-rounded"
|
|
src="https://placehold.co/200x200/66d1ff/FFF?text=TP">
|
|
<!-- <img class="is-rounded"
|
|
src="https://loremflickr.com/200/200/dog?{{ (int)rand()}}">-->
|
|
</figure>
|
|
</div>
|
|
<div class="list-item-content">
|
|
<div class="list-item-title is-flex is-justify-content-space-between">
|
|
<span>{{ @comment.author_name}}</span>
|
|
<span class="has-text-weight-normal has-text-grey">{{ @comment.created_at }}</span>
|
|
</div>
|
|
<div class="list-item-description">
|
|
<parsedown>{{ @comment.comment | raw }}</parsedown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
<div class="block">
|
|
<form action="/ticket/{{@PARAMS.id}}/comment" method="POST">
|
|
<div class="field">
|
|
<label class="label">Add comment:</label>
|
|
<div class="control">
|
|
<textarea class="textarea" name="comment" rows="4" cols="50"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="field is-clearfix">
|
|
<button class="button is-primary is-pulled-right" type="submit">Submit Comment</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/comments/view.html ---
|
|
|
|
|
|
--- File: ui/views/kb/create.html ---
|
|
<h1 class="title">Create Knowledge Base Article</h1>
|
|
|
|
|
|
<div class="content">
|
|
<form action="/kb/create" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>
|
|
|
|
<div id="editor" class="block">
|
|
<div class="tabs">
|
|
<ul>
|
|
<li class="is-active"><a>Write</a></li>
|
|
<li class=""><a>Preview</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="block">
|
|
<div class="tab-content">
|
|
<bulma type="H_FIELD_TEXTAREA" label="Content:" name="content" value=""></bulma>
|
|
</div>
|
|
<div class="tab-content">
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TODO: tags -->
|
|
|
|
<!-- buttons -->
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/kb">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Create KB Article</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
--- End File: ui/views/kb/create.html ---
|
|
|
|
|
|
--- File: ui/views/kb/edit.html ---
|
|
<h1 class="title">Edit Knowledge Base Article</h1>
|
|
|
|
|
|
<form action="/kb/{{@article.id}}/update" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@article.title}}"></bulma>
|
|
|
|
<div id="editor" class="block">
|
|
<div class="tabs">
|
|
<ul>
|
|
<li class="is-active"><a data-target="pane-editor">Write</a></li>
|
|
<li class=""><a data-target="pane-preview">Preview</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="tab-content">
|
|
<div class="tab-pane" id="pane-editor">
|
|
<div class="block">
|
|
<bulma type="H_FIELD_TEXTAREA" label="Content:" name="content" value="{{@article.content}}"></bulma>
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane" id="pane-preview">
|
|
<div class="block content">
|
|
<div class="preview" data-source="content" data-handler="/parsedown/preview" data-method="post"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TODO: tags -->
|
|
|
|
<button class="button is-primary" type="submit">Update Article</button>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/kb/edit.html ---
|
|
|
|
|
|
--- File: ui/views/kb/index.html ---
|
|
<h1 class="title">Knowledge Base</h1>
|
|
<p><a class="button" href="/kb/create">create kb article</a></p>
|
|
<hr>
|
|
|
|
<div class="block">
|
|
<form method="GET" action="/kb">
|
|
<div class="level">
|
|
<div class="level-item">
|
|
<check if="{{ isset(@GET.search)}}">
|
|
<true>
|
|
<input class="input" type="text" name="search" placeholder="Search by title..."
|
|
value="{{ HTMLPurifier::instance()->purify( @GET.search) }}">
|
|
</true>
|
|
<false>
|
|
<input class="input" type="text" name="search" placeholder="Search by title...">
|
|
</false>
|
|
</check>
|
|
</div>
|
|
<div class="level-right">
|
|
<div class="select">
|
|
<select name="tag">
|
|
<option value="">--Filter by Tag</option>
|
|
<!-- TODO: load list of all tags-->
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="level-right">
|
|
<button class="button is-primary" type="submit">Search</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<check if="{{@articles}}">
|
|
<table class="table is-fullwidth is-bordered">
|
|
<thead>
|
|
<tr class="has-background-info">
|
|
<th>id</th><th>title</th><th>created_at</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
<repeat group="{{@articles}}" value="{{@article}}">
|
|
<tr>
|
|
<td>{{@article.id}}</td>
|
|
<td><a href="/kb/{{@article.id}}">{{@article.title}}</a></td>
|
|
<td>{{@article.created_at}}</td>
|
|
<td>
|
|
<a href="/kb/{{@article.id}}/edit"><i class="fa fa-edit"></i></a>
|
|
</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</check>
|
|
|
|
<check if="!@articles">
|
|
<div class="notification is-info is-light">
|
|
<p>No articles found.</p>
|
|
</div>
|
|
</check>
|
|
--- End File: ui/views/kb/index.html ---
|
|
|
|
|
|
--- File: ui/views/kb/view.html ---
|
|
<h1 class="title">{{@article.title}}</h1>
|
|
<p><a href="/kb/{{ @article.id}}/edit">edit article</a></p>
|
|
<hr>
|
|
|
|
<div class="content">
|
|
<parsedown>{{ @article.content | raw }}</parsedown>
|
|
</div>
|
|
|
|
|
|
|
|
<check if="{{ isset(@tags)}}">
|
|
<div class="box">
|
|
<table class="table is-bordered is-fullwidth">
|
|
<thead>
|
|
<tr><th class="has-width-200">Property</th><th>Value</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
|
|
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</check>
|
|
|
|
--- End File: ui/views/kb/view.html ---
|
|
|
|
|
|
--- File: ui/views/project/create.html ---
|
|
<pre>
|
|
TODO: create form.
|
|
</pre>
|
|
--- End File: ui/views/project/create.html ---
|
|
|
|
|
|
--- File: ui/views/project/edit.html ---
|
|
<pre>
|
|
TODO: edit form
|
|
</pre>
|
|
--- End File: ui/views/project/edit.html ---
|
|
|
|
|
|
--- File: ui/views/project/index.html ---
|
|
<h1 class="title">Projects</h1>
|
|
<p><a class="button" href="/project/create">create project</a></p>
|
|
<hr>
|
|
|
|
<table class="table is-fullwidth is-hoverable is-bordered">
|
|
<thead>
|
|
<tr class="has-background-primary">
|
|
<th>ID</th>
|
|
<th>Title</th>
|
|
<th>Requester</th>
|
|
<th>Created By</th>
|
|
<th>Created At</th>
|
|
<th>Start Date</th>
|
|
<th>End Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @projects }}" value="{{ @p }}">
|
|
<tr>
|
|
<td>{{ @p.id }}</td>
|
|
<td><a href="/project/{{@p.id}}">{{ @p.title }}</a></td>
|
|
<td>{{ @p.requester }}</td>
|
|
<td>{{ @p.created_by }}</td>
|
|
<td>{{ @p.created_at }}</td>
|
|
<td>{{ @p.start_date }}</td>
|
|
<td>{{ @p.end_date }}</td>
|
|
</tr>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
|
|
--- End File: ui/views/project/index.html ---
|
|
|
|
|
|
--- File: ui/views/project/view.html ---
|
|
<h3 class="title">{{ @project.title }}</h3>
|
|
<p><a href="/project/{{ @project.id}}/edit">edit project</a></p>
|
|
<hr>
|
|
<div class="columns">
|
|
<div class="column is-two-thirds">
|
|
<h3 class="title subtitle">Overview</h3>
|
|
<div class="box">
|
|
<parsedown>{{ @project.description }}</parsedown>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<div class="block">
|
|
<h3 class="title subtitle">Links</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<div class="columns">
|
|
<div class="column">
|
|
<h3 class="title subtitle">Tickets</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<h3 class="title subtitle">Tasks</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<h3 class="title subtitle">Events</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<div class="columns">
|
|
<div class="column">
|
|
<h3 class="title">Timeline</h3>
|
|
<div class="skeleton-lines">
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
<div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<parsedown inline="true">
|
|
---
|
|
## View project
|
|
|
|
A central place to see everything for this project:
|
|
|
|
- Overview: (title, description, links, start/end dates).
|
|
- related tickets (with status and priorities)
|
|
- events
|
|
- tasks
|
|
- timeline combining events, tickets, milestone dates
|
|
|
|
## Example Workflow
|
|
|
|
- create a project - `team manager overview`
|
|
-- attach relevant links
|
|
- add tickets - each new request or issue can be a ticket referencing this project
|
|
- add events - quick notes about management meetings, or verbal discussions that don't need ticket overhead
|
|
-- meeting on 01 jan to discuss layout
|
|
-- teams message on 28 jan clarifying data requirements
|
|
- project tasks - for smaller to do items that don't warrant a full ticket
|
|
-- identify location of required data, create initial pq connections, build a mockup layout
|
|
|
|
## Reporting Timelines
|
|
- timeline view - merge ticket data with project_events sorted by date - chronological Overview
|
|
- status summaries - how many tickets open, on hold, completed
|
|
- progress tracking - sumarries or gantt style charts
|
|
</parsedown>
|
|
--- End File: ui/views/project/view.html ---
|
|
|
|
|
|
--- File: ui/views/tag/create.html ---
|
|
<h1 class="title">Create Tag</h1>
|
|
|
|
<div class="content">
|
|
<form action="/tag/create" method="POST">
|
|
|
|
<div class="field">
|
|
<label class="label">Name</label>
|
|
<div class="control">
|
|
<input name="name" class="input" type="text" placeholder="Tag Name">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="label">Color</label>
|
|
<div class="control">
|
|
<input name="color" class="input" type="text" placeholder="Tag Color">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/tags">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Create Tag</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
--- End File: ui/views/tag/create.html ---
|
|
|
|
|
|
--- File: ui/views/tag/index.html ---
|
|
<h1 class="title">Tags</h1>
|
|
<p><a class="button" href="/tag/create">create tag</a></p>
|
|
<hr>
|
|
|
|
<check if="{{ count(@tags) > 1 }}">
|
|
<true>
|
|
<div class="tags are-medium">
|
|
<repeat group="{{ @tags }}" value="{{ @tag }}">
|
|
<a class="tag {{ (@tag.color ? 'is-' . @tag.color : '') }}">{{ @tag.name }}</a>
|
|
</repeat>
|
|
</div>
|
|
</true>
|
|
<false>
|
|
<div class="notification is-size-5">
|
|
<span><i class="fa fa-2xl fa-face-sad-tear"></i> </span>No tags found
|
|
</div>
|
|
</false>
|
|
</check>
|
|
|
|
|
|
<div class="box has-background-dark">
|
|
<div class="block">
|
|
<h3 class="subtitle has-text-white"><i class="fa-solid fa-lightbulb has-text-warning"></i> Color Examples</h3>
|
|
<p class="has-text-white">The following color names can be used for tags</p>
|
|
</div>
|
|
<div class="block">
|
|
<span class="tag is-black">Black</span>
|
|
<span class="tag is-dark">Dark</span>
|
|
<span class="tag is-light">Light</span>
|
|
<span class="tag is-white">White</span>
|
|
<span class="tag is-primary">Primary</span>
|
|
<span class="tag is-link">Link</span>
|
|
<span class="tag is-info">Info</span>
|
|
<span class="tag is-success">Success</span>
|
|
<span class="tag is-warning">Warning</span>
|
|
<span class="tag is-danger">Danger</span>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/tag/index.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/create.html ---
|
|
<h1 class="title">Create Ticket Form</h1>
|
|
|
|
<form action="/ticket/create" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>
|
|
<bulma type="H_FIELD_INPUT" label="Created At:" name="created_at" value=""></bulma>
|
|
<bulma type="H_FIELD_TEXTAREA" label="Description:" name="description" value=""></bulma>
|
|
|
|
<!-- priority and status -->
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Priority:" name="priority_id"
|
|
options="priorities" option_value="id" option_name="name"
|
|
selected="2"></bulma>
|
|
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Status:" name="status_id"
|
|
options="statuses" option_value="id" option_name="name"
|
|
selected="1"></bulma>
|
|
|
|
<!-- custom fields -->
|
|
<hr>
|
|
<div class="block">
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]" placeholder="eg. Department">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]" placeholder="eg. Finance">
|
|
</div>
|
|
</div>
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]" placeholder="eg. Category">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]" placeholder="eg. Urgent">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/tickets">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Create Ticket</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/ticket/create.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/edit.html ---
|
|
<!-- Ticket - Edit -->
|
|
<!-- made to look more in line with view-->
|
|
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
|
|
<div class="is-flex">
|
|
<div class="is-flex-grow-1">
|
|
<bulma type="FIELD_INPUT" name="title" value="{{@ticket.title}}" class="mr-3"></bulma>
|
|
</div>
|
|
<div class="field is-grouped">
|
|
<!-- <p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
|
|
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>-->
|
|
<div class="control">
|
|
<a class="button is-secondary" href="/ticket/{{ @ticket.id }}">Cancel</a>
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
<div class="block">
|
|
|
|
<div class="columns">
|
|
<div class="column is-two-thirds">
|
|
|
|
<div class="block">
|
|
<bulma type="FIELD_INPUT" label="Created At:" name="created_at" value="{{@ticket.created_at}}"></bulma>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<ul>
|
|
<li class="is-active">
|
|
<a data-tab="write"><span class="icon is-small">
|
|
<i class="fas fa-pen" aria-hidden="true"></i></span><span>Write</span>
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-tab="preview"><span class="icon is-small">
|
|
<i class="fas fa-magnifying-glass" aria-hidden="true"></i></span><span>Preview</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div id="tab-write" class="tab-content block">
|
|
<bulma type="FIELD_TEXTAREA" name="description" value="{{@ticket.description}}" rows="20"></bulma>
|
|
</div>
|
|
<div id="tab-preview" class="tab-content content">
|
|
<div id="preview-output"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="column">
|
|
<div class="block">
|
|
<!-- priority and status -->
|
|
<bulma type="FIELD_SELECT" label="Priority:" name="priority_id"
|
|
options="{{@priorities}}" option_value="id" option_name="name"
|
|
selected="{{@ticket.priority_id}}"></bulma>
|
|
|
|
<bulma type="FIELD_SELECT" label="Status:" name="status_id"
|
|
options="{{@statuses}}" option_value="id" option_name="name"
|
|
selected="{{@ticket.status_id}}"></bulma>
|
|
</div>
|
|
<!-- meta data -->
|
|
<div class="block">
|
|
<table class="table is-bordered is-fullwidth">
|
|
<thead>
|
|
<tr><th class="has-width-100">Property</th><th>Value</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
|
|
<check if="{{ @key !== 'description'}}">
|
|
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
|
|
</check>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- form to add child ticket relationships -->
|
|
<div class="box">
|
|
<h4 class="title is-4">Linked Tickets</h4>
|
|
<!-- parent -->
|
|
<?php
|
|
/*
|
|
<check if="{{ @parent_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Parent Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
|
|
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
|
|
</repeat>
|
|
</ul>
|
|
</div>
|
|
</check>
|
|
<!-- child tickets -->
|
|
<check if="{{ @child_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Child Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
|
|
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
|
|
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
|
|
<div class="field">
|
|
<label class="label">Add existing ticket as child ticket (ID):</label>
|
|
<div class="control">
|
|
<input class="input" type="number" placeholder="Child Ticket ID" required
|
|
name="child_ticket_id">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<button class="button is-link" type="submit">Link</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
*/ ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<hr>
|
|
|
|
<div>
|
|
|
|
|
|
|
|
<include href="../ui/views/attachment/index.html">
|
|
<include href="../ui/views/comments/view.html">
|
|
|
|
|
|
<!--
|
|
<div class="block" id="attachments"></div>
|
|
<div class="block" id="comments"></div>
|
|
-->
|
|
|
|
</div>
|
|
|
|
--- End File: ui/views/ticket/edit.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/edit.html.v1 ---
|
|
<h1 class="title">Edit Ticket Form</h1>
|
|
|
|
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
|
|
|
|
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@ticket.title}}"></bulma>
|
|
<bulma type="H_FIELD_INPUT" label="Created At:" name="created_at" value="{{@ticket.created_at}}"></bulma>
|
|
<bulma type="H_FIELD_TEXTAREA" label="Description:" name="description" value="{{@ticket.description}}"></bulma>
|
|
|
|
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Priority:" name="priority_id"
|
|
options="priorities" option_value="id" option_name="name"
|
|
selected="{{@ticket.priority_id}}"></bulma>
|
|
|
|
<bulma type="H_FIELD_SELECT_NEW" label="Status:" name="status_id"
|
|
options="statuses" option_value="id" option_name="name"
|
|
selected="{{@ticket.status_id}}"></bulma>
|
|
|
|
<include href="/ui/parts/clipboard.html"></include>
|
|
|
|
<div class="block">
|
|
<h3 class="title is-5">Custom Fields</h3>
|
|
<!-- existing fields-->
|
|
<div class="block">
|
|
<repeat group="{{ @ticket_meta }}" value="{{ @m }}">
|
|
<div class="field is-grouped is-grouped-right">
|
|
<input type="hidden" name="meta_id[]" value=" {{ @m.id }}">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]" value="{{ @m.meta_key }}"
|
|
placeholder="eg. Department">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]" value="{{ @m.meta_value }}"
|
|
placeholder="eg. Finance">
|
|
</div>
|
|
</div>
|
|
</repeat>
|
|
</div>
|
|
<hr>
|
|
<!-- adding new custom meta -->
|
|
<div class="block">
|
|
<div class="field is-grouped is-grouped-right">
|
|
<div class="control">
|
|
<label class="label">Key:</label>
|
|
<input class="input" type="text" name="meta_key[]"
|
|
placeholder="eg. Department">
|
|
</div>
|
|
<div class="control">
|
|
<label class="label">Value:</label>
|
|
<input class="input" type="text" name="meta_value[]"
|
|
placeholder="eg. Finance">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="button is-primary" type="submit">Save Ticket</button>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/ticket/edit.html.v1 ---
|
|
|
|
|
|
--- File: ui/views/ticket/index.html ---
|
|
<h1 class="title">Tickets</h1>
|
|
<!-- updated design -- inspiration gitea -->
|
|
<div class="block">
|
|
<div class="field is-grouped">
|
|
<div class="control is-expanded">
|
|
<div class="field has-addons is-expanded">
|
|
<div class="control is-expanded">
|
|
<input class="input" type="text" placeholder="Find a ticket">
|
|
</div>
|
|
<div class="control">
|
|
<button class="button is-info"><span class="icon"><i
|
|
class="fas fa-magnifying-glass"></i></span></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<p><a class="button is-primary" href="/ticket/create">create ticket</a></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="block">
|
|
<div class="field has-addons">
|
|
<!-- TODO: move this into a template -->
|
|
<repeat group="{{ IconsHelper::$status_icons}}" key="{{ @k }}" value="{{ @icon }}">
|
|
<p class="control">
|
|
<a href="{{ @PATH }}/?status={{ @k }}" class="button">
|
|
<span class="icon is-small"><i class="fas fa-{{ @icon[0] }}"></i></span>
|
|
<span>{{ IconsHelper::$status_names[@k] }}</span>
|
|
</a>
|
|
</p>
|
|
</repeat>
|
|
</div>
|
|
</div>
|
|
<hr>
|
|
|
|
<div id="ticket_list">
|
|
<repeat group="{{@tickets}}" value="{{@ticket}}">
|
|
<include href="/ui/partials/ticket_item.html"></include>
|
|
</repeat>
|
|
</div>
|
|
|
|
<?php
|
|
/*
|
|
<div id="ticket_list">
|
|
<repeat group="{{@tickets}}" value="{{@ticket}}">
|
|
<include href="/ui/views/ticket/index_row.html"></include>
|
|
</repeat>
|
|
</div>
|
|
*/
|
|
?>
|
|
--- End File: ui/views/ticket/index.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/index_row.html ---
|
|
<div class="g-flex-item is-flex is-align-items-flex-start mb-1 pt-1 pb-2">
|
|
<div class="g-flex-item-icon is-align-self-baseline mr-2">
|
|
<label class="checkbox mr-2"><input type="checkbox"></label>
|
|
<span class="icon is-medium"><icons type="status">{{@ticket.status_name}}</icons></span>
|
|
</div>
|
|
<div class="g-flex-item-main is-flex is-flex-direction-column is-flex-grow-1 is-align-self-baseline">
|
|
<div class="g-flex-item-header is-flex is-justify-content-flex-start is-flex-wrap-wrap">
|
|
<div class="">
|
|
<span class="title is-5 has-text-weight-normal"><a href="/ticket/{{@ticket.id}}">{{@ticket.title}}</a></span>
|
|
</div>
|
|
<div class="tags ml-2">
|
|
<!-- TODO: get tags -->
|
|
<span class="tag is-link">tag</span>
|
|
</div>
|
|
</div>
|
|
<div class="g-flex-item-body is-flex is-align-items-centre flex-wrap gap ">
|
|
<p class="subtitle is-6 has-text-weight-light">#{{@ticket.id}} opened 2025-03-25 by {{@ticket.display_name}}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
--- End File: ui/views/ticket/index_row.html ---
|
|
|
|
|
|
--- File: ui/views/ticket/view.html ---
|
|
<!-- Ticket - View -->
|
|
<div class="is-flex">
|
|
<h1 class="title is-flex-grow-1">{{ @ticket.title }}</h1>
|
|
<div class="field is-grouped">
|
|
<p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
|
|
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<div class="content">
|
|
|
|
<div class="columns">
|
|
<div class="column is-two-thirds">
|
|
<div class="block">
|
|
<p>{{ @ticket.created_at }}</p>
|
|
<div class="content">
|
|
<parsedown>{{ @ticket.description | raw }}</parsedown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="column">
|
|
<!-- meta data -->
|
|
<div class="block">
|
|
<div class="tags">
|
|
<repeat group="{{ @ticket.tags }}" value="{{@tag}}">
|
|
<span class="tag is-{{@tag.color}}">{{@tag.name}}</span>
|
|
</repeat>
|
|
</div>
|
|
<table class="table is-bordered is-fullwidth">
|
|
<thead>
|
|
<tr><th class="has-width-100">Property</th><th>Value</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
|
|
<check if="{{ @key !== 'description'}}">
|
|
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
|
|
</check>
|
|
</repeat>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- form to add child ticket relationships -->
|
|
<div class="box">
|
|
<h3>Linked Tickets</h3>
|
|
<!-- parent -->
|
|
<check if="{{ @parent_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Parent Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
|
|
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
|
|
</repeat>
|
|
</ul>
|
|
</div>
|
|
</check>
|
|
<!-- child tickets -->
|
|
<check if="{{ @child_tickets }}">
|
|
<div class="block">
|
|
<h4 class="title">Child Tickets</h4>
|
|
<ul>
|
|
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
|
|
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
|
|
</repeat>
|
|
</div>
|
|
</check>
|
|
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
|
|
<div class="field">
|
|
<label class="label">Add existing ticket as child ticket (ID):</label>
|
|
<div class="control">
|
|
<input class="input" type="number" placeholder="Child Ticket ID" required
|
|
name="child_ticket_id">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<button class="button is-link" type="submit">Link</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<hr>
|
|
<include href="../ui/views/attachment/index.html">
|
|
<include href="../ui/views/comments/view.html">
|
|
|
|
|
|
<!--
|
|
<div class="block" id="attachments"></div>
|
|
<div class="block" id="comments"></div>
|
|
-->
|
|
|
|
</div>
|
|
--- End File: ui/views/ticket/view.html ---
|
|
|
|
|
|
--- File: ui/views/user/edit.html ---
|
|
|
|
|
|
<form method="POST" action="/user/{{@edit_user.id}}/update">
|
|
<div class="field">
|
|
<label class="label">Username</label>
|
|
<div class="control">
|
|
<input class="input" name="username" type="text" value="{{ @edit_user.username}}">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label class="label">Role</label>
|
|
<div class="select">
|
|
<select class="select" name="role">
|
|
<option value="user">User</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<div class="control">
|
|
<button class="button is-primary" type="submit">Edit User</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
--- End File: ui/views/user/edit.html ---
|
|
|
|
|
|
--- File: ui/views/user/index.html ---
|
|
<div class="block">
|
|
<h1 class="title">All Users</h1>
|
|
|
|
<table class="table table-bordered is-fullwidth">
|
|
<tr>
|
|
<th>ID</th><th>Username</th><th>Role</th><th>Actions</th>
|
|
</tr>
|
|
|
|
<repeat group="{{ @users }}" value="{{ @u }}">
|
|
<tr>
|
|
<td>{{ @u.id }}</td>
|
|
<td>{{ @u.username }}</td>
|
|
<td>{{ @u.role_name }} ( {{ @u.role }} )</td>
|
|
</tr>
|
|
</repeat>
|
|
|
|
</table>
|
|
</div>
|
|
--- End File: ui/views/user/index.html ---
|
|
|
|
============================================================
|
|
End of Codebase
|
|
============================================================ |