From a5543a1876f9aa2dab7222c6322007e24da7d847 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Sun, 3 May 2026 14:53:00 +0700 Subject: [PATCH 01/95] feat: spilt client access key to share component Co-authored-by: Copilot --- .../devtrackers/ClientAccessKey.vue | 36 +++++++++++++++++++ app/pages/clientsetting/index.vue | 4 +-- app/pages/devtrackers/index.vue | 28 +++------------ 3 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 app/components/devtrackers/ClientAccessKey.vue diff --git a/app/components/devtrackers/ClientAccessKey.vue b/app/components/devtrackers/ClientAccessKey.vue new file mode 100644 index 0000000..0ad875e --- /dev/null +++ b/app/components/devtrackers/ClientAccessKey.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/pages/clientsetting/index.vue b/app/pages/clientsetting/index.vue index cfd636b..5a75965 100644 --- a/app/pages/clientsetting/index.vue +++ b/app/pages/clientsetting/index.vue @@ -6,7 +6,7 @@
- + - +
Dev Trackers (Program Spec / Defect)
- - -
- Please set your access key in Client Setting -
-
- - - -
- - Use Server Token -
-
-
+ Date: Sun, 3 May 2026 14:57:56 +0700 Subject: [PATCH 02/95] feat: version page get version from redmine project id that config in SupportProject.json / On Create Version you can select project --- app/composables/useRedmineAPI.ts | 5 ++-- app/pages/versions/index.vue | 26 +++++++++++++++--- public/Config/SupportProject.json | 12 +++++++++ server/api/projects/support.get.ts | 3 +++ server/api/versions/index.get.ts | 43 +++++++++++++++++------------- server/api/versions/index.post.ts | 9 ++++--- server/utils/projectConfig.ts | 18 +++++++++++++ 7 files changed, 88 insertions(+), 28 deletions(-) create mode 100644 public/Config/SupportProject.json create mode 100644 server/api/projects/support.get.ts create mode 100644 server/utils/projectConfig.ts diff --git a/app/composables/useRedmineAPI.ts b/app/composables/useRedmineAPI.ts index 30c3bf8..7f0ce8e 100644 --- a/app/composables/useRedmineAPI.ts +++ b/app/composables/useRedmineAPI.ts @@ -42,7 +42,7 @@ export default () => { const versionShares : string[] = [versionShareType.NONE, versionShareType.DESCENDANTS, versionShareType.HIERARCHY, versionShareType.TREE, versionShareType.SYSTEM]; // - const addVersion = async(version: Version) => { + const addVersion = async(version: Version, projectId?: number) => { const body = { version: { name: version.name, @@ -50,7 +50,8 @@ export default () => { sharing: version.sharing, due_date: version.due_date, description: version.description - } + }, + projectId: projectId }; return await useFetch("/api/versions", { diff --git a/app/pages/versions/index.vue b/app/pages/versions/index.vue index 9434d4c..deed769 100644 --- a/app/pages/versions/index.vue +++ b/app/pages/versions/index.vue @@ -28,7 +28,14 @@ From f28a49564b97147be0a30e4bf5c57cf22da9a306 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 06:33:00 +0700 Subject: [PATCH 05/95] UI: Add Menu and Add New Line After /n Co-authored-by: Copilot --- app/components/sidebar/Sidebar.vue | 6 +++++- app/components/sidebar/sidebarItems.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/components/sidebar/Sidebar.vue b/app/components/sidebar/Sidebar.vue index 7ebcc5c..5386d3c 100644 --- a/app/components/sidebar/Sidebar.vue +++ b/app/components/sidebar/Sidebar.vue @@ -13,7 +13,7 @@ > - {{ item.title }} + {{ item.title }} @@ -57,4 +57,8 @@ const version = ref(config.public.appVersion || "0.2.0-DEV"); text-align: center; /* Ensure text alignment */ padding-bottom: 0px; } + +.sidebar-title { + white-space: pre-line; /* honor \n in strings and wrap accordingly */ +} diff --git a/app/components/sidebar/sidebarItems.ts b/app/components/sidebar/sidebarItems.ts index d0ac9e1..e55756d 100644 --- a/app/components/sidebar/sidebarItems.ts +++ b/app/components/sidebar/sidebarItems.ts @@ -19,6 +19,11 @@ export default [ icon: "i-mdi-code-block-braces", to: "/devtrackers", }, + { + title: "New Build Request\n(NET COMMON)", + icon: "i-mdi-code-tags-check", + to: "/buildnetcommon", + }, { title: "Client Setting", icon: "i-mdi-window-shutter-cog", From d1d7a3f3f4416eaed7a30775b3cec925b89319ae Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 06:47:34 +0700 Subject: [PATCH 06/95] feat: add frontend ui for create build req --- app/composables/useRedmineAPI.ts | 44 ++++ app/pages/buildnetcommon/index.vue | 337 +++++++++++++++++++++++++ public/Config/SupportBuildPurpose.json | 10 + public/Config/SupportTracker.json | 22 ++ shared/types/BuildNetCommon.d.ts | 16 ++ 5 files changed, 429 insertions(+) create mode 100644 app/pages/buildnetcommon/index.vue create mode 100644 public/Config/SupportBuildPurpose.json create mode 100644 public/Config/SupportTracker.json create mode 100644 shared/types/BuildNetCommon.d.ts diff --git a/app/composables/useRedmineAPI.ts b/app/composables/useRedmineAPI.ts index 7f0ce8e..6f3291b 100644 --- a/app/composables/useRedmineAPI.ts +++ b/app/composables/useRedmineAPI.ts @@ -258,6 +258,49 @@ export default () => { } }; + const createBuildNetCommonRequest = (trackerId: number, + project: Project, + assignTo: ProjectMemberShip, + targetVerion: Version, + subject: string, + options: BuildNetCommonOptions): BuildNetCommonRequest => { + return { + tracker_id: trackerId, + project, + assignTo, + targetVerion, + subject, + options + }; + } + + const createBuildNetCommon = async (buildNetCommonRequest: BuildNetCommonRequest, + headers?: Record): Promise => { + const body = { + BuildNetCommonRequest: buildNetCommonRequest + }; + + try { + const { data, error } = await useFetch("/api/buildnetcommon", { + method: "POST", + body: JSON.stringify(body), + headers + }); + + if (error.value) { + throw createError({ + ...error.value, + statusMessage: `Failed to create Build Request: ${error.value.statusMessage}`, + }); + } + + return data.value ?? 'No found Issue ID returned.'; + } catch (error) { + console.error('Error creating Build Request:', error); + return 'Error occurred while creating Build Request.'; + } + }; + //========================================================== // SERVER SIDE API //========================================================== @@ -287,6 +330,7 @@ export default () => { , getProject, mapRawProjectToProject , getProjectMemberShip, mapRawMembershipToProjectMemberShip , createDevTrackerRequest, createDevTracker + , createBuildNetCommonRequest, createBuildNetCommon , createBaseRedmineHeader , YourOwnRedmineAPI: YOUR_OWN_REDMINE_API, versionStatuses, versionShareType, versionShares, devTrackers, buildTrackers, TRACKER}; } \ No newline at end of file diff --git a/app/pages/buildnetcommon/index.vue b/app/pages/buildnetcommon/index.vue new file mode 100644 index 0000000..443689e --- /dev/null +++ b/app/pages/buildnetcommon/index.vue @@ -0,0 +1,337 @@ + + + \ No newline at end of file diff --git a/public/Config/SupportBuildPurpose.json b/public/Config/SupportBuildPurpose.json new file mode 100644 index 0000000..63db7ba --- /dev/null +++ b/public/Config/SupportBuildPurpose.json @@ -0,0 +1,10 @@ +[ + { + "name": "SCM Official Build", + "purpose": "CommonRelease" + }, + { + "name": "System Trigger Test", + "purpose": "CommonRelease" + } +] \ No newline at end of file diff --git a/public/Config/SupportTracker.json b/public/Config/SupportTracker.json new file mode 100644 index 0000000..e6d6716 --- /dev/null +++ b/public/Config/SupportTracker.json @@ -0,0 +1,22 @@ +[ + { + "id": 6, + "name": "Build-Request", + "purpose": "Build" + }, + { + "id": 11, + "name": "Program Spec", + "purpose": "Development" + }, + { + "id": 8, + "name": "Defect", + "purpose": "Development" + }, + { + "id": 14, + "name": "Feature", + "purpose": "Development" + } +] \ No newline at end of file diff --git a/shared/types/BuildNetCommon.d.ts b/shared/types/BuildNetCommon.d.ts new file mode 100644 index 0000000..8312b55 --- /dev/null +++ b/shared/types/BuildNetCommon.d.ts @@ -0,0 +1,16 @@ +export interface BuildNetCommonOptions { + executeTest: boolean; + sonarAnalysis: boolean; + publishDc: boolean; + publishDr: boolean; + buildPurpose: string; +} + +export interface BuildNetCommonRequest { + tracker_id: number; + project: Project; + assignTo: ProjectMemberShip; + targetVerion: Version; + subject: string; + options: BuildNetCommonOptions; +} \ No newline at end of file From d432e228c95d34c39bd8df9e9d45e24dd385b857 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 09:53:00 +0700 Subject: [PATCH 07/95] feat: add server side api fro create common build req --- .../BuildNETCommonTemplate.textile | 36 +++++ server/api/buildnetcommon/index.post.ts | 148 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 public/IssueTemplate/BuildNETCommonTemplate.textile create mode 100644 server/api/buildnetcommon/index.post.ts diff --git a/public/IssueTemplate/BuildNETCommonTemplate.textile b/public/IssueTemplate/BuildNETCommonTemplate.textile new file mode 100644 index 0000000..3f2bde7 --- /dev/null +++ b/public/IssueTemplate/BuildNETCommonTemplate.textile @@ -0,0 +1,36 @@ +{{toc}} + + +h1. - SCM BUILD JOB - http://128.1.0.56:8080/view/03NUGET_BUILD/job/free_ds-common-net/ + + +|_. COMMON_VERSION |_. TAG_VERSION |_. EXECUTE_TEST |_. SONARANALYSIS |_. PUBLISH_DC |_. PUBLISH_DR |_. BUILD_PURPOSE |_. SENDNOTIFY | +| [BNZSELECTVERSION] | [BNZSELECTVERSION] |=. [BNZEXE_TEST] |=. [BNZEXE_SONAR] |=. [BNZPUB_DC] |=. [BNZPUB_DR]| [BNZBUILDPURPOSE] |=. / | + +* Revision: bf65163a8f3c657079c44707752a26cd66d96adb +* +NOTE:+ TAG_VERSION = "[BNZSELECTVERSION]":http://dev.free_ds.local/dotnet-common/net-lib-free_ds/-/tags/[BNZSELECTVERSION] + + +h1. - Related Dev Version + +* Revision: รบกวน [BNZISSUEAUTHOR] แปะ Revision หน่อย +* +NOTE:+ TAG_VERSION = "รบกวน [BNZISSUEAUTHOR] เช่น 3.3.5-pre-alpha1":"http://dev.free_ds.local/dotnet-common/net-lib-free_ds/-/tags/[BNZISSUEAUTHOR] +* Nuget: 3.3.5-pre-alpha290 + +h1. - Dev Build Test Job - http://128.1.0.56:8080/view/03NUGET_BUILD/job/free_ds-common-net-dev/ + +รบกวน Dev แปะรูป + +h2. Automate Test (#CODETEST) / Unit Test + +#CODETEST Automate Test +- มี Test โดยชื่อ Test มี Pattern ดังนี้ T[BNZGENREDMINEID]_xxxx โดยให้ตรวจสอบ Automate Test Report Version [BNZSELECTVERSION] และ Coverage Report http://128.1.0.56:8888/netcommoncoverage/[BNZSELECTVERSION] + +h1. - Merge Request + +* develop: รบกวน [BNZISSUEAUTHOR] แปะ Link Merge Request +* master: รบกวน [BNZISSUEAUTHOR] แปะ Link Merge Request +* hotfix/x.y.z: รบกวน [BNZISSUEAUTHOR] แปะ Link Merge Request (ถ้ามี) + + +รบกวน [BNZISSUEAUTHOR] ในรายละเอียดการปรับแก้ ถ้า Owner ไม่ยอม key redmine ตาม Version ที่วาง Roadmap ไว้ diff --git a/server/api/buildnetcommon/index.post.ts b/server/api/buildnetcommon/index.post.ts new file mode 100644 index 0000000..3171bfc --- /dev/null +++ b/server/api/buildnetcommon/index.post.ts @@ -0,0 +1,148 @@ +import axios from "axios" +import path from "path" +import fs from "fs" +import useRedmineAPI from "~/composables/useRedmineAPI" + +const readTemplate = async (pFileName: String): Promise => { + const filePath = path.join(process.cwd(), 'public', `IssueTemplate/${pFileName}`) + const data = await fs.promises.readFile(filePath, 'utf-8') + return data +} + +const buildToggleMarker = (value: boolean) => value ? '/' : '' + +export default defineEventHandler(async (event) => { + const { createBaseRedmineHeader, TRACKER, versionShareType } = useRedmineAPI() + const config = useRuntimeConfig(event) + + const url = `${config.public.redmineUrl}/issues.json` + const baseUpdateUrl = `${config.public.redmineUrl}/issues/` + + const req = getRequestHeaders(event) + const headers = createBaseRedmineHeader(req) + + const body = await readBody(event) + const buildNetCommonRequest: BuildNetCommonRequest = body.BuildNetCommonRequest + + const validateAssigneeBelongToProject = (request: BuildNetCommonRequest) => { + if (request.project.id !== request.assignTo.projectId) { + throw createError({ + statusCode: 400, + message: `Assignee ${request.assignTo.name} does not belong to the project ${request.project.name}`, + statusMessage: `Assignee ${request.assignTo.name} does not belong to the project ${request.project.name}`, + }) + } + } + + const validateVersionBelongToProject = (request: BuildNetCommonRequest) => { + if ((request.targetVerion.sharing === versionShareType.DESCENDANTS) + || (request.targetVerion.sharing === versionShareType.HIERARCHY) + || (request.targetVerion.sharing === versionShareType.TREE)) { + return true; + } + + if (request.project.id !== request.targetVerion.projectid) { + throw createError({ + statusCode: 400, + message: `Version ${request.targetVerion.name} does not belong to the project ${request.project.name}`, + statusMessage: `Version ${request.targetVerion.name} does not belong to the project ${request.project.name}`, + }) + } + + return true; + } + + const updateDescRedmineId = async (description: string, redmineId: string) => { + const updatedDescription = description.split("[BNZGENREDMINEID]").join(redmineId) + const updateBody = { + issue: { + description: updatedDescription, + } + } + + const updateResponse = await axios.put(`${baseUpdateUrl}${redmineId}.json`, updateBody, { headers }) + if (updateResponse.status !== 204) { + throw createError({ + statusCode: 500, + message: `Update Redmine Id ${redmineId} failed`, + statusMessage: `Update Redmine Id ${redmineId} failed`, + }) + } + + return updatedDescription + } + + const createBuildRequest = async (request: BuildNetCommonRequest) => { + try { + validateAssigneeBelongToProject(request) + validateVersionBelongToProject(request) + + const description = await readTemplate('BuildNETCommonTemplate.textile') + const replacedDescription = description + .split("[BNZSELECTVERSION]").join(request.targetVerion.name) + .split("[BNZISSUEAUTHOR]").join(request.assignTo.name) + .split("[BNZEXE_TEST]").join(buildToggleMarker(request.options.executeTest)) + .split("[BNZEXE_SONAR]").join(buildToggleMarker(request.options.sonarAnalysis)) + .split("[BNZPUB_DC]").join(buildToggleMarker(request.options.publishDc)) + .split("[BNZPUB_DR]").join(buildToggleMarker(request.options.publishDr)) + .split("[BNZBUILDPURPOSE]").join(request.options.buildPurpose) + + const issueBody = { + issue: { + project_id: request.project.id, + tracker_id: request.tracker_id, + status_id: 1, + priority_id: 3, + assigned_to_id: request.assignTo.id, + fixed_version_id: request.targetVerion.id, + subject: request.subject, + description: replacedDescription, + start_date: new Date().toISOString().split('T')[0], + due_date: new Date().toISOString().split('T')[0], + custom_fields: [ + { + id: 4, + value: "Implementation" + }, + { + id: 34, + value: "Production" + } + ] + } + } + + const response = await axios.post(url, issueBody, { headers }) + const issueId = String(response?.data?.issue?.id) + if (!issueId) { + throw createError({ + statusCode: 500, + message: 'Invalid response from Redmine when creating issue', + statusMessage: 'Invalid response from Redmine when creating issue', + }) + } + + await updateDescRedmineId(response.data.issue.description, issueId) + return issueId + } catch (err: unknown) { + console.error('createBuildRequest failed:', err) + // If error is already a createError-like object, rethrow it + if ((err as any)?.statusCode) throw err + throw createError({ + statusCode: 500, + message: 'Failed to create build request', + statusMessage: (err as Error)?.message ?? String(err), + }) + } + } + + if (buildNetCommonRequest.tracker_id !== TRACKER.BUILD_REQUEST) { + throw createError({ + statusCode: 500, + message: `Tracker ${buildNetCommonRequest.tracker_id} is not supported`, + statusMessage: `Tracker ${buildNetCommonRequest.tracker_id} is not supported`, + }) + } + + return await createBuildRequest(buildNetCommonRequest) +}) \ No newline at end of file From 54632570fbf14f68113dab51b1ccf34bb808deeb Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 09:53:20 +0700 Subject: [PATCH 08/95] do :update --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4132010..7ee9e43 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,14 @@ bun add axios ## Build & Run ``` -docker build --pull -t bun-redmine:0.3.0rc15 . +docker build --pull -t bun-redmine:0.3.1rc1 . -docker build --pull -t bun-redmine:0.3.0rc15 . --no-cache --progress=plain +docker build --pull -t bun-redmine:0.3.1rc1 . --no-cache --progress=plain -docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.0rc15 +docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.1rc1 -docker tag bun-redmine:0.3.0rc15 pingkunga/bun-redmine:0.3.0rc15 -docker push pingkunga/bun-redmine:0.3.0rc15 +docker tag bun-redmine:0.3.1rc1 pi1ngkunga/bun-redmine:0.3.1rc1 +docker push pingkunga/bun-redmine:0.3.1rc1 ``` ## Test From e865c934ab21850b3b9e206f9c647367510fcd88 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 20:59:24 +0700 Subject: [PATCH 09/95] feat: excel export (package file-saver / xlsx ) --- bun.lock | 23 +++++++++++++++++++++++ package.json | 3 +++ 2 files changed, 26 insertions(+) diff --git a/bun.lock b/bun.lock index 6b1b155..3add9ee 100644 --- a/bun.lock +++ b/bun.lock @@ -15,17 +15,20 @@ "axios": "^1.13.2", "baseline-browser-mapping": "^2.9.11", "caniuse-lite": "^1.0.30001762", + "file-saver": "^2.0.5", "nodemailer": "^7.0.12", "nuxt": "^4.3.1", "nuxt-app": "file:", "tailwindcss": "^4.1.18", "vue": "latest", "vue-router": "latest", + "xlsx": "^0.18.5", "zod": "^4.3.4", }, "devDependencies": { "@iconify-json/mdi": "^1.2.3", "@nuxt/test-utils": "^3.17.2", + "@types/file-saver": "^2.0.7", "@vitest/ui": "^3.1.1", "@vue/test-utils": "^2.4.6", "happy-dom": "^17.4.4", @@ -394,6 +397,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, ""], + "@types/file-saver": ["@types/file-saver@2.0.7", "", {}, "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A=="], + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, ""], "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, ""], @@ -486,6 +491,8 @@ "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, ""], + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "agent-base": ["agent-base@7.1.4", "", {}, ""], "alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="], @@ -564,6 +571,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, ""], + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, ""], "check-error": ["check-error@2.1.1", "", {}, ""], @@ -582,6 +591,8 @@ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, ""], + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, ""], "color-name": ["color-name@1.1.4", "", {}, ""], @@ -790,6 +801,8 @@ "fflate": ["fflate@0.8.2", "", {}, ""], + "file-saver": ["file-saver@2.0.5", "", {}, "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, ""], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, ""], @@ -808,6 +821,8 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, ""], + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + "fraction.js": ["fraction.js@5.3.4", "", {}, ""], "framer-motion": ["framer-motion@12.33.0", "", { "dependencies": { "motion-dom": "^12.33.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, ""], @@ -1358,6 +1373,8 @@ "srvx": ["srvx@0.11.2", "", { "bin": "bin/srvx.mjs" }, ""], + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + "stackback": ["stackback@0.0.2", "", {}, ""], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, ""], @@ -1556,6 +1573,10 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, ""], + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, ""], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, ""], @@ -1564,6 +1585,8 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, ""], + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, ""], "xmlchars": ["xmlchars@2.2.0", "", {}, ""], diff --git a/package.json b/package.json index be57732..3ca7074 100644 --- a/package.json +++ b/package.json @@ -21,17 +21,20 @@ "axios": "^1.13.2", "baseline-browser-mapping": "^2.9.11", "caniuse-lite": "^1.0.30001762", + "file-saver": "^2.0.5", "nodemailer": "^7.0.12", "nuxt": "^4.3.1", "nuxt-app": "file:", "tailwindcss": "^4.1.18", "vue": "latest", "vue-router": "latest", + "xlsx": "^0.18.5", "zod": "^4.3.4" }, "devDependencies": { "@iconify-json/mdi": "^1.2.3", "@nuxt/test-utils": "^3.17.2", + "@types/file-saver": "^2.0.7", "@vitest/ui": "^3.1.1", "@vue/test-utils": "^2.4.6", "happy-dom": "^17.4.4", From 2866f086ddea2ab5631cd22794392a1b1b716e8d Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 21:03:53 +0700 Subject: [PATCH 10/95] feat: export excel Co-authored-by: Copilot --- app/pages/issues/index.vue | 103 +++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/app/pages/issues/index.vue b/app/pages/issues/index.vue index afbfbd9..7a4cd48 100644 --- a/app/pages/issues/index.vue +++ b/app/pages/issues/index.vue @@ -33,14 +33,15 @@
-
+
Search + Export Excel
import { useRuntimeConfig } from "#app"; import type { TableColumn } from "@nuxt/ui"; +import { saveAs } from 'file-saver' +import * as XLSX from 'xlsx' import { getGroupedRowModel } from '@tanstack/vue-table' +import type { Cell, Row, Table } from '@tanstack/vue-table' const config = useRuntimeConfig(); const baseUrl = config.public.redmineUrl; @@ -95,6 +100,7 @@ const versions = computed(() => dataversions.value ?? []); const selectedVersions = ref([]); const IssuesByVersions = ref([]); +const issuesTable = useTemplateRef<{ tableApi: Table }>('issuesTable') const groupedColumns = ref([]) @@ -149,6 +155,95 @@ const removeSelectedVersion = (v: Version) => { selectedVersions.value = selectedVersions.value.filter(sv => sv.id !== v.id) } +const stripHtml = (html: string | null | undefined): string => { + if (!html) { + return '' + } + + if (import.meta.client) { + const doc = new DOMParser().parseFromString(html, 'text/html') + return (doc.body.textContent || '').replace(/\s+/g, ' ').trim() + } + + return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() +} + +const flattenLeafRows = (rows: Row[]): TData[] => { + const result: TData[] = [] + + const visitRow = (row: Row): void => { + if (row.getIsGrouped()) { + row.subRows.forEach(visitRow) + return + } + + result.push(row.original) + } + + rows.forEach(visitRow) + return result +} + +const getExportIssues = (): Issue[] => { + const tableApi = issuesTable.value?.tableApi + if (!tableApi) { + return IssuesByVersions.value + } + + return flattenLeafRows(tableApi.getPrePaginationRowModel().rows) +} + +const formatExportDate = (value: string | null | undefined): string => { + if (!value) { + return '' + } + + return new Date(value).toLocaleDateString() +} + +const exportToExcel = (): void => { + const exportIssues = getExportIssues() + if (!exportIssues.length) { + return + } + + const rows = exportIssues.map((issue) => ({ + ID: issue.id, + Project: issue.projectName || '', + Version: issue.versionName || '', + Assignee: issue.assignedToUserName || '', + Subject: issue.subject || '', + Status: issue.statusName || '', + ImpactNote: stripHtml(issue.impactNote), + CreatedOn: formatExportDate(issue.created_on), + UpdatedOn: formatExportDate(issue.updated_on) + })) + + const worksheet = XLSX.utils.json_to_sheet(rows) + worksheet['!cols'] = [ + { wch: 10 }, + { wch: 24 }, + { wch: 18 }, + { wch: 20 }, + { wch: 60 }, + { wch: 18 }, + { wch: 50 }, + { wch: 14 }, + { wch: 14 } + ] + + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, worksheet, 'Issues') + + const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }) + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const fileName = `issues_${new Date().toISOString().slice(0, 10)}.xlsx` + saveAs(blob, fileName) +} + const renderDraggableHeader = (title: string, columnId: string) => { return h('div', { draggable: true, @@ -195,8 +290,8 @@ const columns: TableColumn[] = [ td: 'w-8 overflow-visible' }, colspan: { - td: (cell: any) => { - return cell.row?.getIsGrouped() ? cell.row.getAllCells().length : undefined + td: (cell: Cell) => { + return cell.row.getIsGrouped() ? String(cell.row.getAllCells().length) : '1' } } }, From d39ac4ddc9b7fd16457d1dc724ddb536da3b03c7 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 21:04:11 +0700 Subject: [PATCH 11/95] feat: export with preserve newline --- app/pages/issues/index.vue | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/pages/issues/index.vue b/app/pages/issues/index.vue index 7a4cd48..8ad9b93 100644 --- a/app/pages/issues/index.vue +++ b/app/pages/issues/index.vue @@ -155,17 +155,30 @@ const removeSelectedVersion = (v: Version) => { selectedVersions.value = selectedVersions.value.filter(sv => sv.id !== v.id) } +const normalizeCellText = (text: string): string => { + return text + .replace(/\r\n/g, '\n') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + const stripHtml = (html: string | null | undefined): string => { if (!html) { return '' } + const htmlWithBreaks = html + .replace(//gi, '\n') + .replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n') + if (import.meta.client) { - const doc = new DOMParser().parseFromString(html, 'text/html') - return (doc.body.textContent || '').replace(/\s+/g, ' ').trim() + const doc = new DOMParser().parseFromString(htmlWithBreaks, 'text/html') + return normalizeCellText(doc.body.textContent || '') } - return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() + return normalizeCellText(htmlWithBreaks.replace(/<[^>]*>/g, '')) } const flattenLeafRows = (rows: Row[]): TData[] => { From e43148ad1c957d4fe961c9425281da2a3c67903e Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 4 May 2026 21:37:03 +0700 Subject: [PATCH 12/95] fixbug Nuxt Docker Build Error failed to solve: invalid file request .output --- .dockerignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.dockerignore b/.dockerignore index 270ab33..41e5a20 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,7 @@ node_modules +.output +.nuxt +dist Dockerfile* docker-compose* .dockerignore From 8430c2db606284fad169f442064f5471e54b315d Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 11 May 2026 22:31:25 +0700 Subject: [PATCH 13/95] feat: by pass for same server --- server/middleware/auth.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index cf69e5f..e42f357 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -21,13 +21,15 @@ export default defineEventHandler(async (event) => { // 3. For API calls, we allow session or fallback to API Key + IP Whitelist // Note: We check specifically for /api/release/* if (url.pathname.startsWith('/api/release/')) { - if (user) { - // User logged in via UI session, allow + const clientIp = getRequestIP(event, { xForwardedFor: true }); + + // Whitelist: If same machine (localhost) or User is logged in, allow + const isSameMachine = clientIp === '127.0.0.1' || clientIp === '::1'; + if (user || isSameMachine) { return; } - // No session, check API Key and IP - const clientIp = getRequestIP(event, { xForwardedFor: true }); + // No session or not localhost, check API Key and IP const requestApiKey = getHeader(event, 'x-api-key'); const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); From 6a828f0dccd41088716d89fed1bf1315d88d1c77 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 11 May 2026 22:33:43 +0700 Subject: [PATCH 14/95] feat: show this week version info --- app/pages/index.vue | 111 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/app/pages/index.vue b/app/pages/index.vue index 4081624..92ee2a6 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,13 +1,118 @@ \ No newline at end of file From 4cd3c1e04a8e4f7fbdf1d58e4ae485d6e9d4549f Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 11 May 2026 22:47:03 +0700 Subject: [PATCH 15/95] fixbug: home permission error --- server/middleware/auth.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index e42f357..83e09c3 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -23,18 +23,22 @@ export default defineEventHandler(async (event) => { if (url.pathname.startsWith('/api/release/')) { const clientIp = getRequestIP(event, { xForwardedFor: true }); - // Whitelist: If same machine (localhost) or User is logged in, allow - const isSameMachine = clientIp === '127.0.0.1' || clientIp === '::1'; - if (user || isSameMachine) { + // Internal Check: If request is from server-to-self (SSR) or localhost + const isInternalRequest = !clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === 'localhost'; + + // Whitelist: User is logged in or IP is allowed + const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); + const isWhitelisted = allowedIps.includes(clientIp || ''); + + if (user || isInternalRequest || isWhitelisted) { return; } - // No session or not localhost, check API Key and IP + // No session/whitelist, check API Key and IP requirement const requestApiKey = getHeader(event, 'x-api-key'); - const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); - - // Check IP - if (allowedIps.length > 0 && (!clientIp || !allowedIps.includes(clientIp))) { + + // Check IP separately if needed for other scenarios + if (allowedIps.length > 0 && (!clientIp || !isWhitelisted)) { throw createError({ statusCode: 403, statusMessage: `Forbidden: IP ${clientIp} not allowed` }); } From 599604fafdbb0eb3f74a7e53e973897578d7c618 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Mon, 11 May 2026 22:55:55 +0700 Subject: [PATCH 16/95] fixbug: reverse --- server/middleware/auth.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 83e09c3..165b801 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -21,24 +21,18 @@ export default defineEventHandler(async (event) => { // 3. For API calls, we allow session or fallback to API Key + IP Whitelist // Note: We check specifically for /api/release/* if (url.pathname.startsWith('/api/release/')) { - const clientIp = getRequestIP(event, { xForwardedFor: true }); - - // Internal Check: If request is from server-to-self (SSR) or localhost - const isInternalRequest = !clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === 'localhost'; - - // Whitelist: User is logged in or IP is allowed - const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); - const isWhitelisted = allowedIps.includes(clientIp || ''); - - if (user || isInternalRequest || isWhitelisted) { + if (user) { + // User logged in via UI session, allow return; } - // No session/whitelist, check API Key and IP requirement + // No session, check API Key and IP + const clientIp = getRequestIP(event, { xForwardedFor: true }); const requestApiKey = getHeader(event, 'x-api-key'); - - // Check IP separately if needed for other scenarios - if (allowedIps.length > 0 && (!clientIp || !isWhitelisted)) { + const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); + + // Check IP + if (allowedIps.length > 0 && (!clientIp || !allowedIps.includes(clientIp))) { throw createError({ statusCode: 403, statusMessage: `Forbidden: IP ${clientIp} not allowed` }); } @@ -57,4 +51,4 @@ export default defineEventHandler(async (event) => { // Store user in context for downstream handlers if needed event.context.user = user; -}); +}); \ No newline at end of file From e2e94324a9d3b32ea5af9e57dcc96beba27bce91 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Tue, 12 May 2026 03:43:07 +0700 Subject: [PATCH 17/95] fixbug : by pass is same site --- server/middleware/auth.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 165b801..54077f4 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -21,18 +21,24 @@ export default defineEventHandler(async (event) => { // 3. For API calls, we allow session or fallback to API Key + IP Whitelist // Note: We check specifically for /api/release/* if (url.pathname.startsWith('/api/release/')) { - if (user) { - // User logged in via UI session, allow + const clientIp = getRequestIP(event, { xForwardedFor: true }); + console.log(`Auth Middleware: Client IP - ${clientIp}, User - ${user ? user.username : 'None'}`); + // Internal Check: If SSR/Internal Fetch (!clientIp) or Localhost + const isInternal = !clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === 'localhost'; + + // Whitelist Check: IP matches explicitly allowed list + const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); + const isWhitelisted = clientIp && allowedIps.includes(clientIp); + + if (user || isInternal || isWhitelisted) { return; } - // No session, check API Key and IP - const clientIp = getRequestIP(event, { xForwardedFor: true }); + // No session/whitelist, check API Key and IP requirement const requestApiKey = getHeader(event, 'x-api-key'); - const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); - - // Check IP - if (allowedIps.length > 0 && (!clientIp || !allowedIps.includes(clientIp))) { + + // Check IP separately for more specific error message if whitelist is configured + if (allowedIps.length > 0 && !isWhitelisted) { throw createError({ statusCode: 403, statusMessage: `Forbidden: IP ${clientIp} not allowed` }); } @@ -40,13 +46,6 @@ export default defineEventHandler(async (event) => { if (config.notifyReleaseMailApiKey && requestApiKey !== config.notifyReleaseMailApiKey) { throw createError({ statusCode: 401, statusMessage: 'Unauthorized: Invalid API Key' }); } - - // If neither session nor apiKey present (if apiKey configured) - if (!config.notifyReleaseMailApiKey && !user && !allowedIps.length) { - // If nothing is configured, maybe it's wide open (as before) or we want to block? - // Let's allow it for now or force auth if required. - // But for safety, let's at least expect a session or key if we're adding auth. - } } // Store user in context for downstream handlers if needed From d7243d98b9ebfaa7efa77e65f14e2ccb8e4c379a Mon Sep 17 00:00:00 2001 From: pingkunga Date: Tue, 12 May 2026 04:09:17 +0700 Subject: [PATCH 18/95] fixbug: bypass if from frontend --- server/middleware/auth.ts | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 54077f4..6d9c921 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -22,29 +22,38 @@ export default defineEventHandler(async (event) => { // Note: We check specifically for /api/release/* if (url.pathname.startsWith('/api/release/')) { const clientIp = getRequestIP(event, { xForwardedFor: true }); - console.log(`Auth Middleware: Client IP - ${clientIp}, User - ${user ? user.username : 'None'}`); - // Internal Check: If SSR/Internal Fetch (!clientIp) or Localhost - const isInternal = !clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === 'localhost'; - // Whitelist Check: IP matches explicitly allowed list - const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); - const isWhitelisted = clientIp && allowedIps.includes(clientIp); + // Stage 1: Trusted Internal/Dashboard Traffic + // - User is logged in via UI + // - SSR / Internal Fetch (!clientIp) + // - Localhost access + // - Requested from our own UI (Referer check to handle dynamic home IP) + const isInternal = !clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === 'localhost'; + const isFromOurUI = getHeader(event, 'referer')?.includes(url.host); + console.log(`[Auth Middleware] Client IP: ${clientIp}, User: ${user ? user.username : 'None'}, IsInternal: ${isInternal}, IsFromOurUI: ${isFromOurUI}`); - if (user || isInternal || isWhitelisted) { + if (user || isInternal || isFromOurUI) { return; } - // No session/whitelist, check API Key and IP requirement + // Stage 2: External Automation (Jenkins / External Scripts) + // Must have BOTH: IP in Whitelist AND Valid API Key + const allowedIps = (config.notifyReleaseMailAllowedIps || '').split(',').map(ip => ip.trim()).filter(ip => ip); + const isWhitelisted = clientIp && allowedIps.includes(clientIp); const requestApiKey = getHeader(event, 'x-api-key'); + const isValidKey = config.notifyReleaseMailApiKey && requestApiKey === config.notifyReleaseMailApiKey; - // Check IP separately for more specific error message if whitelist is configured - if (allowedIps.length > 0 && !isWhitelisted) { - throw createError({ statusCode: 403, statusMessage: `Forbidden: IP ${clientIp} not allowed` }); + if (isWhitelisted && isValidKey) { + return; } - // Check API Key - if (config.notifyReleaseMailApiKey && requestApiKey !== config.notifyReleaseMailApiKey) { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized: Invalid API Key' }); + // Comprehensive Error Logging & Handling + if (!isWhitelisted && allowedIps.length > 0) { + throw createError({ statusCode: 403, statusMessage: `Forbidden: IP ${clientIp} not in whitelist` }); + } + + if (!isValidKey) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized: Missing or Invalid API Key' }); } } From dbc03a186a207848882ff1f8b7bb2fd493bc577c Mon Sep 17 00:00:00 2001 From: pingkunga Date: Tue, 12 May 2026 04:51:30 +0700 Subject: [PATCH 19/95] fixbug : bypass same site --- server/middleware/auth.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 6d9c921..bdc77de 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -27,10 +27,16 @@ export default defineEventHandler(async (event) => { // - User is logged in via UI // - SSR / Internal Fetch (!clientIp) // - Localhost access - // - Requested from our own UI (Referer check to handle dynamic home IP) + // - Requested from our own UI (Referer/Sec-Fetch-Site check to handle dynamic home IP) const isInternal = !clientIp || clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === 'localhost'; - const isFromOurUI = getHeader(event, 'referer')?.includes(url.host); - console.log(`[Auth Middleware] Client IP: ${clientIp}, User: ${user ? user.username : 'None'}, IsInternal: ${isInternal}, IsFromOurUI: ${isFromOurUI}`); + + const referer = getHeader(event, 'referer'); + const secFetchSite = getHeader(event, 'sec-fetch-site'); + const isFromOurUI = (referer && referer.includes(url.host)) || + (secFetchSite === 'same-origin') || + (secFetchSite === 'same-site'); + + console.log(`[Auth Middleware] Client IP: ${clientIp}, User: ${user ? user.username : 'None'}, IsInternal: ${isInternal}, IsFromOurUI: ${isFromOurUI}, SecFetch: ${secFetchSite}`); if (user || isInternal || isFromOurUI) { return; From 54caa1aad7b5f0a3c605ad39dc8b4ab122f373fd Mon Sep 17 00:00:00 2001 From: pingkunga Date: Tue, 12 May 2026 05:02:51 +0700 Subject: [PATCH 20/95] fixbug: by pass samesite --- server/middleware/auth.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index bdc77de..06370f9 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -32,11 +32,16 @@ export default defineEventHandler(async (event) => { const referer = getHeader(event, 'referer'); const secFetchSite = getHeader(event, 'sec-fetch-site'); + const userAgent = getHeader(event, 'user-agent'); + const isBrowser = userAgent && (userAgent.includes('Mozilla') || userAgent.includes('Chrome') || userAgent.includes('Safari')); + + // Comprehensive UI check: Header exists OR it's a browser requesting our domain directly const isFromOurUI = (referer && referer.includes(url.host)) || (secFetchSite === 'same-origin') || - (secFetchSite === 'same-site'); + (secFetchSite === 'same-site') || + (isBrowser && !getHeader(event, 'x-api-key')); - console.log(`[Auth Middleware] Client IP: ${clientIp}, User: ${user ? user.username : 'None'}, IsInternal: ${isInternal}, IsFromOurUI: ${isFromOurUI}, SecFetch: ${secFetchSite}`); + console.log(`[Auth Middleware] Path: ${url.pathname}, IP: ${clientIp}, Browser: ${isBrowser}, UI: ${isFromOurUI}`); if (user || isInternal || isFromOurUI) { return; From ffd409f014f50e5d85635b27dbf1de29f9d4eb2f Mon Sep 17 00:00:00 2001 From: pingkunga Date: Wed, 13 May 2026 05:26:51 +0700 Subject: [PATCH 21/95] feat: use x-internal-key --- README.md | 10 +++++----- app/pages/index.vue | 14 ++++++++++---- nuxt.config.ts | 3 ++- server/middleware/auth.ts | 30 +++++++++--------------------- 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7ee9e43..f72fa38 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,14 @@ bun add axios ## Build & Run ``` -docker build --pull -t bun-redmine:0.3.1rc1 . +docker build --pull -t bun-redmine:0.3.2rc7 . -docker build --pull -t bun-redmine:0.3.1rc1 . --no-cache --progress=plain +docker build --pull -t bun-redmine:0.3.2rc7 . --no-cache --progress=plain -docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.1rc1 +docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.2rc7 -docker tag bun-redmine:0.3.1rc1 pi1ngkunga/bun-redmine:0.3.1rc1 -docker push pingkunga/bun-redmine:0.3.1rc1 +docker tag bun-redmine:0.3.2rc7 pingkunga/bun-redmine:0.3.2rc7 +docker push pingkunga/bun-redmine:0.3.2rc7 ``` ## Test diff --git a/app/pages/index.vue b/app/pages/index.vue index 92ee2a6..86473d5 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -40,7 +40,7 @@

- {{ releases[0]?.ownerTeam }} + Maintainer: {{ releases[0]?.ownerTeam }}

@@ -53,7 +53,7 @@ {{ rel.name }} - + {{ rel.description || 'No description' }}
@@ -83,13 +83,19 @@ + + + From 4ae7368129b6e91571c0183bfc8e0cd4a0a049f6 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Sun, 31 May 2026 11:29:07 +0700 Subject: [PATCH 26/95] feat: add menu --- app/components/sidebar/Sidebar.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/components/sidebar/Sidebar.vue b/app/components/sidebar/Sidebar.vue index 5386d3c..73e24cc 100644 --- a/app/components/sidebar/Sidebar.vue +++ b/app/components/sidebar/Sidebar.vue @@ -25,6 +25,10 @@ Release Mail + + + GitLab Branches +
From 326818a135e3c76258f503855676b25b8a3e1859 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Wed, 3 Jun 2026 10:02:53 +0700 Subject: [PATCH 27/95] add GitLabCacheData / note will remove @ts-ignore later --- nuxt.config.ts | 9 ++++ server/api/gitlab/branches.get.ts | 50 ++--------------- server/utils/gitlabCache.ts | 90 +++++++++++++++++++++++++++++++ shared/types/GitLab.d.ts | 10 ++++ 4 files changed, 113 insertions(+), 46 deletions(-) create mode 100644 server/utils/gitlabCache.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 305ace6..2b03e44 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -22,7 +22,16 @@ export default defineNuxtConfig({ adminPass: "password", adminSessionSecret: "a-very-secret-key-12345", adminSessionSecure: true, + // @ts-ignore gitlabToken: process.env.GITLAB_TOKEN || "no_token", + // GitLab Cache Configuration + // @ts-ignore + gitlabCacheMode: process.env.GITLAB_CACHE_MODE || "file", // "file" | "mongodb" + // @ts-ignore + mongodbUri: process.env.MONGODB_URI || "mongodb://localhost:27017/redmine-client", + // @ts-ignore + gitlabCacheDir: process.env.GITLAB_CACHE_DIR || "./data/gitlab-cache", + public: { redmineUrl: "https://redmine.example.com", appVersion: "0.3.0-DEV", diff --git a/server/api/gitlab/branches.get.ts b/server/api/gitlab/branches.get.ts index fa1a001..ccd44e7 100644 --- a/server/api/gitlab/branches.get.ts +++ b/server/api/gitlab/branches.get.ts @@ -12,56 +12,14 @@ export default defineEventHandler(async (event) => { }) } - const config = useRuntimeConfig() - const gitlabUrl = config.public.gitlabUrl - const gitlabToken = config.gitlabToken - - const headers = { - 'PRIVATE-TOKEN': gitlabToken, - } - try { - // 1. Fetch Branches - const branchesResponse = await axios.get( - `${gitlabUrl}/api/v4/projects/${projectId}/repository/branches`, - { headers } - ) - const branches = branchesResponse.data - - // 2. Fetch Push Events (to find creators) - const eventsResponse = await axios.get( - `${gitlabUrl}/api/v4/projects/${projectId}/events`, - { - headers, - params: { action: 'pushed', per_page: 100 } - } - ) - const events = eventsResponse.data - - // 3. Filter branch creation events - const branchCreations = events.filter(e => - e.push_data?.action === "created" && - e.push_data?.ref_type === "branch" - ) - - // 4. Merge data - const mergedBranches = branches.map(branch => { - const creationEvent = branchCreations.find(e => e.push_data?.ref === branch.name) - - return { - ...branch, - creator_name: creationEvent ? creationEvent.author.name : branch.commit.author_name, - created_at: creationEvent ? creationEvent.created_at : branch.commit.created_at || branch.commit.authored_date, - is_direct: !!creationEvent - } - }) - - return mergedBranches + // Use the cache utility to handle hybrid data fetching + return await getGitLabBranches(projectId) } catch (error: any) { - console.error('Error fetching GitLab data:', error.response?.data || error.message) + console.error('GitLab API Error:', error.response?.data || error.message) throw createError({ statusCode: error.response?.status || 500, - statusMessage: `GitLab API Error: ${error.message}`, + statusMessage: 'Failed to fetch GitLab branches', }) } }) diff --git a/server/utils/gitlabCache.ts b/server/utils/gitlabCache.ts new file mode 100644 index 0000000..71c93fb --- /dev/null +++ b/server/utils/gitlabCache.ts @@ -0,0 +1,90 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import axios from 'axios'; +import type { GitLabBranch, GitLabEvent, GitLabCacheData } from '~~/shared/types/GitLab'; + +export const getGitLabBranches = async (projectId: string) => { + const config = useRuntimeConfig(); + const gitlabUrl = config.public.gitlabUrl; + const gitlabToken = (config as any).gitlabToken; + const gitlabCacheMode = (config as any).gitlabCacheMode; + const gitlabCacheDir = (config as any).gitlabCacheDir; + + const headers = { + 'PRIVATE-TOKEN': gitlabToken, + }; + + // 1. Load existing cache + let cache: GitLabCacheData = { projectId: Number(projectId), branches: {}, lastUpdated: '' }; + const cacheFilePath = path.join(process.cwd(), gitlabCacheDir, `project-${projectId}.json`); + + if (gitlabCacheMode === 'file') { + try { + await fs.mkdir(path.dirname(cacheFilePath), { recursive: true }); + const fileContent = await fs.readFile(cacheFilePath, 'utf-8'); + cache = JSON.parse(fileContent); + } catch (e) { + // Cache file doesn't exist yet, proceed with empty cache + } + } + + // 2. Fetch current branches from GitLab + const branchesRes = await axios.get( + `${gitlabUrl}/api/v4/projects/${projectId}/repository/branches`, + { headers } + ); + const currentBranches = branchesRes.data; + + // 3. Fetch recent push events (The 90-day window) + const eventsRes = await axios.get( + `${gitlabUrl}/api/v4/projects/${projectId}/events`, + { + headers, + params: { action: 'pushed', per_page: 100 } + } + ); + const pushEvents = eventsRes.data.filter(e => + e.push_data?.action === "created" && + e.push_data?.ref_type === "branch" + ); + + // 4. Merge Logic + const mergedBranches = currentBranches.map(branch => { + // A. Check recent API events (Direct info) + const apiEvent = pushEvents.find(e => e.push_data?.ref === branch.name); + if (apiEvent) { + const info = { + creator_name: apiEvent.author.name, + created_at: apiEvent.created_at, + is_direct: true + }; + // Upsert into cache + cache.branches[branch.name] = info; + return { ...branch, ...info }; + } + + // B. Check persistent cache (Old Direct info) + if (cache.branches[branch.name] && cache.branches[branch.name].is_direct) { + return { ...branch, ...cache.branches[branch.name] }; + } + + // C. Fallback: Indirect calculation from Commit + return { + ...branch, + creator_name: branch.commit.author_name, + created_at: branch.commit.authored_date, // Using authored_date as primary fallback + is_direct: false + }; + }); + + // 5. Save Cache (Auto-fill) + if (gitlabCacheMode === 'file') { + cache.lastUpdated = new Date().toISOString(); + await fs.writeFile(cacheFilePath, JSON.stringify(cache, null, 2)); + } else if (gitlabCacheMode === 'mongodb') { + // MongoDB implementation placeholder + console.warn('MongoDB mode not yet fully implemented, check gitlabCache.ts'); + } + + return mergedBranches; +}; diff --git a/shared/types/GitLab.d.ts b/shared/types/GitLab.d.ts index 027e6c2..70d4e69 100644 --- a/shared/types/GitLab.d.ts +++ b/shared/types/GitLab.d.ts @@ -28,6 +28,16 @@ export interface GitLabBranch { is_direct?: boolean; } +export interface GitLabCacheData { + projectId: number; + branches: Record; + lastUpdated: string; +} + export interface GitLabEvent { id: number; project_id: number; From 9899ec6ccd1ec7f18304f80e524d16609c6f1c61 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Wed, 3 Jun 2026 15:17:43 +0700 Subject: [PATCH 28/95] feat: add feature sync gitlab event for store who created branch --- README.md | 10 +- app/composables/useGitLabAPI.ts | 14 +- app/pages/admin/branches/index.vue | 90 +++++++++-- server/api/gitlab/branches.get.ts | 3 - server/api/gitlab/sync-events.post.ts | 30 ++++ server/utils/gitlabCache.ts | 211 ++++++++++++++++++++------ shared/types/GitLab.d.ts | 8 + 7 files changed, 296 insertions(+), 70 deletions(-) create mode 100644 server/api/gitlab/sync-events.post.ts diff --git a/README.md b/README.md index f72fa38..ec073ab 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,14 @@ bun add axios ## Build & Run ``` -docker build --pull -t bun-redmine:0.3.2rc7 . +docker build --pull -t bun-redmine:0.3.3rc2 . -docker build --pull -t bun-redmine:0.3.2rc7 . --no-cache --progress=plain +docker build --pull -t bun-redmine:0.3.3rc2 . --no-cache --progress=plain -docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.2rc7 +docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.3rc2 -docker tag bun-redmine:0.3.2rc7 pingkunga/bun-redmine:0.3.2rc7 -docker push pingkunga/bun-redmine:0.3.2rc7 +docker tag bun-redmine:0.3.3rc2 pingkunga/bun-redmine:0.3.3rc2 +docker push pingkunga/bun-redmine:0.3.3rc2 ``` ## Test diff --git a/app/composables/useGitLabAPI.ts b/app/composables/useGitLabAPI.ts index 17116d2..3cabd75 100644 --- a/app/composables/useGitLabAPI.ts +++ b/app/composables/useGitLabAPI.ts @@ -1,4 +1,4 @@ -import type { GitLabProject, GitLabBranch } from "~~/shared/types/GitLab"; +import type { GitLabProject, GitLabBranch, GitLabSyncResult } from "~~/shared/types/GitLab"; export default () => { const fetchGitLabProjects = async () => { @@ -11,8 +11,20 @@ export default () => { }); }; + const syncGitLabEvents = async (projectId: number, after: string, before: string) => { + return await $fetch("/api/gitlab/sync-events", { + method: "POST", + body: { + projectId, + after, + before, + }, + }); + }; + return { fetchGitLabProjects, fetchGitLabBranches, + syncGitLabEvents, }; }; diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index 13134ce..60c3208 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -1,14 +1,22 @@ - From 3caa91c761d55164aa38bc425250656453183c14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:01:31 +0000 Subject: [PATCH 36/95] style: align branch age helper formatting --- app/pages/admin/branches/index.vue | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index 1b2b439..2330ec3 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -83,13 +83,13 @@ const getRelativeAge = (dateString: string | undefined) => { }; const getAgeDays = (dateString: string | undefined) => { - if (!dateString) return 0 - const date = new Date(dateString) - const now = new Date() - const diffInMilliseconds = now.getTime() - date.getTime() + if (!dateString) return 0; + const date = new Date(dateString); + const now = new Date(); + const diffInMilliseconds = now.getTime() - date.getTime(); - return Math.floor(diffInMilliseconds / (1000 * 60 * 60 * 24)) -} + return Math.floor(diffInMilliseconds / (1000 * 60 * 60 * 24)); +}; const columns = [ { From 2299aaa0ecb36846ee41c59f75e58a53f11d6e0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:02:34 +0000 Subject: [PATCH 37/95] refactor: reuse computed branch age cell value --- app/pages/admin/branches/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index 2330ec3..afba6a1 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -133,7 +133,7 @@ const columns = [ accessorFn: (branch: GitLabBranch) => getAgeDays(branch.created_at), header: 'Age (Days)', enableSorting: true, - cell: ({ row }: any) => h('span', { class: 'font-medium' }, getAgeDays((row.original as GitLabBranch).created_at).toString()) + cell: ({ row }: any) => h('span', { class: 'font-medium' }, row.getValue('ageDays')?.toString?.() || '0') }, { accessorKey: 'commit_title', From 256579e17a02acb2eb23bb414353760171e06c11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:03:44 +0000 Subject: [PATCH 38/95] fix: harden branch age days calculation --- app/pages/admin/branches/index.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index afba6a1..811c8c7 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -2,7 +2,7 @@ import { h } from 'vue' import type { GitLabProject, GitLabBranch } from '~~/shared/types/GitLab' import { UBadge, UButton, UIcon, UInput, USelect, UPagination } from '#components' -import { getPaginationRowModel } from '@tanstack/vue-table' +import { getPaginationRowModel, type CellContext } from '@tanstack/vue-table' import { saveAs } from 'file-saver' import * as XLSX from 'xlsx' @@ -85,6 +85,7 @@ const getRelativeAge = (dateString: string | undefined) => { const getAgeDays = (dateString: string | undefined) => { if (!dateString) return 0; const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return 0; const now = new Date(); const diffInMilliseconds = now.getTime() - date.getTime(); @@ -133,7 +134,7 @@ const columns = [ accessorFn: (branch: GitLabBranch) => getAgeDays(branch.created_at), header: 'Age (Days)', enableSorting: true, - cell: ({ row }: any) => h('span', { class: 'font-medium' }, row.getValue('ageDays')?.toString?.() || '0') + cell: ({ row }: CellContext) => h('span', { class: 'font-medium' }, row.getValue('ageDays')?.toString?.() || '0') }, { accessorKey: 'commit_title', From a9c4a99d5764464cec144335a8c5cef725c74b82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:04:42 +0000 Subject: [PATCH 39/95] refactor: simplify branch age days cell --- app/pages/admin/branches/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index 811c8c7..60e3f3e 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -134,7 +134,7 @@ const columns = [ accessorFn: (branch: GitLabBranch) => getAgeDays(branch.created_at), header: 'Age (Days)', enableSorting: true, - cell: ({ row }: CellContext) => h('span', { class: 'font-medium' }, row.getValue('ageDays')?.toString?.() || '0') + cell: ({ row }: CellContext) => h('span', { class: 'font-medium' }, row.getValue('ageDays').toString()) }, { accessorKey: 'commit_title', From 97c60e575cfe94267c82787c1f6ec72f8c8cadca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:05:42 +0000 Subject: [PATCH 40/95] refactor: streamline age days cell rendering --- app/pages/admin/branches/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index 60e3f3e..f987919 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -134,7 +134,7 @@ const columns = [ accessorFn: (branch: GitLabBranch) => getAgeDays(branch.created_at), header: 'Age (Days)', enableSorting: true, - cell: ({ row }: CellContext) => h('span', { class: 'font-medium' }, row.getValue('ageDays').toString()) + cell: ({ row }: CellContext) => h('span', { class: 'font-medium' }, row.getValue('ageDays')) }, { accessorKey: 'commit_title', From decdd31a6b459e1c4960f5362ca16fc4a7ff52bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:06:41 +0000 Subject: [PATCH 41/95] refactor: extract branch age day constant --- app/pages/admin/branches/index.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index f987919..dc7010e 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -50,6 +50,8 @@ const syncForm = reactive({ before: toISODate(new Date()) }) +const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24; + const formatDate = (dateString: string | undefined) => { if (!dateString) return '-'; const date = new Date(dateString); @@ -89,7 +91,7 @@ const getAgeDays = (dateString: string | undefined) => { const now = new Date(); const diffInMilliseconds = now.getTime() - date.getTime(); - return Math.floor(diffInMilliseconds / (1000 * 60 * 60 * 24)); + return Math.floor(diffInMilliseconds / MILLISECONDS_PER_DAY); }; const columns = [ From 456c198f92b66c09e51682b39877fb08660a2c11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:07:42 +0000 Subject: [PATCH 42/95] style: align branch age constant naming --- app/pages/admin/branches/index.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index dc7010e..179e7f0 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -50,7 +50,7 @@ const syncForm = reactive({ before: toISODate(new Date()) }) -const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24; +const millisecondsPerDay = 1000 * 60 * 60 * 24; const formatDate = (dateString: string | undefined) => { if (!dateString) return '-'; @@ -91,7 +91,7 @@ const getAgeDays = (dateString: string | undefined) => { const now = new Date(); const diffInMilliseconds = now.getTime() - date.getTime(); - return Math.floor(diffInMilliseconds / MILLISECONDS_PER_DAY); + return Math.floor(diffInMilliseconds / millisecondsPerDay); }; const columns = [ From 6a65d45c2a00e0d876116332f3ec972dfcfa29a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:08:42 +0000 Subject: [PATCH 43/95] refactor: localize branch age day constant --- app/pages/admin/branches/index.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index 179e7f0..50523ed 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -50,8 +50,6 @@ const syncForm = reactive({ before: toISODate(new Date()) }) -const millisecondsPerDay = 1000 * 60 * 60 * 24; - const formatDate = (dateString: string | undefined) => { if (!dateString) return '-'; const date = new Date(dateString); @@ -86,12 +84,13 @@ const getRelativeAge = (dateString: string | undefined) => { const getAgeDays = (dateString: string | undefined) => { if (!dateString) return 0; + const dayInMilliseconds = 1000 * 60 * 60 * 24; const date = new Date(dateString); if (Number.isNaN(date.getTime())) return 0; const now = new Date(); const diffInMilliseconds = now.getTime() - date.getTime(); - return Math.floor(diffInMilliseconds / millisecondsPerDay); + return Math.floor(diffInMilliseconds / dayInMilliseconds); }; const columns = [ From a68916d132c830f918d96c3242914407514b55e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:09:48 +0000 Subject: [PATCH 44/95] Apply remaining changes --- app/pages/admin/branches/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index 50523ed..fe3770b 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -88,7 +88,7 @@ const getAgeDays = (dateString: string | undefined) => { const date = new Date(dateString); if (Number.isNaN(date.getTime())) return 0; const now = new Date(); - const diffInMilliseconds = now.getTime() - date.getTime(); + const diffInMilliseconds = Math.max(0, now.getTime() - date.getTime()); return Math.floor(diffInMilliseconds / dayInMilliseconds); }; From 30a90814740b652f33ab214c7233d22e783a5084 Mon Sep 17 00:00:00 2001 From: pingkunga Date: Sun, 7 Jun 2026 15:35:24 +0700 Subject: [PATCH 45/95] feat: create common helper for grouping --- app/composables/useTableGrouping.ts | 35 +++++ app/pages/admin/branches/index.vue | 194 +++++++++++++++++++++++++++- app/utils/tableHelpers.ts | 87 +++++++++++++ 3 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 app/composables/useTableGrouping.ts create mode 100644 app/utils/tableHelpers.ts diff --git a/app/composables/useTableGrouping.ts b/app/composables/useTableGrouping.ts new file mode 100644 index 0000000..db1a9f2 --- /dev/null +++ b/app/composables/useTableGrouping.ts @@ -0,0 +1,35 @@ +export function useTableGrouping(allowedColumns: string[]) { + const groupedColumns = ref([]) + const isDragOver = ref(false) + const draggedColumn = ref(null) + + const handleDragStart = (event: DragEvent, columnId: string) => { + draggedColumn.value = columnId + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', columnId) + } + } + + const handleDrop = (event: DragEvent) => { + isDragOver.value = false + const columnId = event.dataTransfer?.getData('text/plain') + if (columnId && allowedColumns.includes(columnId)) { + if (!groupedColumns.value.includes(columnId)) { + groupedColumns.value = [...groupedColumns.value, columnId] + } + } + } + + const handleRemoveGroup = (columnId: string) => { + groupedColumns.value = groupedColumns.value.filter(id => id !== columnId) + } + + return { + groupedColumns, + isDragOver, + handleDragStart, + handleDrop, + handleRemoveGroup, + } +} \ No newline at end of file diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue index fe3770b..dd12ebb 100644 --- a/app/pages/admin/branches/index.vue +++ b/app/pages/admin/branches/index.vue @@ -1,10 +1,16 @@