Initial commit.

This commit is contained in:
Cameron Redmore 2025-04-23 15:55:28 +01:00
commit 2d11d0bd79
54 changed files with 6657 additions and 0 deletions

7
.editorconfig Normal file
View file

@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
.DS_Store
.thumbs.db
node_modules
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
# local .env files
.env.local*

5
.npmrc Normal file
View file

@ -0,0 +1,5 @@
# pnpm-related options
shamefully-hoist=true
strict-peer-dependencies=false
# to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463
resolution-mode=highest

13
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,13 @@
{
"recommendations": [
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

4
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true
}

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# STS Forms (forms-app)
Custom forms application for StyleTech
## Install the dependencies
```bash
yarn
# or
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash
quasar dev
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

21
index.html Normal file
View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

3
jsconfig.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "./.quasar/tsconfig.json"
}

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "forms-app",
"version": "0.0.1",
"description": "Custom forms application for StyleTech",
"productName": "STS Forms",
"author": "Cameron Redmore <cameron@redmore.me>",
"type": "module",
"private": true,
"scripts": {
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev -m ssr",
"build": "quasar build -m ssr",
"postinstall": "quasar prepare"
},
"dependencies": {
"@google/genai": "^0.9.0",
"@quasar/extras": "^1.16.4",
"axios": "^1.8.4",
"better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0",
"pdfkit": "^0.17.0",
"pdfmake": "^0.2.18",
"quasar": "^2.16.0",
"vue": "^3.4.18",
"vue-router": "^4.0.0"
},
"devDependencies": {
"@quasar/app-vite": "^2.1.0",
"autoprefixer": "^10.4.2",
"postcss": "^8.4.14"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}

4320
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

4
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,4 @@
onlyBuiltDependencies:
- better-sqlite3
- esbuild
- sqlite3

29
postcss.config.js Normal file
View file

@ -0,0 +1,29 @@
// https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer'
// import rtlcss from 'postcss-rtlcss'
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line (and its import statement above):
// rtlcss()
]
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

212
quasar.config.js Normal file
View file

@ -0,0 +1,212 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app/wrappers'
export default defineConfig((/* ctx */) => {
return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: [
'app.scss'
],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v7',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
target: {
browser: [ 'es2022', 'firefox115', 'chrome115', 'safari14' ],
node: 'node20'
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
// vitePlugins: [
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
// ]
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: {
// https: true,
open: true // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {
dark: "auto"
},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: [
'Dark',
'Notify',
'Dialog'
]
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// pwaRegisterServiceWorker: 'src-pwa/register-service-worker',
// pwaServiceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// bexManifestFile: 'src-bex/manifest.json
// },
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render' // keep this as last one
],
// extendPackageJson (json) {},
// extendSSRWebserverConf (esbuildConf) {},
// manualStoreSerialization: true,
// manualStoreSsrContextInjection: true,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
pwa: false
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// pwaExtendGenerateSWOptions (cfg) {},
// pwaExtendInjectManifestOptions (cfg) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js',
// manifestFilename: 'manifest.json',
// extendManifestJson (json) {},
// useCredentialsForManifestTag: true,
// injectPwaMetaTags: false,
// extendPWACustomSWConf (esbuildConf) {},
// extendGenerateSWOptions (cfg) {},
// extendInjectManifestOptions (cfg) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf) {},
// extendElectronPreloadConf (esbuildConf) {},
// extendPackageJson (json) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
preloadScripts: [ 'electron-preload' ],
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration/configuration
appId: 'forms-app'
}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
// extendBexScriptsConf (esbuildConf) {},
// extendBexManifestJson (json) {},
/**
* The list of extra scripts (js/ts) not in your bex manifest that you want to
* compile and use in your browser extension. Maybe dynamic use them?
*
* Each entry in the list should be a relative filename to /src-bex/
*
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/
extraScripts: []
}
}
})

110
src-ssr/database.js Normal file
View file

@ -0,0 +1,110 @@
import Database from 'better-sqlite3';
import { join } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs'; // Needed to check if db file exists
// Determine the database path relative to this file
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const dbPath = join(__dirname, 'forms.db');
let db = null;
export function initializeDatabase() {
if (db) {
return db;
}
try {
// Check if the directory exists, create if not (better-sqlite3 might need this)
const dbDir = join(__dirname);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// better-sqlite3 constructor opens/creates the database file
db = new Database(dbPath, { verbose: console.log }); // Enable verbose logging
console.log('Connected to the SQLite database using better-sqlite3.');
// Ensure WAL mode is enabled for better concurrency
db.pragma('journal_mode = WAL');
// Create tables if they don't exist (run sequentially)
db.exec(`
CREATE TABLE IF NOT EXISTS forms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formId INTEGER NOT NULL,
name TEXT NOT NULL,
sortOrder INTEGER DEFAULT 0,
FOREIGN KEY (formId) REFERENCES forms (id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
categoryId INTEGER NOT NULL,
label TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('text', 'number', 'date', 'textarea', 'boolean')),
description TEXT,
sortOrder INTEGER NOT NULL,
FOREIGN KEY (categoryId) REFERENCES categories(id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formId INTEGER NOT NULL,
submittedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (formId) REFERENCES forms (id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS response_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
responseId INTEGER NOT NULL,
fieldId INTEGER NOT NULL,
value TEXT,
FOREIGN KEY (responseId) REFERENCES responses (id) ON DELETE CASCADE,
FOREIGN KEY (fieldId) REFERENCES fields (id) ON DELETE CASCADE
);
`);
console.log('Database tables ensured.');
return db;
} catch (err) {
console.error('Error initializing database with better-sqlite3:', err.message);
throw err; // Re-throw the error
}
}
export function getDb() {
if (!db) {
// Try to initialize if not already done (e.g., during hot reload)
try {
initializeDatabase();
} catch (err) {
throw new Error('Database not initialized and initialization failed.');
}
if (!db) { // Check again after trying to initialize
throw new Error('Database not initialized. Call initializeDatabase first.');
}
}
return db;
}
// Optional: Add a function to close the database gracefully on server shutdown
export function closeDatabase() {
if (db) {
db.close();
db = null;
console.log('Database connection closed.');
}
}

View file

@ -0,0 +1,58 @@
import { defineSsrMiddleware } from '#q-app/wrappers'
// This middleware should execute as last one
// since it captures everything and tries to
// render the page with Vue
export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
// we capture any other Express route and hand it
// over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => {
res.setHeader('Content-Type', 'text/html')
render(/* the ssrContext: */ { req, res })
.then(html => {
// now let's send the rendered html to the client
res.send(html)
})
.catch(err => {
// oops, we had an error while rendering the page
// we were told to redirect to another URL
if (err.url) {
if (err.code) {
res.redirect(err.code, err.url)
} else {
res.redirect(err.url)
}
} else if (err.code === 404) {
// hmm, Vue Router could not find the requested route
// Should reach here only if no "catch-all" route
// is defined in /src/routes
res.status(404).send('404 | Page Not Found')
} else if (process.env.DEV) {
// well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI
// to display a nice error page that contains the stack
// and other useful information
// serve.error is available on dev only
serve.error({ err, req, res })
} else {
// we're in production, so we should have another method
// to display something to the client when we encounter an error
// (for security reasons, it's not ok to display the same wealth
// of information as we do in development)
// Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error')
if (process.env.DEBUGGING) {
console.error(err.stack)
}
}
})
})
})

598
src-ssr/routes/api.js Normal file
View file

@ -0,0 +1,598 @@
import { Router } from 'express';
import { getDb } from '../database.js';
import PDFDocument from 'pdfkit'; // Import pdfkit
import axios from 'axios'; // Added for Mantis
import { GoogleGenAI } from '@google/genai'; // Added for GenAI
import * as fs from 'fs'; // Added for reading prompt file
import * as path from 'path'; // Added for path manipulation
const router = Router();
const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, '');
import { join } from 'path';
// --- Environment Variables (Ensure these are set in your .env file) ---
const {
MANTIS_API_KEY,
MANTIS_API_ENDPOINT,
GOOGLE_API_KEY
} = process.env;
// --- Mantis Summarizer Setup ---
const promptFilePath = join(__dirname, 'prompt.txt'); // Path relative to this file
const ai = GOOGLE_API_KEY ? new GoogleGenAI({ // Check if API key exists
apiKey: GOOGLE_API_KEY,
}) : null;
const usernameMap = {
'credmore': 'Cameron Redmore',
'dgibson': 'Dane Gibson',
'egzibovskis': 'Ed Gzibovskis',
'ascotney': 'Amanda Scotney',
'gclough': 'Garry Clough',
'slee': 'Sarah Lee',
'dwalker': 'Dave Walker',
'askaith': 'Amy Skaith',
'dpotter': 'Danny Potter',
'msmart': 'Michael Smart',
// Add other usernames as needed
};
async function getMantisTickets() {
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) {
throw new Error("Mantis API endpoint or key not configured in environment variables.");
}
const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
const headers = {
'Authorization': `${MANTIS_API_KEY}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
};
try {
const response = await axios.get(url, { headers });
const tickets = response.data.issues.filter((ticket) => {
const ticketDate = new Date(ticket.updated_at);
const thresholdDate = new Date();
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
const daysToSubtract = currentDay === 1 ? 4 : 2;
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return ticketDate >= thresholdDate;
}).map((ticket) => {
return {
id: ticket.id,
summary: ticket.summary,
description: ticket.description,
created_at: ticket.created_at,
updated_at: ticket.updated_at,
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
notes: (ticket.notes ? ticket.notes.filter((note) => {
const noteDate = new Date(note.created_at);
const thresholdDate = new Date();
const currentDay = thresholdDate.getDay();
const daysToSubtract = currentDay === 1 ? 4 : 2;
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return noteDate >= thresholdDate;
}) : []).map((note) => {
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
return {
reporter,
created_at: note.created_at,
text: note.text,
};
}),
};
});
return tickets;
} catch (error) {
console.error("Error fetching Mantis tickets:", error.message);
// Check if it's an Axios error and provide more details
if (axios.isAxiosError(error)) {
console.error("Axios error details:", error.response?.status, error.response?.data);
throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
}
throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
}
}
// --- Forms API --- //
// GET /api/forms - List all forms
router.get('/forms', (req, res) => {
try {
const db = getDb();
const forms = db.prepare('SELECT id, title, description, createdAt FROM forms ORDER BY createdAt DESC').all();
res.json(forms);
} catch (err) {
console.error('Error fetching forms:', err.message);
res.status(500).json({ error: 'Failed to fetch forms' });
}
});
// POST /api/forms - Create a new form
router.post('/forms', (req, res) => {
const { title, description, categories } = req.body;
if (!title) {
return res.status(400).json({ error: 'Form title is required' });
}
const db = getDb();
const insertForm = db.prepare('INSERT INTO forms (title, description) VALUES (?, ?)');
const insertCategory = db.prepare('INSERT INTO categories (formId, name, sortOrder) VALUES (?, ?, ?)');
const insertField = db.prepare('INSERT INTO fields (categoryId, label, type, description, sortOrder) VALUES (?, ?, ?, ?, ?)');
const createTransaction = db.transaction((formData) => {
const { title, description, categories } = formData;
const formResult = insertForm.run(title, description);
const formId = formResult.lastInsertRowid;
if (categories && categories.length > 0) {
for (const [catIndex, category] of categories.entries()) {
if (!category.name) throw new Error('Category name is required');
const catResult = insertCategory.run(formId, category.name, catIndex);
const categoryId = catResult.lastInsertRowid;
if (category.fields && category.fields.length > 0) {
for (const [fieldIndex, field] of category.fields.entries()) {
if (!field.label || !field.type) {
throw new Error('Field label and type are required');
}
const validTypes = ['text', 'number', 'date', 'textarea', 'boolean'];
if (!validTypes.includes(field.type)) {
throw new Error(`Invalid field type: ${field.type}`);
}
insertField.run(categoryId, field.label, field.type, field.description || null, fieldIndex);
}
}
}
}
return { id: formId, title, description };
});
try {
const resultData = createTransaction({ title, description, categories });
res.status(201).json(resultData);
} catch (err) {
console.error('Error creating form:', err.message);
res.status(500).json({ error: `Failed to create form: ${err.message}` });
}
});
// GET /api/forms/:id - Get a specific form with its structure
router.get('/forms/:id', (req, res) => {
const { id } = req.params;
try {
const db = getDb();
const form = db.prepare('SELECT id, title, description FROM forms WHERE id = ?').get(id);
if (!form) {
return res.status(404).json({ error: 'Form not found' });
}
const categories = db.prepare(`
SELECT c.id, c.name, c.sortOrder
FROM categories c
WHERE c.formId = ?
ORDER BY c.sortOrder
`).all(id);
const getFieldsStmt = db.prepare(`
SELECT f.id, f.label, f.type, f.description, f.sortOrder
FROM fields f
WHERE f.categoryId = ?
ORDER BY f.sortOrder
`);
for (const category of categories) {
category.fields = getFieldsStmt.all(category.id);
}
form.categories = categories;
res.json(form);
} catch (err) {
console.error(`Error fetching form ${id}:`, err.message);
res.status(500).json({ error: 'Failed to fetch form details' });
}
});
// DELETE /api/forms/:id - Delete a specific form and all related data
router.delete('/forms/:id', (req, res) => {
const formId = req.params.id; // Corrected destructuring
const db = getDb();
const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?');
const deleteValuesStmt = db.prepare('DELETE FROM response_values WHERE responseId IN (SELECT id FROM responses WHERE formId = ?)');
const deleteResponsesStmt = db.prepare('DELETE FROM responses WHERE formId = ?');
const deleteFieldsStmt = db.prepare('DELETE FROM fields WHERE categoryId IN (SELECT id FROM categories WHERE formId = ?)');
const deleteCategoriesStmt = db.prepare('DELETE FROM categories WHERE formId = ?');
const deleteFormStmt = db.prepare('DELETE FROM forms WHERE id = ?');
const deleteTransaction = db.transaction((id) => {
const form = checkFormStmt.get(id);
if (!form) {
const err = new Error('Form not found');
err.statusCode = 404;
throw err;
}
// Delete in order of dependency: values -> responses -> fields -> categories -> form
deleteValuesStmt.run(id);
deleteResponsesStmt.run(id);
deleteFieldsStmt.run(id);
deleteCategoriesStmt.run(id);
deleteFormStmt.run(id);
return { message: `Form ${id} and all related data deleted successfully.` };
});
try {
const resultData = deleteTransaction(formId);
res.status(200).json(resultData);
} catch (err) {
console.error(`Error deleting form ${formId}:`, err.message);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({ error: `Failed to delete form: ${err.message}` });
}
});
// --- Responses API --- //
// POST /api/forms/:id/responses - Submit a response for a form
router.post('/forms/:id/responses', (req, res) => {
const { id: formId } = req.params;
const { values } = req.body;
if (!values || typeof values !== 'object' || Object.keys(values).length === 0) {
return res.status(400).json({ error: 'Response values are required' });
}
const db = getDb();
const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?');
const checkFieldStmt = db.prepare('SELECT f.id FROM fields f JOIN categories c ON f.categoryId = c.id WHERE f.id = ? AND c.formId = ?');
const insertResponseStmt = db.prepare('INSERT INTO responses (formId) VALUES (?)');
const insertValueStmt = db.prepare('INSERT INTO response_values (responseId, fieldId, value) VALUES (?, ?, ?)');
const submitTransaction = db.transaction((formIdParam, responseValues) => {
const form = checkFormStmt.get(formIdParam);
if (!form) {
const err = new Error('Form not found');
err.statusCode = 404;
throw err;
}
const responseResult = insertResponseStmt.run(formIdParam);
const responseId = responseResult.lastInsertRowid;
for (const [fieldIdStr, value] of Object.entries(responseValues)) {
const fieldId = parseInt(fieldIdStr, 10);
const field = checkFieldStmt.get(fieldId, formIdParam);
if (!field) {
console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formIdParam}`);
continue;
}
const valueToStore = (value === null || typeof value === 'undefined') ? null : String(value);
insertValueStmt.run(responseId, fieldId, valueToStore);
}
return { responseId };
});
try {
const resultData = submitTransaction(formId, values);
res.status(201).json(resultData);
} catch (err) {
console.error(`Error submitting response for form ${formId}:`, err.message);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({ error: `Failed to submit response: ${err.message}` });
}
});
// GET /api/forms/:id/responses - Get all responses for a form
router.get('/forms/:id/responses', (req, res) => {
const { id: formId } = req.params;
try {
const db = getDb();
const formExists = db.prepare('SELECT id FROM forms WHERE id = ?').get(formId);
if (!formExists) {
return res.status(404).json({ error: 'Form not found' });
}
const responses = db.prepare(`
SELECT r.id as responseId, r.submittedAt,
rv.fieldId, f.label as fieldLabel, f.type as fieldType, rv.value
FROM responses r
JOIN response_values rv ON r.id = rv.responseId
JOIN fields f ON rv.fieldId = f.id
JOIN categories c ON f.categoryId = c.id
WHERE r.formId = ?
ORDER BY r.submittedAt DESC, r.id, c.sortOrder, f.sortOrder
`).all(formId);
const groupedResponses = responses.reduce((acc, row) => {
const { responseId, submittedAt, fieldId, fieldLabel, fieldType, value } = row;
if (!acc[responseId]) {
acc[responseId] = {
id: responseId,
submittedAt,
values: {}
};
}
acc[responseId].values[fieldId] = { label: fieldLabel, type: fieldType, value };
return acc;
}, {});
res.json(Object.values(groupedResponses));
} catch (err) {
console.error(`Error fetching responses for form ${formId}:`, err.message);
res.status(500).json({ error: 'Failed to fetch responses' });
}
});
// PUT /api/forms/:id - Update an existing form
router.put('/forms/:id', (req, res) => {
const { id: formId } = req.params;
const { title, description, categories } = req.body;
if (!title) {
return res.status(400).json({ error: 'Form title is required' });
}
const db = getDb();
const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?');
const updateFormStmt = db.prepare('UPDATE forms SET title = ?, description = ? WHERE id = ?');
const deleteFieldsStmt = db.prepare('DELETE FROM fields WHERE categoryId IN (SELECT id FROM categories WHERE formId = ?)');
const deleteCategoriesStmt = db.prepare('DELETE FROM categories WHERE formId = ?');
const insertCategoryStmt = db.prepare('INSERT INTO categories (formId, name, sortOrder) VALUES (?, ?, ?)');
const insertFieldStmt = db.prepare('INSERT INTO fields (categoryId, label, type, description, sortOrder) VALUES (?, ?, ?, ?, ?)');
const updateTransaction = db.transaction((formData) => {
const { formId, title, description, categories } = formData;
// 1. Check if form exists
const existingForm = checkFormStmt.get(formId);
if (!existingForm) {
const err = new Error('Form not found');
err.statusCode = 404;
throw err;
}
// 2. Delete existing categories and fields for this form
deleteFieldsStmt.run(formId);
deleteCategoriesStmt.run(formId);
// 3. Update form details
updateFormStmt.run(title, description, formId);
// 4. Re-insert categories and fields
if (categories && categories.length > 0) {
for (const [catIndex, category] of categories.entries()) {
if (!category.name) throw new Error('Category name is required');
const catResult = insertCategoryStmt.run(formId, category.name, catIndex);
const categoryId = catResult.lastInsertRowid;
if (category.fields && category.fields.length > 0) {
for (const [fieldIndex, field] of category.fields.entries()) {
if (!field.label || !field.type) {
throw new Error('Field label and type are required');
}
const validTypes = ['text', 'number', 'date', 'textarea', 'boolean'];
if (!validTypes.includes(field.type)) {
throw new Error(`Invalid field type: ${field.type}`);
}
insertFieldStmt.run(categoryId, field.label, field.type, field.description || null, fieldIndex);
}
}
}
}
// Return the updated form ID and title (or potentially the full updated form structure)
return { id: formId, title, description };
});
try {
const resultData = updateTransaction({ formId, title, description, categories });
// Optionally fetch the full updated form structure here if needed for the response
res.status(200).json(resultData); // Send back basic info for now
} catch (err) {
console.error(`Error updating form ${formId}:`, err.message);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({ error: `Failed to update form: ${err.message}` });
}
});
router.get('/responses/:responseId/export/pdf', async (req, res) => {
const { responseId } = req.params;
try {
const db = getDb();
// 1. Fetch the response and its associated form ID
const response = db.prepare(`
SELECT r.id, r.formId, r.submittedAt, f.title as formTitle
FROM responses r
JOIN forms f ON r.formId = f.id
WHERE r.id = ?
`).get(responseId);
if (!response) {
return res.status(404).json({ error: 'Response not found' });
}
const formId = response.formId;
const formTitle = response.formTitle;
// 2. Fetch the form structure (categories and fields)
const categories = db.prepare(`
SELECT c.id, c.name
FROM categories c
WHERE c.formId = ?
ORDER BY c.sortOrder
`).all(formId);
const getFieldsStmt = db.prepare(`
SELECT f.id, f.label, f.type, f.description
FROM fields f
WHERE f.categoryId = ?
ORDER BY f.sortOrder
`);
for (const category of categories) {
category.fields = getFieldsStmt.all(category.id);
}
// 3. Fetch the values for this specific response
const valuesResult = db.prepare(`
SELECT fieldId, value
FROM response_values
WHERE responseId = ?
`).all(responseId);
const responseValues = valuesResult.reduce((acc, row) => {
acc[row.fieldId] = (row.value === null || typeof row.value === 'undefined') ? '' : String(row.value);
return acc;
}, {});
// 4. Generate PDF using pdfkit
const doc = new PDFDocument({ margin: 50, size: 'A4' }); // Set size to A4
doc.registerFont('Roboto-Bold', join(fontsDir, 'Roboto-Bold.ttf'));
doc.registerFont('Roboto-SemiBold', join(fontsDir, 'Roboto-SemiBold.ttf'));
doc.registerFont('Roboto-Italics', join(fontsDir, 'Roboto-Italic.ttf'));
doc.registerFont('Roboto-Regular', join(fontsDir, 'Roboto-Regular.ttf'));
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename=response_${responseId}_${formTitle.replace(/[\s\\/]/g, '_') || 'form'}.pdf`); // Use inline to preview
doc.pipe(res);
// --- PDF Content ---
// Title
doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' });
doc.moveDown();
// Iterate through categories and fields
for (const category of categories) {
if (category.name) {
doc.fontSize(14).font('Roboto-Bold').text(category.name);
doc.moveDown(0.5);
}
for (const field of category.fields) {
const value = responseValues[field.id] || ''; // Get the value for this field
// Field Label
doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false }); // Use continued: false to reset position potentially
// Optional: Add description
if (field.description) {
doc.fontSize(9).font('Roboto-Italics').text(field.description);
}
doc.moveDown(0.2);
// Field Value (mimic input)
doc.fontSize(11).font('Roboto-Regular');
if (field.type === 'textarea') {
// Draw a box and put text inside for textarea
const textHeight = doc.heightOfString(value, { width: 500 }); // Estimate height
doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke(); // Draw rectangle
doc.text(value, doc.x + 5, doc.y + 5, { width: 490 }); // Add text inside with padding
doc.y += Math.max(textHeight + 10, 30) + 10; // Move below the box
} else if (field.type === 'date') {
// Format date as DD/MM/YYYY
let formattedDate = '';
if (value) {
try {
const dateObj = new Date(value + 'T00:00:00'); // Add time part to avoid timezone issues with just YYYY-MM-DD
if (!isNaN(dateObj.getTime())) {
const day = String(dateObj.getDate()).padStart(2, '0');
const month = String(dateObj.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
const year = dateObj.getFullYear();
formattedDate = `${day}/${month}/${year}`;
} else {
formattedDate = value; // Keep original if invalid
}
} catch (e) {
console.error('Error formatting date:', value, e);
formattedDate = value; // Keep original on error
}
}
doc.text(formattedDate || ' '); // Add space if empty
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath
doc.moveDown(1.5); // Space between fields
} else if (field.type === 'boolean') {
// Display boolean as Yes/No
const displayValue = value === 'true' ? 'Yes' : (value === 'false' ? 'No' : ' ');
doc.text(displayValue);
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath
doc.moveDown(1.5); // Space between fields
} else {
// Simple line for other types
doc.text(value || ' '); // Add space if empty to ensure line moves
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath
doc.moveDown(1.5); // Space between fields
}
}
doc.moveDown(1); // Space between categories
}
// --- Finalize PDF ---
doc.end();
} catch (err) {
console.error(`Error generating PDF for response ${responseId}:`, err.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to generate PDF' });
} else {
console.error("Headers already sent, could not send JSON error for PDF generation failure.");
// Ensure the stream is ended if an error occurs after piping
res.end();
}
}
});
// --- Mantis Summary API --- //
router.get('/mantis-summary', async (req, res) => {
if (!ai) {
return res.status(500).json({ error: 'Google AI API key not configured.' });
}
if (!fs.existsSync(promptFilePath)) {
return res.status(500).json({ error: `Prompt file not found at ${promptFilePath}` });
}
try {
// Read the prompt from the file
let promptTemplate = fs.readFileSync(promptFilePath, 'utf8');
const tickets = await getMantisTickets();
if (tickets.length === 0) {
return res.json({ summary: "No Mantis tickets updated recently." });
}
let prompt = promptTemplate.replaceAll("$DATE", new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll("$MANTIS_TICKETS", JSON.stringify(tickets, null, 2));
// Use the specific model and configuration from your original script
const model = ai.getGenerativeModel({ model: "gemini-2.5-flash-exp" }); // Or your specific model like "gemini-2.0-flash-exp" if available
const result = await model.generateContent(prompt);
const response = await result.response;
const summaryText = response.text();
res.json({ summary: summaryText });
} catch (error) {
console.error("Error generating Mantis summary:", error);
res.status(500).json({ error: `Failed to generate summary: ${error.message}` });
}
});
export default router;

163
src-ssr/server.js Normal file
View file

@ -0,0 +1,163 @@
/**
* More info about this file:
* https://v2.quasar.dev/quasar-cli-vite/developing-ssr/ssr-webserver
*
* Runs in Node context.
*/
/**
* Make sure to yarn add / npm install (in your project root)
* anything you import here (except for express and compression).
*/
import express from 'express'
import compression from 'compression'
import {
defineSsrCreate,
defineSsrListen,
defineSsrClose,
defineSsrServeStaticContent,
defineSsrRenderPreloadTag
} from '#q-app/wrappers'
// Import database initialization and close function
import { initializeDatabase, closeDatabase } from './database.js';
import apiRoutes from './routes/api.js';
/**
* Create your webserver and return its instance.
* If needed, prepare your webserver to receive
* connect-like middlewares.
*
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
*/
export const create = defineSsrCreate((/* { ... } */) => {
const app = express()
// Initialize the database (now synchronous)
try {
initializeDatabase();
console.log('Database initialized successfully.');
} catch (error) {
console.error('Failed to initialize database:', error);
// Optionally handle the error more gracefully, e.g., prevent server start
process.exit(1); // Exit if DB connection fails
}
// attackers can use this header to detect apps running Express
// and then launch specifically-targeted attacks
app.disable('x-powered-by')
// Add JSON body parsing middleware
app.use(express.json());
// Add API routes
app.use('/api', apiRoutes);
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD) {
app.use(compression())
}
return app
})
/**
* You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to
* close the server with.
*
* The "listenResult" param for the "close()" definition below
* is what you return here.
*
* For production, you can instead export your
* handler for serverless use or whatever else fits your needs.
*
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
*/
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
const server = devHttpsApp || app
return server.listen(port, () => {
if (process.env.PROD) {
console.log('Server listening at port ' + port)
}
})
})
/**
* Should close the server and free up any resources.
* Will be used on development mode when the server needs
* to be restarted, or when the application shuts down.
*
* Can be async: defineSsrClose(async ({ ... }) => { ... })
*/
export const close = defineSsrClose(({ listenResult }) => {
// Close the database connection when the server shuts down
closeDatabase();
return listenResult.close()
})
const maxAge = process.env.DEV
? 0
: 1000 * 60 * 60 * 24 * 30
/**
* Should return a function that will be used to configure the webserver
* to serve static content at "urlPath" from "pathToServe" folder/file.
*
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
*
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
*/
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
app.use(resolve.urlPath(urlPath), serveFn)
}
})
const jsRE = /\.js$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/
/**
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>`
}
if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}" crossorigin>`
}
if (woffRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
}
if (woff2RE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
}
if (gifRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
}
if (jpgRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
}
if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
}
return ''
})

7
src/App.vue Normal file
View file

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

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

0
src/boot/.gitkeep Normal file
View file

1
src/css/app.scss Normal file
View file

@ -0,0 +1 @@
// app global css in SCSS form

View file

@ -0,0 +1,25 @@
// Quasar SCSS (& Sass) Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #1976D2;
$secondary : #26A69A;
$accent : #9C27B0;
$dark : #1D1D1D;
$dark-page : #121212;
$positive : #21BA45;
$negative : #C10015;
$info : #31CCEC;
$warning : #F2C037;

View file

@ -0,0 +1,68 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
Form Builder App
</q-toolbar-title>
</q-toolbar>
</q-header>
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
>
<q-list>
<q-item-label
header
>
Navigation
</q-item-label>
<q-item clickable v-ripple :to="{ name: 'formList' }" exact>
<q-item-section avatar>
<q-icon name="list_alt" />
</q-item-section>
<q-item-section>
<q-item-label>Forms</q-item-label>
<q-item-label caption>View existing forms</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple :to="{ name: 'formCreate' }" exact>
<q-item-section avatar>
<q-icon name="add_circle_outline" />
</q-item-section>
<q-item-section>
<q-item-label>Create Form</q-item-label>
<q-item-label caption>Create a new form</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup>
import { ref } from 'vue'
const leftDrawerOpen = ref(false)
function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value
}
</script>

View file

@ -0,0 +1,27 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
Oops. Nothing here...
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script setup>
//
</script>

View file

@ -0,0 +1,124 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">Create New Form</div>
<q-form @submit.prevent="createForm" class="q-gutter-md">
<q-input outlined v-model="form.title" label="Form Title *" lazy-rules
:rules="[val => val && val.length > 0 || 'Please enter a title']" />
<q-input outlined v-model="form.description" label="Form Description" type="textarea" autogrow />
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">Categories & Fields</div>
<div v-for="(category, catIndex) in form.categories" :key="catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders">
<div class="row items-center q-mb-sm">
<q-input outlined dense v-model="category.name" :label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm" lazy-rules
:rules="[val => val && val.length > 0 || 'Category name required']" />
<q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)"
title="Remove Category" />
</div>
<div v-for="(field, fieldIndex) in category.fields" :key="fieldIndex"
class="q-ml-md q-mb-sm field-item">
<div class="row items-center q-gutter-sm">
<q-input outlined dense v-model="field.label" label="Field Label *" class="col" lazy-rules
:rules="[val => val && val.length > 0 || 'Field label required']" />
<q-select outlined dense v-model="field.type" :options="fieldTypes" label="Field Type *"
class="col-auto" style="min-width: 150px;" lazy-rules
:rules="[val => !!val || 'Field type required']" />
<q-btn flat round dense icon="delete" color="negative"
@click="removeField(catIndex, fieldIndex)" title="Remove Field" />
</div>
<q-input v-model="field.description" outlined dense label="Field Description (Optional)" autogrow
class="q-mt-xs q-mb-xl" hint="This description will appear below the field label on the form." />
</div>
<q-btn outline color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" />
</div>
<q-btn outline color="secondary" label="Add Category" @click="addCategory" />
<q-separator class="q-my-lg" />
<div>
<q-btn outline label="Create Form" type="submit" color="primary" :loading="submitting" />
<q-btn outline label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" />
</div>
</q-form>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
const $q = useQuasar();
const router = useRouter();
const form = ref({
title: '',
description: '',
categories: [
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
]
});
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
function addCategory() {
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
}
function removeCategory(index) {
form.value.categories.splice(index, 1);
}
function addField(catIndex) {
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex) {
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function createForm() {
submitting.value = true;
try {
const response = await axios.post('/api/forms', form.value);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" created successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' });
} catch (error) {
console.error('Error creating form:', error);
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>

201
src/pages/FormEditPage.vue Normal file
View file

@ -0,0 +1,201 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">Edit Form</div>
<q-form v-if="!loading && form" @submit.prevent="updateForm" class="q-gutter-md">
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">Categories & Fields</div>
<div v-for="(category, catIndex) in form.categories" :key="category.id || catIndex" class="q-mb-lg q-pa-md bordered rounded-borders">
<div class="row items-center q-mb-sm">
<q-input
outlined dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Category name required']"
/>
<q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)" title="Remove Category" />
</div>
<div v-for="(field, fieldIndex) in category.fields" :key="field.id || fieldIndex" class="q-ml-md q-mb-sm">
<div class="row items-center q-gutter-sm">
<q-input
outlined dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[ val => !!val || 'Field type required']"
/>
<q-btn flat round dense icon="delete" color="negative" @click="removeField(catIndex, fieldIndex)" title="Remove Field" />
</div>
<q-input
v-model="field.description"
label="Field Description (Optional)"
outlined
dense
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn outline color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" />
</div>
<q-btn outline color="secondary" label="Add Category" @click="addCategory" />
<q-separator class="q-my-lg" />
<div>
<q-btn outline label="Update Form" type="submit" color="primary" :loading="submitting"/>
<q-btn outline label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" />
</div>
</q-form>
<div v-else-if="loading">
<q-spinner-dots color="primary" size="40px" />
Loading form details...
</div>
<div v-else class="text-negative">
Failed to load form details.
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: String,
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute(); // Use useRoute if needed, though id is from props
const form = ref(null); // Initialize as null
const loading = ref(true);
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
async function fetchForm() {
loading.value = true;
try {
const response = await axios.get(`/api/forms/${props.id}`);
// Ensure categories and fields exist, even if empty
response.data.categories = response.data.categories || [];
response.data.categories.forEach(cat => {
cat.fields = cat.fields || [];
});
form.value = response.data;
} catch (error) {
console.error('Error fetching form details:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
form.value = null; // Indicate failure
} finally {
loading.value = false;
}
}
onMounted(fetchForm);
function addCategory() {
if (!form.value.categories) {
form.value.categories = [];
}
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
}
function removeCategory(index) {
form.value.categories.splice(index, 1);
}
function addField(catIndex) {
if (!form.value.categories[catIndex].fields) {
form.value.categories[catIndex].fields = [];
}
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex) {
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function updateForm() {
submitting.value = true;
try {
// Prepare payload, potentially removing temporary IDs if any were added client-side
const payload = JSON.parse(JSON.stringify(form.value));
// The backend PUT expects title, description, categories (with name, fields (with label, type, description))
// We don't need to send the form ID in the body as it's in the URL
await axios.put(`/api/forms/${props.id}`, payload);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" updated successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
} catch (error) {
console.error('Error updating form:', error);
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>

162
src/pages/FormFillPage.vue Normal file
View file

@ -0,0 +1,162 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
<div v-if="!loading && form">
<div class="text-h4 q-mb-xs">{{ form.title }}</div>
<div class="text-subtitle1 text-grey q-mb-lg">{{ form.description }}</div>
<q-form @submit.prevent="submitResponse" class="q-gutter-md">
<div v-for="category in form.categories" :key="category.id" class="q-mb-lg">
<div class="text-h6 q-mb-sm">{{ category.name }}</div>
<div v-for="field in category.fields" :key="field.id" class="q-mb-md">
<q-item-label class="q-mb-xs">{{ field.label }}</q-item-label>
<q-item-label caption v-if="field.description" class="q-mb-xs text-grey-7">{{ field.description }}</q-item-label>
<q-input
v-if="field.type === 'text'"
outlined
v-model="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'number'"
outlined
type="number"
v-model.number="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'date'"
outlined
type="date"
v-model="responses[field.id]"
:label="field.label"
stack-label
/>
<q-input
v-else-if="field.type === 'textarea'"
outlined
type="textarea"
autogrow
v-model="responses[field.id]"
:label="field.label"
/>
<q-checkbox
v-else-if="field.type === 'boolean'"
v-model="responses[field.id]"
:label="field.label"
left-label
class="q-mt-sm"
/>
<!-- Add other field types as needed -->
</div>
</div>
<q-separator class="q-my-lg" />
<div>
<q-btn outline label="Submit Response" type="submit" color="primary" :loading="submitting"/>
<q-btn outline label="Cancel" type="reset" color="default" class="q-ml-sm" :to="{ name: 'formList' }" />
</div>
</q-form>
</div>
<q-banner v-else-if="!loading && !form" class="bg-negative text-white">
<template v-slot:avatar>
<q-icon name="error" />
</template>
Form not found or could not be loaded.
<template v-slot:action>
<q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" />
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute();
const form = ref(null);
const responses = reactive({}); // Use reactive for dynamic properties
const loading = ref(true);
const submitting = ref(false);
async function fetchFormDetails() {
loading.value = true;
form.value = null; // Reset form data
try {
const response = await axios.get(`/api/forms/${props.id}`);
form.value = response.data;
// Initialize responses object based on fields
form.value.categories.forEach(cat => {
cat.fields.forEach(field => {
responses[field.id] = null; // Initialize all fields to null or default
});
});
} catch (error) {
console.error(`Error fetching form ${props.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
} finally {
loading.value = false;
}
}
async function submitResponse() {
submitting.value = true;
try {
// Basic check if any response is provided (optional)
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
// if (!hasResponse) {
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
// return;
// }
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
$q.notify({
color: 'positive',
position: 'top',
message: 'Response submitted successfully!',
icon: 'check_circle'
});
// Optionally redirect or clear form
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
// Or clear the form:
// Object.keys(responses).forEach(key => { responses[key] = null; });
} catch (error) {
console.error('Error submitting response:', error);
const message = error.response?.data?.error || 'Failed to submit response.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
} finally {
submitting.value = false;
}
}
onMounted(fetchFormDetails);
</script>

116
src/pages/FormListPage.vue Normal file
View file

@ -0,0 +1,116 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">Forms</div>
<q-btn outline label="Create New Form" color="primary" :to="{ name: 'formCreate' }" />
</div>
<q-list bordered separator v-if="forms.length > 0">
<q-item v-for="form in forms" :key="form.id">
<q-item-section>
<q-item-label>{{ form.title }}</q-item-label>
<q-item-label caption>{{ form.description || 'No description' }}</q-item-label>
<q-item-label caption>Created: {{ formatDate(form.createdAt) }}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-sm">
<q-btn flat round dense icon="edit_note" color="info" :to="{ name: 'formFill', params: { id: form.id } }" title="Fill Form" />
<q-btn flat round dense icon="visibility" color="secondary" :to="{ name: 'formResponses', params: { id: form.id } }" title="View Responses" />
<q-btn flat round dense icon="edit" color="warning" :to="{ name: 'formEdit', params: { id: form.id } }" title="Edit Form" />
<q-btn flat round dense icon="delete" color="negative" @click.stop="confirmDeleteForm(form.id)" title="Delete Form" />
</div>
</q-item-section>
</q-item>
</q-list>
<q-banner v-else class="bg-info text-white">
<template v-slot:avatar>
<q-icon name="info" color="white" />
</template>
No forms created yet. Click the button above to create your first form.
</q-banner>
<q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const forms = ref([]);
const loading = ref(false);
async function fetchForms() {
loading.value = true;
try {
const response = await axios.get('/api/forms');
forms.value = response.data;
} catch (error) {
console.error('Error fetching forms:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load forms. Please try again later.',
icon: 'report_problem'
});
} finally {
loading.value = false;
}
}
// Add function to handle delete confirmation
function confirmDeleteForm(id) {
$q.dialog({
title: 'Confirm Delete',
message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
cancel: true,
persistent: true,
ok: {
label: 'Delete',
color: 'negative',
flat: false
},
cancel: {
label: 'Cancel',
flat: true
}
}).onOk(() => {
deleteForm(id);
});
}
// Add function to call the delete API
async function deleteForm(id) {
try {
await axios.delete(`/api/forms/${id}`);
forms.value = forms.value.filter(form => form.id !== id);
$q.notify({
color: 'positive',
position: 'top',
message: 'Form deleted successfully.',
icon: 'check_circle'
});
} catch (error) {
console.error(`Error deleting form ${id}:`, error);
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
$q.notify({
color: 'negative',
position: 'top',
message: errorMessage,
icon: 'report_problem'
});
}
}
// Add function to format date
function formatDate(date) {
return new Date(date).toLocaleString();
}
onMounted(fetchForms);
</script>

View file

@ -0,0 +1,216 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
<div v-if="!loading && formTitle">
<div class="row justify-between items-center q-mb-md">
<div class="text-h4">Responses for: {{ formTitle }}</div>
</div>
<!-- Add Search Input -->
<q-input
v-if="responses.length > 0"
outlined
dense
debounce="300"
v-model="filterText"
placeholder="Search responses..."
class="q-mb-md"
>
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
<q-table
v-if="responses.length > 0"
:rows="formattedResponses"
:columns="columns"
row-key="id"
flat bordered
separator="cell"
wrap-cells
:filter="filterText"
>
<template v-slot:body-cell-submittedAt="props">
<q-td :props="props">
{{ new Date(props.value).toLocaleString() }}
</q-td>
</template>
<!-- Slot for Actions column -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn
flat dense round
icon="download"
color="primary"
@click="downloadResponsePdf(props.row.id)"
aria-label="Download PDF"
>
<q-tooltip>Download PDF</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<q-banner v-else class="">
<template v-slot:avatar>
<q-icon name="info" color="info" />
</template>
No responses have been submitted for this form yet.
</q-banner>
</div>
<q-banner v-else-if="!loading && !formTitle" class="bg-negative text-white">
<template v-slot:avatar>
<q-icon name="error" />
</template>
Form not found or could not load responses.
<template v-slot:action>
<q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" />
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
const props = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const formTitle = ref('');
const responses = ref([]);
const columns = ref([]); // Columns will be generated dynamically
const loading = ref(true);
const filterText = ref(''); // Add ref for filter text
// Fetch both form details (for title and field labels/order) and responses
async function fetchData() {
loading.value = true;
formTitle.value = '';
responses.value = [];
columns.value = [];
try {
// Fetch form details first to get the structure
const formDetailsResponse = await axios.get(`/api/forms/${props.id}`);
const form = formDetailsResponse.data;
formTitle.value = form.title;
// Generate columns based on form fields in correct order
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
form.categories.forEach(cat => {
cat.fields.forEach(field => {
generatedColumns.push({
name: `field_${field.id}`, // Unique name for column
label: field.label,
field: row => row.values[field.id]?.value ?? '', // Access nested value safely
align: 'left',
sortable: true,
// Add formatting based on field.type if needed
});
});
});
columns.value = generatedColumns;
// Add Actions column
columns.value.push({
name: 'actions',
label: 'Actions',
field: 'actions',
align: 'center'
});
// Fetch responses
const responsesResponse = await axios.get(`/api/forms/${props.id}/responses`);
responses.value = responsesResponse.data; // API already groups them
} catch (error) {
console.error(`Error fetching data for form ${props.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form responses.',
icon: 'report_problem'
});
} finally {
loading.value = false;
}
}
// Computed property to match the structure expected by QTable rows
const formattedResponses = computed(() => {
return responses.value.map(response => {
const row = {
id: response.id,
submittedAt: response.submittedAt,
// Flatten values for direct access by field function in columns
values: response.values
};
return row;
});
});
// Function to download a single response as PDF
async function downloadResponsePdf(responseId) {
try {
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
responseType: 'blob', // Important for handling file downloads
});
// Create a URL for the blob
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Try to get filename from content-disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `response-${responseId}.pdf`; // Default filename
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1) {
filename = filenameMatch[1];
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
// Clean up
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
$q.notify({
color: 'positive',
position: 'top',
message: `Downloaded ${filename}`,
icon: 'check_circle'
});
} catch (error) {
console.error(`Error downloading PDF for response ${responseId}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to download PDF.',
icon: 'report_problem'
});
}
}
onMounted(fetchData);
</script>

30
src/router/index.js Normal file
View file

@ -0,0 +1,30 @@
import { defineRouter } from '#q-app/wrappers'
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes'
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default defineRouter(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE)
})
return Router
})

23
src/router/routes.js Normal file
View file

@ -0,0 +1,23 @@
const routes = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [
{ path: '', name: 'home', component: () => import('pages/FormListPage.vue') },
{ path: 'forms', name: 'formList', component: () => import('pages/FormListPage.vue') },
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue') },
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true },
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true },
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true }
]
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue')
}
]
export default routes