From b747b586b30975d583d243fc79b50ec54f549662 Mon Sep 17 00:00:00 2001
From: Veronika Minova
Date: Sat, 22 Aug 2026 23:48:15 +0200
Subject: [PATCH] added CRUD for Projects, added set project for report
---
backend/src/API.php | 204 +++++++++++++++++++++++---
backend/src/Init.php | 1 +
backend/src/Maintenance.php | 123 ++++++++++++++++
backend/src/Models/Projects.php | 108 ++++++++++++++
backend/src/Models/Reports.php | 41 +++++-
frontend/src/App.vue | 29 +++-
frontend/src/assets/css/style.css | 156 +++++++++++++++++++-
frontend/src/backend.js | 38 +++--
frontend/src/components/ReportBox.vue | 10 +-
frontend/src/dragDropSettings.js | 58 ++++++++
frontend/src/projects.js | 77 ++++++++++
frontend/src/router.js | 2 +
frontend/src/views/Archive.vue | 5 +-
frontend/src/views/BugAdd.vue | 49 +++++--
frontend/src/views/Dashboard.vue | 44 ++++--
frontend/src/views/Projects.vue | 183 +++++++++++++++++++++++
frontend/src/views/Report.vue | 55 ++++++-
17 files changed, 1118 insertions(+), 65 deletions(-)
create mode 100644 backend/src/Models/Projects.php
create mode 100644 frontend/src/dragDropSettings.js
create mode 100644 frontend/src/projects.js
create mode 100644 frontend/src/views/Projects.vue
diff --git a/backend/src/API.php b/backend/src/API.php
index c5284ab..786db88 100644
--- a/backend/src/API.php
+++ b/backend/src/API.php
@@ -8,6 +8,7 @@ use TPsoft\APIlite\APIlite;
use TPsoft\BugreportBackend\Models\Reports;
use TPsoft\BugreportBackend\Models\Attachments;
use TPsoft\BugreportBackend\Models\Options;
+use TPsoft\BugreportBackend\Models\Projects;
class API extends APIlite
@@ -18,21 +19,22 @@ class API extends APIlite
* @param string $title
* @param string $description
* @param int $status 0 = Uncategorized, 1 = Waiting, 2 = InProgress, 3 = Blocked, 4 = Archived
- * @param string $group
+ * @param int $project_id
* @param int $priority 0 = Low, 1 = Medium, 2 = High, 3 = Urgent
*
* @return int
*/
- public function add(string $title, string $description, int $status = 0, ?string $group = null, int $priority = 0): int
+ public function add(string $title, string $description, int $status = 0, ?int $project_id = null, int $priority = 0): int
{
$status = intval($status);
$priority = intval($priority);
+ $project_id = $this->resolveProjectId($project_id);
$reports = new Reports();
$report_id = $reports->report(null, [
'report_title' => $title,
'report_description' => $description,
'report_status' => $status,
- 'report_group' => $group,
+ 'project_id' => $project_id,
'report_priority' => $priority,
]);
return $report_id;
@@ -48,6 +50,12 @@ class API extends APIlite
*/
public function update(int $report_id, array $report_data): bool
{
+ if (array_key_exists('report_group', $report_data)) {
+ throw new \InvalidArgumentException('Field "report_group" is no longer supported');
+ }
+ if (array_key_exists('project_id', $report_data)) {
+ $report_data['project_id'] = $this->resolveProjectId(intval($report_data['project_id']));
+ }
$reports = new Reports();
$suc = $reports->report($report_id, $report_data);
return $suc !== false;
@@ -77,7 +85,9 @@ class API extends APIlite
public function get(int $report_id): array
{
$reports = new Reports();
- return $reports->report($report_id);
+ $report = $reports->reportWithProject($report_id);
+ if ($report === false) throw new \InvalidArgumentException('Report not found');
+ return $report;
}
/**
@@ -85,20 +95,17 @@ class API extends APIlite
*
* @param array $status 0 = Uncategorized, 1 = Waiting, 2 = InProgress, 3 = Blocked, 4 = Archived
* @param int $page Pagination from 0
+ * @param int $project_id Optional project filter
*
* @return array
*/
- public function getAll(?array $status = null, int $page = 0): array
+ public function getAll(?array $status = null, int $page = 0, ?int $project_id = null): array
{
$page = intval($page);
$reports = new Reports();
if ($status === null) $status = array(0, 1, 2, 3);
- $ret = $reports->search('reports')
- ->where(['report_status' => $status])
- ->order(array('report_priority' => 'DESC', 'ordnum' => 'ASC'))
- ->limit($page * 10, 10)
- ->toArray();
- return $ret;
+ if (!is_null($project_id)) $this->requireProject($project_id, false);
+ return $reports->getListWithProjects($status, $page, $project_id);
}
/**
@@ -106,13 +113,14 @@ class API extends APIlite
*
* @param array $status 0 = Uncategorized, 1 = Waiting, 2 = InProgress, 3 = Blocked, 4 = Archived
* @param int $page Pagination from 0
+ * @param int $project_id Optional project filter
*
* @return array
*/
- public function getAllGrouped(?array $status = null, int $page = 0): array
+ public function getAllGrouped(?array $status = null, int $page = 0, ?int $project_id = null): array
{
$page = intval($page);
- $all = $this->getAll($status, $page);
+ $all = $this->getAll($status, $page, $project_id);
$groups = [];
foreach ($all as $report) {
$groups[$report['report_status']][] = $report;
@@ -124,19 +132,16 @@ class API extends APIlite
* Get archived reports
*
* @param int $page Pagination from 0
+ * @param int $project_id Optional project filter
*
* @return array
*/
- public function getArchived(int $page = 0): array
+ public function getArchived(int $page = 0, ?int $project_id = null): array
{
$page = intval($page);
+ if (!is_null($project_id)) $this->requireProject($project_id, false);
$reports = new Reports();
- $ret = $reports->search('reports')
- ->where(['report_status' => 4])
- ->order(array('created_dt' => 'DESC'))
- ->limit($page * 10, 10)
- ->toArray();
- return $ret;
+ return $reports->getListWithProjects(array(4), $page, $project_id, true);
}
/**
@@ -171,6 +176,165 @@ class API extends APIlite
return $suc !== false;
}
+ /**
+ * Get projects
+ *
+ * @param bool $include_archived Include archived projects
+ *
+ * @return array
+ */
+ public function projectGetAll(bool $include_archived = false): array
+ {
+ $projects = new Projects();
+ return $projects->getListWithReportCount($include_archived);
+ }
+
+ /**
+ * Get project
+ *
+ * @param int $project_id
+ *
+ * @return array
+ */
+ public function projectGet(int $project_id): array
+ {
+ return $this->requireProject($project_id, false);
+ }
+
+ /**
+ * Add project
+ *
+ * @param string $name
+ * @param string $code
+ * @param string $color Hex color in #RRGGBB format
+ *
+ * @return int
+ */
+ public function projectAdd(string $name, string $code, string $color): int
+ {
+ $name = $this->validateProjectName($name);
+ $code = $this->validateProjectCode($code);
+ $color = $this->validateProjectColor($color);
+ $projects = new Projects();
+ $project_id = $projects->project(null, array(
+ 'project_name' => $name,
+ 'project_code' => $code,
+ 'project_color' => $color,
+ 'project_active' => 1,
+ 'project_system' => 0,
+ ));
+ if ($project_id === false) throw new \RuntimeException('Project was not created');
+ return intval($project_id);
+ }
+
+ /**
+ * Update project
+ *
+ * @param int $project_id
+ * @param string $name
+ * @param string $color Hex color in #RRGGBB format
+ *
+ * @return bool
+ */
+ public function projectUpdate(int $project_id, string $name, string $color): bool
+ {
+ $project = $this->requireEditableProject($project_id);
+ $name = $this->validateProjectName($name, $project_id);
+ $color = $this->validateProjectColor($color);
+ $projects = new Projects();
+ $suc = $projects->project($project['project_id'], array(
+ 'project_name' => $name,
+ 'project_color' => $color,
+ ));
+ return $suc !== false;
+ }
+
+ /**
+ * Archive or restore project
+ *
+ * @param int $project_id
+ * @param bool $archived True to archive, false to restore
+ *
+ * @return bool
+ */
+ public function projectArchive(int $project_id, bool $archived = true): bool
+ {
+ $project = $this->requireEditableProject($project_id);
+ $projects = new Projects();
+ $suc = $projects->project($project['project_id'], array(
+ 'project_active' => $archived ? 0 : 1,
+ ));
+ return $suc !== false;
+ }
+
+ private function resolveProjectId(?int $project_id = null): int
+ {
+ if (is_null($project_id)) {
+ $projects = new Projects();
+ $project = $projects->projectBy('project_code', 'unassigned');
+ if ($project === false) throw new \RuntimeException('Unassigned project not found');
+ return intval($project['project_id']);
+ }
+ $project = $this->requireProject($project_id);
+ return intval($project['project_id']);
+ }
+
+ private function requireProject(int $project_id, bool $require_active = true): array
+ {
+ $projects = new Projects();
+ $project = $projects->project($project_id);
+ if ($project === false) throw new \InvalidArgumentException('Project not found');
+ if ($require_active && intval($project['project_active']) !== 1) {
+ throw new \InvalidArgumentException('Archived project cannot be assigned');
+ }
+ return $project;
+ }
+
+ private function requireEditableProject(int $project_id): array
+ {
+ $project = $this->requireProject($project_id, false);
+ if (intval($project['project_system']) === 1) {
+ throw new \InvalidArgumentException('System project cannot be changed');
+ }
+ return $project;
+ }
+
+ private function validateProjectName(string $name, ?int $project_id = null): string
+ {
+ $name = trim($name);
+ if (strlen($name) <= 0 || strlen($name) > 255) {
+ throw new \InvalidArgumentException('Project name must contain 1 to 255 characters');
+ }
+ $projects = new Projects();
+ $existing = $projects->projectBy('project_name', $name);
+ if ($existing !== false && intval($existing['project_id']) !== intval($project_id)) {
+ throw new \InvalidArgumentException('Project name already exists');
+ }
+ return $name;
+ }
+
+ private function validateProjectCode(string $code): string
+ {
+ $code = strtolower(trim($code));
+ if (!preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $code)) {
+ throw new \InvalidArgumentException('Project code has invalid format');
+ }
+ $projects = new Projects();
+ if ($projects->projectBy('project_code', $code) !== false) {
+ throw new \InvalidArgumentException('Project code already exists');
+ }
+ return $code;
+ }
+
+ private function validateProjectColor(string $color): string
+ {
+ $color = strtoupper(trim($color));
+ if (!preg_match('/^#[0-9A-F]{6}$/', $color)) {
+ throw new \InvalidArgumentException('Project color must use #RRGGBB format');
+ }
+ return $color;
+ }
+
/**
* Add report attachment
*
diff --git a/backend/src/Init.php b/backend/src/Init.php
index ee16619..be4ed36 100644
--- a/backend/src/Init.php
+++ b/backend/src/Init.php
@@ -11,6 +11,7 @@ if (Configuration::DB_TYPE == 'mysql') {
$dbh = new DBmodel(sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', Configuration::DB_HOST, Configuration::DB_NAME), Configuration::DB_USER, Configuration::DB_PASS);
} else if (Configuration::DB_TYPE == 'sqlite') {
$dbh = new DBmodel(sprintf('sqlite:%s', Configuration::DB_FILEPATH));
+ $dbh->query('PRAGMA foreign_keys = ON');
} else {
throw new Exception('Unknown database type');
}
diff --git a/backend/src/Maintenance.php b/backend/src/Maintenance.php
index ed2dca1..34a6c79 100644
--- a/backend/src/Maintenance.php
+++ b/backend/src/Maintenance.php
@@ -41,6 +41,129 @@ class Maintenance extends \TPsoft\DBmodel\Maintenance
$this->dbver(3);
$dbver = 3;
}
+ if ($dbver == 3) {
+ $this->migrateProjects();
+ $dbver = 4;
+ }
+ }
+
+ private function migrateProjects(): void
+ {
+ $db_type = $this->dbh->getDBtype();
+ if ($db_type == 'sqlite') {
+ $this->migrateProjectsSqlite();
+ return;
+ }
+ if ($db_type == 'mysql') {
+ $this->migrateProjectsMysql();
+ return;
+ }
+ throw new \Exception('Unknown DB type: ' . $db_type);
+ }
+
+ private function migrateProjectsSqlite(): void
+ {
+ $pdo = $this->dbh->dbh;
+ $pdo->beginTransaction();
+ try {
+ $this->queryOrFail('
+ CREATE TABLE `projects` (
+ `project_id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ `project_name` VARCHAR(255) COLLATE NOCASE NOT NULL UNIQUE,
+ `project_code` VARCHAR(64) NOT NULL UNIQUE,
+ `project_color` VARCHAR(7) NOT NULL,
+ `project_active` INTEGER NOT NULL DEFAULT 1,
+ `project_system` INTEGER NOT NULL DEFAULT 0,
+ `created_dt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_dt` DATETIME DEFAULT NULL
+ )
+ ');
+ $this->insertUnassignedProject();
+ $unassigned_id = $this->unassignedProjectId();
+
+ $this->queryOrFail('
+ CREATE TABLE `reports_v4` (
+ `report_id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ `report_title` VARCHAR(255) DEFAULT NULL,
+ `report_description` TEXT DEFAULT NULL,
+ `report_status` INTEGER DEFAULT NULL,
+ `project_id` INTEGER NOT NULL,
+ `report_priority` INTEGER DEFAULT NULL,
+ `ordnum` INTEGER DEFAULT NULL,
+ `created_dt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE RESTRICT
+ )
+ ');
+ $this->queryOrFail(sprintf(
+ 'INSERT INTO `reports_v4` (`report_id`, `report_title`, `report_description`, `report_status`, `project_id`, `report_priority`, `ordnum`, `created_dt`)
+ SELECT `report_id`, `report_title`, `report_description`, `report_status`, %d, `report_priority`, `ordnum`, `created_dt` FROM `reports`',
+ $unassigned_id
+ ));
+ $this->queryOrFail('DROP TABLE `reports`');
+ $this->queryOrFail('ALTER TABLE `reports_v4` RENAME TO `reports`');
+ $this->queryOrFail('CREATE INDEX `reports_project_id` ON `reports` (`project_id`)');
+ $this->dbver(4);
+ $pdo->commit();
+ } catch (\Throwable $error) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ throw $error;
+ }
+ }
+
+ private function migrateProjectsMysql(): void
+ {
+ $this->queryOrFail('
+ CREATE TABLE `projects` (
+ `project_id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ `project_name` VARCHAR(255) NOT NULL UNIQUE,
+ `project_code` VARCHAR(64) NOT NULL UNIQUE,
+ `project_color` VARCHAR(7) NOT NULL,
+ `project_active` TINYINT(1) NOT NULL DEFAULT 1,
+ `project_system` TINYINT(1) NOT NULL DEFAULT 0,
+ `created_dt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_dt` DATETIME DEFAULT NULL
+ ) ENGINE=InnoDB
+ ');
+ $this->insertUnassignedProject();
+ $unassigned_id = $this->unassignedProjectId();
+ $this->queryOrFail('ALTER TABLE `reports` ADD `project_id` INT DEFAULT NULL AFTER `report_status`');
+ $this->queryOrFail(sprintf('UPDATE `reports` SET `project_id` = %d', $unassigned_id));
+ $this->queryOrFail('ALTER TABLE `reports` MODIFY `project_id` INT NOT NULL');
+ $this->queryOrFail('ALTER TABLE `reports` DROP COLUMN `report_group`');
+ $this->queryOrFail('CREATE INDEX `reports_project_id` ON `reports` (`project_id`)');
+ $this->queryOrFail('
+ ALTER TABLE `reports`
+ ADD CONSTRAINT `reports_project_id_fk`
+ FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE RESTRICT
+ ');
+ $this->dbver(4);
+ }
+
+ private function insertUnassignedProject(): void
+ {
+ $this->queryOrFail(sprintf(
+ 'INSERT INTO `projects` (`project_name`, `project_code`, `project_color`, `project_active`, `project_system`, `created_dt`)
+ VALUES (%s, %s, %s, 1, 1, %s)',
+ $this->dbh->quote('Nezaradené'),
+ $this->dbh->quote('unassigned'),
+ $this->dbh->quote('#797979'),
+ $this->dbh->quote(date('Y-m-d H:i:s'))
+ ));
+ }
+
+ private function unassignedProjectId(): int
+ {
+ return intval($this->dbh->getOne(sprintf(
+ 'SELECT `project_id` FROM `projects` WHERE `project_code` = %s',
+ $this->dbh->quote('unassigned')
+ )));
+ }
+
+ private function queryOrFail(string $query): void
+ {
+ if ($this->dbh->query($query) === false) {
+ throw new \RuntimeException($this->dbh->errorMessage());
+ }
}
protected function settings(string $key, ?string $value = null): string|false
diff --git a/backend/src/Models/Projects.php b/backend/src/Models/Projects.php
new file mode 100644
index 0000000..7db8680
--- /dev/null
+++ b/backend/src/Models/Projects.php
@@ -0,0 +1,108 @@
+ array(
+ 'name' => 'projects',
+ 'primary_key_name' => 'project_id',
+ 'allow_attributes' => array(
+ 'project_name' => 'VARCHAR(255)',
+ 'project_code' => 'VARCHAR(64)',
+ 'project_color' => 'VARCHAR(7)',
+ 'project_active' => 'INTEGER',
+ 'project_system' => 'INTEGER',
+ 'created_dt' => 'DATETIME',
+ 'updated_dt' => 'DATETIME'
+ )
+ ),
+ );
+
+ public function exist($primary_key = null) {
+ return $this->existRecord('projects', $primary_key);
+ }
+
+ public function project($primary_key = null, $data = array()) {
+ if (is_null($primary_key)
+ && !isset($data['created_dt']))
+ {
+ $data['created_dt'] = date('Y-m-d H:i:s');
+ }
+ if (!is_null($primary_key)
+ && is_array($data)
+ && count($data) > 0
+ && !isset($data['updated_dt']))
+ {
+ $data['updated_dt'] = date('Y-m-d H:i:s');
+ }
+ return $this->record('projects', $primary_key, $data);
+ }
+
+ public function projectBy($colname, $colvalue) {
+ return $this->recordBy('projects', $colname, $colvalue);
+ }
+
+ public function projectSave($data = array()) {
+ return $this->project($this->exist($data) ? $data : null, $data);
+ }
+
+ public function projectEmpty() {
+ return $this->recordEmpty('projects');
+ }
+
+ public function projectAttributes() {
+ return $this->typesAttributes('projects');
+ }
+
+ public function projectCount() {
+ return $this->count('projects');
+ }
+
+ public function getList($search = array(), $reverse = false, $concat_or = false) {
+ return $this->search('projects')
+ ->where($search, $concat_or)
+ ->order(array('project_id' => $reverse ? 'DESC' : 'ASC'))
+ ->toArray();
+ }
+
+ public function getListOrganize($cola_name, $search = array(), $reverse = false, $concat_or = false) {
+ $all = $this->getList($search, $reverse, $concat_or);
+ $ret = array();
+ if (is_array($all)) foreach ($all as $key => $row) {
+ $ret[$row[$cola_name]] = $row;
+ }
+ return $ret;
+ }
+
+ public function getListByID($search = array(), $reverse = false, $concat_or = false) {
+ return $this->getListOrganize('project_id', $search, $reverse, $concat_or);
+ }
+
+ public function projectCombo($col_key, $col_value, $add_empty = false) {
+ return $this->search('projects')
+ ->toCombo($col_key, $col_value, $add_empty);
+ }
+
+ public function getListWithReportCount($include_archived = false) {
+ $where = $include_archived ? '' : ' WHERE `project_active` = 1';
+ return $this->getAll(
+ 'SELECT `projects`.*,'
+ . ' (SELECT COUNT(*) FROM `reports` WHERE `reports`.`project_id` = `projects`.`project_id`) AS `report_count`'
+ . ' FROM `projects`'
+ . $where
+ . ' ORDER BY `project_active` DESC, `project_system` DESC, `project_name` ASC'
+ );
+ }
+
+}
+
+?>
diff --git a/backend/src/Models/Reports.php b/backend/src/Models/Reports.php
index ad3dc57..50ad120 100644
--- a/backend/src/Models/Reports.php
+++ b/backend/src/Models/Reports.php
@@ -19,7 +19,7 @@ class Reports extends \TPsoft\DBmodel\DBmodel {
'report_title' => 'VARCHAR(255)',
'report_description' => 'TEXT',
'report_status' => 'INTEGER',
- 'report_group' => 'VARCHAR(255)',
+ 'project_id' => 'INTEGER',
'report_priority' => 'INTEGER',
'created_dt' => 'DATETIME',
'ordnum' => 'INTEGER'
@@ -85,6 +85,45 @@ class Reports extends \TPsoft\DBmodel\DBmodel {
->toCombo($col_key, $col_value, $add_empty);
}
+ public function reportWithProject($report_id) {
+ $this->import(new Projects());
+ return $this->getRow(sprintf(
+ 'SELECT `reports`.*, `projects`.`project_name`, `projects`.`project_code`,'
+ . ' `projects`.`project_color`, `projects`.`project_active`'
+ . ' FROM %s'
+ . ' INNER JOIN %s ON `projects`.`project_id` = `reports`.`project_id`'
+ . ' WHERE `reports`.`report_id` = %d LIMIT 1',
+ $this->tables['reports']['name'],
+ $this->tables['projects']['name'],
+ intval($report_id)
+ ));
+ }
+
+ public function getListWithProjects($status, $page = 0, $project_id = null, $archived = false) {
+ $status = array_map('intval', $status);
+ if (count($status) <= 0) return array();
+ $where = '`reports`.`report_status` IN (' . implode(', ', $status) . ')';
+ if (!is_null($project_id)) {
+ $where .= ' AND `reports`.`project_id` = ' . intval($project_id);
+ }
+ $order = $archived
+ ? '`reports`.`created_dt` DESC'
+ : '`reports`.`report_priority` DESC, `reports`.`ordnum` ASC';
+ $this->import(new Projects());
+ return $this->getAll(sprintf(
+ 'SELECT `reports`.*, `projects`.`project_name`, `projects`.`project_code`,'
+ . ' `projects`.`project_color`, `projects`.`project_active`'
+ . ' FROM %s'
+ . ' INNER JOIN %s ON `projects`.`project_id` = `reports`.`project_id`'
+ . ' WHERE %s ORDER BY %s LIMIT %d, 10',
+ $this->tables['reports']['name'],
+ $this->tables['projects']['name'],
+ $where,
+ $order,
+ intval($page) * 10
+ ));
+ }
+
}
?>
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 0840034..4c24a06 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -15,7 +15,20 @@
v-model="short_bug"
@keyup.enter="onShortBugEnter"
/>
-
@@ -42,4 +46,4 @@ export default {
},
},
};
-
\ No newline at end of file
+
diff --git a/frontend/src/dragDropSettings.js b/frontend/src/dragDropSettings.js
new file mode 100644
index 0000000..841731b
--- /dev/null
+++ b/frontend/src/dragDropSettings.js
@@ -0,0 +1,58 @@
+const DRAG_DROP_STORAGE_KEY = "bugreport_drag_drop_enabled";
+const DRAG_DROP_MEDIA_QUERY = "(max-width: 768px), (any-pointer: coarse)";
+
+let sessionDragDropPreference = null;
+
+function getStorage() {
+ try {
+ if (typeof window !== "undefined" && window.localStorage) {
+ return window.localStorage;
+ }
+ } catch (error) {
+ return null;
+ }
+ return null;
+}
+
+export function getDragDropMediaQuery() {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
+ return null;
+ }
+ return window.matchMedia(DRAG_DROP_MEDIA_QUERY);
+}
+
+export function getDragDropPreference() {
+ if (sessionDragDropPreference != null) return sessionDragDropPreference;
+
+ let storage = getStorage();
+ if (storage != null) {
+ try {
+ let storedPreference = storage.getItem(DRAG_DROP_STORAGE_KEY);
+ if (storedPreference === "true") return true;
+ if (storedPreference === "false") return false;
+ } catch (error) {
+ return null;
+ }
+ }
+ return null;
+}
+
+export function getDragDropEnabled(mediaQuery = null) {
+ let preference = getDragDropPreference();
+ if (preference != null) return preference;
+
+ if (mediaQuery == null) mediaQuery = getDragDropMediaQuery();
+ return mediaQuery == null || !mediaQuery.matches;
+}
+
+export function setDragDropEnabled(enabled) {
+ sessionDragDropPreference = enabled;
+ let storage = getStorage();
+ if (storage == null) return;
+
+ try {
+ storage.setItem(DRAG_DROP_STORAGE_KEY, String(enabled));
+ } catch (error) {
+ // The in-memory preference still applies until the page is reloaded.
+ }
+}
diff --git a/frontend/src/projects.js b/frontend/src/projects.js
new file mode 100644
index 0000000..c97d289
--- /dev/null
+++ b/frontend/src/projects.js
@@ -0,0 +1,77 @@
+import { computed, ref } from "vue";
+import backend from "./backend";
+
+const SELECTED_PROJECT_KEY = "bugreport_selected_project_id";
+
+export const projects = ref([]);
+export const projectsLoading = ref(false);
+export const activeProjects = computed(() =>
+ projects.value.filter((project) => Number(project.project_active) === 1)
+);
+export const selectedProjectId = ref(readSelectedProjectId());
+
+let loadPromise = null;
+
+function getStorage() {
+ try {
+ if (typeof window !== "undefined" && window.localStorage) {
+ return window.localStorage;
+ }
+ } catch (error) {
+ return null;
+ }
+ return null;
+}
+
+function readSelectedProjectId() {
+ let storage = getStorage();
+ if (storage == null) return null;
+ try {
+ let projectId = Number(storage.getItem(SELECTED_PROJECT_KEY));
+ return Number.isInteger(projectId) && projectId > 0 ? projectId : null;
+ } catch (error) {
+ return null;
+ }
+}
+
+function ensureSelectedProject() {
+ let selected = activeProjects.value.find(
+ (project) => Number(project.project_id) === Number(selectedProjectId.value)
+ );
+ if (selected != null) return;
+ let fallback = activeProjects.value.find(
+ (project) => project.project_code === "unassigned"
+ ) ?? activeProjects.value[0];
+ setSelectedProject(fallback == null ? null : fallback.project_id);
+}
+
+export function setSelectedProject(projectId) {
+ selectedProjectId.value = projectId == null ? null : Number(projectId);
+ let storage = getStorage();
+ if (storage == null) return;
+ try {
+ if (selectedProjectId.value == null) {
+ storage.removeItem(SELECTED_PROJECT_KEY);
+ return;
+ }
+ storage.setItem(SELECTED_PROJECT_KEY, String(selectedProjectId.value));
+ } catch (error) {
+ // The selected project still applies until the page is reloaded.
+ }
+}
+
+export function loadProjects() {
+ if (loadPromise != null) return loadPromise;
+ projectsLoading.value = true;
+ loadPromise = backend.projectGetAll(true)
+ .then((response) => {
+ projects.value = response.data;
+ ensureSelectedProject();
+ return projects.value;
+ })
+ .finally(() => {
+ projectsLoading.value = false;
+ loadPromise = null;
+ });
+ return loadPromise;
+}
diff --git a/frontend/src/router.js b/frontend/src/router.js
index 3fe0214..ffc8721 100644
--- a/frontend/src/router.js
+++ b/frontend/src/router.js
@@ -6,6 +6,7 @@ import BugAdd from "./views/BugAdd.vue";
import Archive from "./views/Archive.vue";
import API from "./views/API.vue";
import Report from "./views/Report.vue";
+import Projects from "./views/Projects.vue";
const routes = [
{ path: "/", component: Dashboard },
@@ -14,6 +15,7 @@ const routes = [
{ path: "/archive", component: Archive },
{ path: "/api", component: API },
{ path: "/report/:id", component: Report },
+ { path: "/projects", component: Projects },
];
export const router = createRouter({
diff --git a/frontend/src/views/Archive.vue b/frontend/src/views/Archive.vue
index 8551253..f1212bb 100644
--- a/frontend/src/views/Archive.vue
+++ b/frontend/src/views/Archive.vue
@@ -17,7 +17,10 @@
{{ report.report_title }}
{{ report.created_dt }}
- {{ report.report_group }}
+
+
+ {{ report.project_name }}
+
diff --git a/frontend/src/views/BugAdd.vue b/frontend/src/views/BugAdd.vue
index 0ec6226..fc37333 100644
--- a/frontend/src/views/BugAdd.vue
+++ b/frontend/src/views/BugAdd.vue
@@ -47,18 +47,22 @@
-
+
@@ -81,24 +85,46 @@
diff --git a/frontend/src/views/Report.vue b/frontend/src/views/Report.vue
index 3e72e69..cddfbb3 100644
--- a/frontend/src/views/Report.vue
+++ b/frontend/src/views/Report.vue
@@ -20,8 +20,31 @@
{{ report.report_priority }}
- Skupina
- {{ report.report_group }}
+ Projekt
+
+
+
+
+ {{ report.project_name }}
+
@@ -145,11 +168,12 @@