diff --git a/gurubu-backend/controllers/jiraPdfCreatorController.js b/gurubu-backend/controllers/jiraPdfCreatorController.js new file mode 100644 index 00000000..9662a067 --- /dev/null +++ b/gurubu-backend/controllers/jiraPdfCreatorController.js @@ -0,0 +1,51 @@ +const pdfService = require("../services/pdfService"); +const jiraService = require("../services/jiraService"); +const fs = require("fs"); +exports.downloadBoardIssuesAsPdf = async (req, res) => { + try { + const { boardId } = req.params; + + if (!boardId) { + return res.status(400).json({ error: "Board ID gerekli" }); + } + + const result = await jiraService.getAllBoardIssuesWithInitialStoryPoints( + boardId + ); + + if (!result || !result.issues || result.issues.length === 0) { + return res.status(404).json({ error: "Bu board için görev bulunamadı" }); + } + + const pdfPath = await pdfService.generateIssuesReport( + result.issues, + boardId + ); + + if (!fs.existsSync(pdfPath)) { + return res + .status(500) + .json({ error: "PDF oluşturulurken bir hata oluştu" }); + } + + const today = new Date(); + const formattedDate = today.toISOString().split("T")[0]; + const formattedTime = today.toTimeString().split(" ")[0].replace(/:/g, ""); + const filename = `jira_board_${boardId}_raporu_${formattedDate}_${formattedTime}.pdf`; + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + + const fileStream = fs.createReadStream(pdfPath); + fileStream.pipe(res); + + fileStream.on("close", () => { + pdfService.cleanupPdf(pdfPath); + }); + } catch (error) { + console.error("PDF oluşturma hatası:", error); + res.status(500).json({ + error: "PDF oluşturulurken bir hata oluştu", + details: error.message, + }); + } +}; diff --git a/gurubu-backend/package.json b/gurubu-backend/package.json index 63a31276..086fb013 100644 --- a/gurubu-backend/package.json +++ b/gurubu-backend/package.json @@ -15,6 +15,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "openai": "^4.86.2", + "pdfkit": "^0.16.0", "redis": "^4.6.10", "socket.io": "^4.7.2", "uuid": "^9.0.1" diff --git a/gurubu-backend/routes/jiraPdfCreatorRoutes.js b/gurubu-backend/routes/jiraPdfCreatorRoutes.js new file mode 100644 index 00000000..63a575ce --- /dev/null +++ b/gurubu-backend/routes/jiraPdfCreatorRoutes.js @@ -0,0 +1,7 @@ +const express = require("express"); +const router = express.Router(); +const jiraPdfCreatorController = require("../controllers/jiraPdfCreatorController"); + +router.get("/:boardId/download-pdf", jiraPdfCreatorController.downloadBoardIssuesAsPdf); + +module.exports = router; diff --git a/gurubu-backend/server.js b/gurubu-backend/server.js index 280c7941..2d946bef 100644 --- a/gurubu-backend/server.js +++ b/gurubu-backend/server.js @@ -20,10 +20,12 @@ const healthCheckRoute = require("./routes/healthCheckRoute"); const jiraRoutes = require("./routes/jiraRoutes"); const pRoutes = require("./routes/pRoutes"); const storyPointRoutes = require("./routes/storyPointRoutes"); +const jiraPdfCreatorRoutes = require("./routes/jiraPdfCreatorRoutes"); app.use("/room", cors(corsOptions), roomRoutes); app.use("/healthcheck", cors(corsOptions), healthCheckRoute); app.use("/jira", cors(corsOptions), jiraRoutes); +app.use("/jira-pdf-creator", cors(corsOptions), jiraPdfCreatorRoutes); app.use("/p", cors(corsOptions), pRoutes); app.use("/storypoint", cors(corsOptions), storyPointRoutes); diff --git a/gurubu-backend/services/jiraService.js b/gurubu-backend/services/jiraService.js index 187ba30d..4e97c76d 100644 --- a/gurubu-backend/services/jiraService.js +++ b/gurubu-backend/services/jiraService.js @@ -268,6 +268,126 @@ class JiraService { throw error; } } + + async getAllBoardIssuesWithInitialStoryPoints(boardId) { + try { + let allIssues = []; + let startAt = 0; + const maxResults = 100; + let total = null; + + do { + console.log(`Fetching issues ${startAt} to ${startAt + maxResults}...`); + const response = await axios.get( + `${this.baseUrl}/rest/agile/1.0/board/${boardId}/issue`, + { + params: { + expand: 'changelog', + startAt: startAt, + maxResults: maxResults, + fields: 'key,assignee,changelog,customfield_10002,summary,description,' + + (process.env.JIRA_PROJECT_KEY_ONE ? process.env.JIRA_PROJECT_KEY_ONE + ',' : '') + + (process.env.JIRA_PROJECT_KEY_TWO ? process.env.JIRA_PROJECT_KEY_TWO + ',' : '') + + (process.env.JIRA_PROJECT_KEY_THREE ? process.env.JIRA_PROJECT_KEY_THREE + ',' : '') + + (process.env.JIRA_PROJECT_KEY_FOUR ? process.env.JIRA_PROJECT_KEY_FOUR : '') + }, + auth: this.auth, + headers: { + Accept: "application/json", + }, + } + ); + + if (total === null) { + total = response.data.total; + } + + const processedIssues = response.data.issues.map(issue => { + let initialStoryPoint = null; + const currentStoryPoint = issue.fields[process.env.JIRA_PROJECT_KEY_FOUR] || + issue.fields.customfield_10002; + + let sortedHistories = []; + let firstStoryPointHistory = null; + + if (issue.changelog && issue.changelog.histories) { + sortedHistories = issue.changelog.histories.sort( + (a, b) => new Date(a.created) - new Date(b.created) + ); + + for (const history of sortedHistories) { + for (const item of history.items) { + if ( + item.field === process.env.JIRA_PROJECT_KEY_FOUR || + item.field === "Story Points" + ) { + + if (item.fromString && !isNaN(parseFloat(item.fromString))) { + initialStoryPoint = parseFloat(item.fromString); + firstStoryPointHistory = history; + break; + } + else if (!firstStoryPointHistory && item.toString && !isNaN(parseFloat(item.toString))) { + initialStoryPoint = parseFloat(item.toString); + firstStoryPointHistory = history; + break; + } + } + } + if (firstStoryPointHistory) break; + } + } + + if (initialStoryPoint === null && currentStoryPoint) { + initialStoryPoint = currentStoryPoint; + } + + return { + key: issue.key, + summary: issue.fields.summary || null, + description: issue.fields.description || null, + initialStoryPoint, + currentStoryPoint + }; + }) + .filter(issue => { + if (issue.summary === "Sprint Leftover") { + return false; + } + + if (!issue.initialStoryPoint || !issue.currentStoryPoint || + isNaN(issue.initialStoryPoint) || isNaN(issue.currentStoryPoint)) { + return false; + } + + return true; + }); + + allIssues = [...allIssues, ...processedIssues]; + + startAt += maxResults; + + console.log(`Processed ${allIssues.length} of ${total} issues...`); + + } while (startAt < total); + + return { + maxResults: maxResults, + startAt: 0, + total: total, + issues: allIssues + }; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Axios error details:", { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + }); + } + throw error; + } + } } module.exports = new JiraService(); diff --git a/gurubu-backend/services/pdfService.js b/gurubu-backend/services/pdfService.js new file mode 100644 index 00000000..fbd4054f --- /dev/null +++ b/gurubu-backend/services/pdfService.js @@ -0,0 +1,452 @@ +const PDFDocument = require("pdfkit"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +class PdfService { + turkishToEnglish(text) { + if (!text) return ""; + + return text + .toString() + .replace(/ı/g, "i") + .replace(/İ/g, "I") + .replace(/ğ/g, "g") + .replace(/Ğ/g, "G") + .replace(/ü/g, "u") + .replace(/Ü/g, "U") + .replace(/ş/g, "s") + .replace(/Ş/g, "S") + .replace(/ç/g, "c") + .replace(/Ç/g, "C") + .replace(/ö/g, "o") + .replace(/Ö/g, "O"); + } + + translateToEnglish(text) { + if (!text) return ""; + + const translations = { + "Jira Board": "Jira Board", + "Story Points Raporu": "Story Points Report", + "Toplam Görev": "Total Tasks", + "Toplam Baslangic Story Point": "Total Initial Story Points", + "Toplam Guncel Story Point": "Total Current Story Points", + "Toplam Degisim": "Total Change", + Özet: "Summary", + Açıklama: "Description", + "İlk SP": "Initial SP", + "Son SP": "Final SP", + Sayfa: "Page", + }; + + let result = this.turkishToEnglish(text); + + Object.keys(translations).forEach((key) => { + const englishKey = this.turkishToEnglish(key); + const pattern = new RegExp(englishKey, "g"); + result = result.replace(pattern, translations[key]); + }); + + return result; + } + + cleanText(text) { + if (!text) return ""; + + let cleanedText = text.toString().replace(/<[^>]*>/g, " "); + + cleanedText = cleanedText + .replace(/ /g, " ") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/‑/g, "-") + .replace(/\{code:.*?\}/g, "") + .trim(); + + cleanedText = cleanedText.replace(/\s+/g, " "); + + return this.turkishToEnglish(cleanedText); + } + + encodeSpecialChars(text) { + if (!text) return ""; + + return text + .toString() + .replace(/\r\n|\r|\n/g, " ") + .replace(/\t/g, " "); + } + + addPageNumber(doc, pageNumber, totalPages) { + doc + .font("Helvetica") + .fontSize(7) + .fillColor("#666666") + .text(`Page ${pageNumber} / ${totalPages}`, 20, doc.page.height - 15, { + align: "center", + width: doc.page.width - 40, + }); + } + + async generateIssuesReport(issues, boardId) { + return new Promise((resolve, reject) => { + try { + const tempFilePath = path.join( + os.tmpdir(), + `jira_board_${boardId}_report_${Date.now()}.pdf` + ); + + const doc = new PDFDocument({ + size: "A4", + margin: 20, + info: { + Title: `Jira Board ${boardId} Story Points Report`, + Author: "Gurubu Application", + Producer: "PDFKit", + Creator: "Gurubu PDF Service", + }, + pdfVersion: "1.7", + compress: true, + autoFirstPage: true, + bufferPages: false, + }); + + const stream = fs.createWriteStream(tempFilePath); + doc.pipe(stream); + + const originalFont = "Helvetica"; + const boldFont = "Helvetica-Bold"; + + doc + .font(boldFont) + .fontSize(14) + .fillColor("#000000") + .text(`Jira Board ${boardId} Story Points Report`, { + align: "center", + }); + + doc.moveDown(0.5); + + doc + .font(originalFont) + .fontSize(9) + .text(`Total Tasks: ${issues.length}`, { + align: "left", + }); + + let totalInitialPoints = 0; + let totalCurrentPoints = 0; + + issues.forEach((issue) => { + totalInitialPoints += issue.initialStoryPoint || 0; + totalCurrentPoints += issue.currentStoryPoint || 0; + }); + + doc.text( + `Total Initial Story Points: ${totalInitialPoints.toFixed(1)}`, + { + align: "left", + } + ); + + doc.text( + `Total Current Story Points: ${totalCurrentPoints.toFixed(1)}`, + { + align: "left", + } + ); + + const diff = totalCurrentPoints - totalInitialPoints; + const percentChange = + totalInitialPoints > 0 ? (diff / totalInitialPoints) * 100 : 0; + + doc.text( + `Total Change: ${diff.toFixed(1)} (${percentChange.toFixed(1)}%)`, + { + align: "left", + } + ); + + doc.moveDown(0.5); + + const pageWidth = doc.page.width - 40; + const colWidths = { + key: pageWidth * 0.1, + summary: pageWidth * 0.35, + description: pageWidth * 0.35, + initial: pageWidth * 0.1, + current: pageWidth * 0.1, + }; + + const tableTop = doc.y; + let tableRow = tableTop; + + doc + .lineWidth(0.5) + .moveTo(20, tableRow) + .lineTo(doc.page.width - 20, tableRow) + .stroke(); + + tableRow += 2; + + doc + .fillColor("#e0e0e0") + .rect(20, tableRow, pageWidth, 20) + .fill(); + + doc + .fillColor("#000000") + .fontSize(8) + .font(boldFont) + .text("Key", 23, tableRow + 6, { + width: colWidths.key, + align: "left", + }) + .text("Summary", 23 + colWidths.key, tableRow + 6, { + width: colWidths.summary, + align: "left", + }) + .text( + "Description", + 23 + colWidths.key + colWidths.summary, + tableRow + 6, + { width: colWidths.description, align: "left" } + ) + .text( + "Initial SP", + 23 + colWidths.key + colWidths.summary + colWidths.description, + tableRow + 6, + { width: colWidths.initial, align: "center" } + ) + .text( + "Final SP", + 23 + + colWidths.key + + colWidths.summary + + colWidths.description + + colWidths.initial, + tableRow + 6, + { width: colWidths.current, align: "center" } + ); + + tableRow += 20; + + doc + .lineWidth(0.5) + .moveTo(20, tableRow) + .lineTo(doc.page.width - 20, tableRow) + .stroke(); + + doc.font(originalFont); + + let currentPage = 1; + let rowCount = 0; + + this.addPageFooter(doc, currentPage); + + const availablePageHeight = doc.page.height - 50; + + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + + const key = issue.key; + const summary = this.cleanText(issue.summary || ""); + let description = this.cleanText(issue.description || ""); + + const fontSize = 7; + doc.fontSize(fontSize); + + const summaryWidth = colWidths.summary - 4; + const descriptionWidth = colWidths.description - 4; + + const summaryCharsPerLine = Math.floor( + summaryWidth / (fontSize * 0.5) + ); + const descriptionCharsPerLine = Math.floor( + descriptionWidth / (fontSize * 0.5) + ); + + const summaryLines = + Math.ceil(summary.length / summaryCharsPerLine) || 1; + const descriptionLines = + Math.ceil(description.length / descriptionCharsPerLine) || 1; + + const maxLines = Math.max(summaryLines, descriptionLines, 2); + + const lineHeight = fontSize * 1.2; + const rowHeight = Math.max(maxLines * lineHeight + 4, 18); + + if (tableRow + rowHeight > availablePageHeight) { + doc.addPage({ margin: 20 }); + currentPage++; + rowCount = 0; + + this.addPageFooter(doc, currentPage); + + tableRow = 40; + + doc + .lineWidth(0.5) + .moveTo(20, tableRow - 20) + .lineTo(doc.page.width - 20, tableRow - 20) + .stroke(); + + doc + .fillColor("#e0e0e0") + .rect(20, tableRow - 18, pageWidth, 16) + .fill(); + + doc + .fillColor("#000000") + .fontSize(8) + .font(boldFont) + .text("Key", 23, tableRow - 16, { + width: colWidths.key, + align: "left", + }) + .text("Summary", 23 + colWidths.key, tableRow - 16, { + width: colWidths.summary, + align: "left", + }) + .text( + "Description", + 23 + colWidths.key + colWidths.summary, + tableRow - 16, + { width: colWidths.description, align: "left" } + ) + .text( + "Initial SP", + 23 + colWidths.key + colWidths.summary + colWidths.description, + tableRow - 16, + { width: colWidths.initial, align: "center" } + ) + .text( + "Final SP", + 23 + + colWidths.key + + colWidths.summary + + colWidths.description + + colWidths.initial, + tableRow - 16, + { width: colWidths.current, align: "center" } + ); + + doc + .lineWidth(0.5) + .moveTo(20, tableRow) + .lineTo(doc.page.width - 20, tableRow) + .stroke(); + + doc.font(originalFont); + } + + if (i % 2 === 0) { + doc + .fillColor("#f5f5f5") + .rect(20, tableRow, pageWidth, rowHeight) + .fill(); + } + + const textY = tableRow + 2; + + doc.fillColor("#000000").fontSize(fontSize); + + doc.text(key, 23, textY, { + width: colWidths.key - 4, + height: rowHeight - 4, + }); + + doc.text(summary, 23 + colWidths.key, textY, { + width: colWidths.summary - 4, + height: rowHeight - 4, + }); + + doc.text( + description, + 23 + colWidths.key + colWidths.summary, + textY, + { + width: colWidths.description - 4, + height: rowHeight - 4, + } + ); + + doc.text( + issue.initialStoryPoint.toString(), + 23 + colWidths.key + colWidths.summary + colWidths.description, + textY, + { + width: colWidths.initial - 4, + height: rowHeight - 4, + align: "center", + } + ); + + doc.text( + issue.currentStoryPoint.toString(), + 23 + + colWidths.key + + colWidths.summary + + colWidths.description + + colWidths.initial, + textY, + { + width: colWidths.current - 4, + height: rowHeight - 4, + align: "center", + } + ); + + tableRow += rowHeight; + doc + .lineWidth(0.25) + .moveTo(20, tableRow) + .lineTo(doc.page.width - 20, tableRow) + .stroke(); + + rowCount++; + } + + doc.end(); + + stream.on("finish", () => { + resolve(tempFilePath); + }); + + stream.on("error", (error) => { + reject(error); + }); + } catch (error) { + console.error("PDF generation error:", error); + reject(error); + } + }); + } + + addPageFooter(doc, pageNumber) { + doc + .fontSize(10) + .fillColor("#000") + .text(`${pageNumber}`, 0, doc.page.height - 50, { + align: "center", + width: doc.page.width, + }); + } + + async cleanupPdf(filePath) { + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + } catch (error) { + console.error("PDF dosyası silinemedi:", error); + } + } + } +} + +module.exports = new PdfService(); diff --git a/gurubu-backend/yarn.lock b/gurubu-backend/yarn.lock index f56f5ecb..d343f355 100644 --- a/gurubu-backend/yarn.lock +++ b/gurubu-backend/yarn.lock @@ -1439,6 +1439,13 @@ resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== +"@swc/helpers@^0.5.12": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" + integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== + dependencies: + tslib "^2.8.0" + "@types/babel__core@^7.1.14": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1967,6 +1974,16 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" + integrity sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw== + +base64-js@^1.1.2, base64-js@^1.3.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + base64id@2.0.0, base64id@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" @@ -2035,6 +2052,13 @@ braces@~3.0.2: dependencies: fill-range "^7.0.1" +brotli@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48" + integrity sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg== + dependencies: + base64-js "^1.1.2" + browserslist@^4.14.5, browserslist@^4.21.9, browserslist@^4.22.1: version "4.22.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619" @@ -2192,6 +2216,11 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clone@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + cluster-key-slot@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" @@ -2342,6 +2371,11 @@ cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -2417,6 +2451,11 @@ dezalgo@^1.0.4: asap "^2.0.0" wrappy "1" +dfa@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657" + integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q== + diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -2760,6 +2799,21 @@ follow-redirects@^1.15.6: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== +fontkit@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0" + integrity sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g== + dependencies: + "@swc/helpers" "^0.5.12" + brotli "^1.3.2" + clone "^2.1.2" + dfa "^1.2.0" + fast-deep-equal "^3.1.3" + restructure "^3.0.0" + tiny-inflate "^1.0.3" + unicode-properties "^1.4.0" + unicode-trie "^2.0.0" + form-data-encoder@1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" @@ -3536,6 +3590,11 @@ jest@^29.7.0: import-local "^3.0.2" jest-cli "^29.7.0" +jpeg-exif@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/jpeg-exif/-/jpeg-exif-1.1.4.tgz#781a65b6cd74f62cb1c493511020f8d3577a1c2b" + integrity sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3599,6 +3658,14 @@ leven@^3.1.0: resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== +linebreak@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.1.0.tgz#831cf378d98bced381d8ab118f852bd50d81e46b" + integrity sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ== + dependencies: + base64-js "0.0.8" + unicode-trie "^2.0.0" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -3904,6 +3971,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@^0.2.5: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== + parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -3949,6 +4021,17 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +pdfkit@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/pdfkit/-/pdfkit-0.16.0.tgz#009a9b3844c1419809e1bcd58f36bec75e8aaa69" + integrity sha512-oXMxkIqXH4uTAtohWdYA41i/f6i2ReB78uhgizN8H4hJEpgR3/Xjy3iu2InNAuwCIabN3PVs8P1D6G4+W2NH0A== + dependencies: + crypto-js "^4.2.0" + fontkit "^2.0.4" + jpeg-exif "^1.1.4" + linebreak "^1.1.0" + png-js "^1.0.0" + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -3983,6 +4066,11 @@ pkg-dir@^7.0.0: dependencies: find-up "^6.3.0" +png-js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/png-js/-/png-js-1.0.0.tgz#e5484f1e8156996e383aceebb3789fd75df1874d" + integrity sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g== + pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" @@ -4184,6 +4272,11 @@ resolve@^1.14.2, resolve@^1.20.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +restructure@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/restructure/-/restructure-3.0.2.tgz#e6b2fad214f78edee21797fa8160fef50eb9b49a" + integrity sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw== + safe-buffer@5.2.1, safe-buffer@^5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -4560,6 +4653,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +tiny-inflate@^1.0.0, tiny-inflate@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" + integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -4594,6 +4692,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-detect@4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -4640,11 +4743,27 @@ unicode-match-property-value-ecmascript@^2.1.0: resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz#cb5fffdcd16a05124f5a4b0bf7c3770208acbbe0" integrity sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA== +unicode-properties@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/unicode-properties/-/unicode-properties-1.4.1.tgz#96a9cffb7e619a0dc7368c28da27e05fc8f9be5f" + integrity sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg== + dependencies: + base64-js "^1.3.0" + unicode-trie "^2.0.0" + unicode-property-aliases-ecmascript@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +unicode-trie@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8" + integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ== + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" diff --git a/gurubu-client/src/app/jira-pdf/components/JiraPdfDownloader.tsx b/gurubu-client/src/app/jira-pdf/components/JiraPdfDownloader.tsx new file mode 100644 index 00000000..7e10f5d9 --- /dev/null +++ b/gurubu-client/src/app/jira-pdf/components/JiraPdfDownloader.tsx @@ -0,0 +1,234 @@ +"use client"; +import React, { useState } from "react"; +import { JiraService } from "@/services/jiraService"; +import { useToast } from "@/contexts/ToastContext"; +import { useLoader } from "@/contexts/LoaderContext"; +import debounce from "lodash.debounce"; +import axios from "axios"; + +const JiraPdfDownloader: React.FC = () => { + const [boardSearch, setBoardSearch] = useState(""); + const [selectedBoard, setSelectedBoard] = useState(""); + const [boards, setBoards] = useState<{ id: string; name: string }[]>([]); + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(0); + const { showSuccessToast, showFailureToast } = useToast(); + const { setShowLoader } = useLoader(); + + const jiraService = new JiraService(process.env.NEXT_PUBLIC_API_URL || ""); + + const handleBoardSearchChange = (e: React.ChangeEvent) => { + const board = e.target.value.trim(); + setBoardSearch(board); + setSelectedBoard(""); + setBoards([]); + + if (board) { + debouncedSearchBoards(board); + } + }; + + const debouncedSearchBoards = React.useCallback( + debounce((searchQuery: string) => { + fetchBoards(searchQuery); + }, 500), + [] + ); + + const handleBoardChange = (e: React.ChangeEvent) => { + setSelectedBoard(e.target.value); + }; + + const fetchBoards = async (searchQuery: string) => { + setShowLoader(true); + try { + const response = await jiraService.searchBoards(searchQuery); + if (response.isSuccess && response.data) { + showSuccessToast( + "Jira Boards Found", + "Select a board to download the PDF report", + "top-center" + ); + setBoards(response.data); + } else { + showFailureToast( + "Search Board Error", + "Try a different board name", + "top-center" + ); + } + } catch (error) { + showFailureToast( + "Search Board Error", + "An error occurred while searching for boards", + "top-center" + ); + } finally { + setShowLoader(false); + } + }; + + const handleDownloadPdf = async () => { + if (!selectedBoard) { + showFailureToast( + "Board Required", + "Please select a board first", + "top-center" + ); + return; + } + + try { + setIsDownloading(true); + setProgress(10); + + const apiUrl = process.env.NEXT_PUBLIC_API_URL || ""; + const downloadUrl = `${apiUrl}/jira-pdf-creator/${selectedBoard}/download-pdf`; + + const response = await axios({ + url: downloadUrl, + method: 'GET', + responseType: 'blob', + onDownloadProgress: (progressEvent) => { + if (progressEvent.total) { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + setProgress(percentCompleted); + } else { + setProgress((prevProgress) => { + return prevProgress < 90 ? prevProgress + 5 : prevProgress; + }); + } + } + }); + + if (response.status === 200) { + setProgress(100); + + const blob = new Blob([response.data], { type: "application/pdf" }); + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + + const date = new Date(); + const formattedDate = date.toISOString().split("T")[0]; + const formattedTime = date.toTimeString().split(" ")[0].replace(/:/g, ""); + + const boardName = boards.find(board => board.id === selectedBoard)?.name || selectedBoard; + link.download = `jira_board_${boardName}_report_${formattedDate}_${formattedTime}.pdf`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + showSuccessToast( + "Download Complete", + "PDF report has been downloaded successfully", + "top-center" + ); + + setTimeout(() => { + window.URL.revokeObjectURL(url); + setIsDownloading(false); + setProgress(0); + }, 2000); + } + } catch (error) { + let errorMessage = "Unknown error occurred"; + if (axios.isAxiosError(error)) { + errorMessage = error.response?.data?.message || error.message; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + handleDownloadError(errorMessage); + } + }; + + const handleDownloadError = (errorMessage: string) => { + setIsDownloading(false); + setProgress(0); + showFailureToast( + "Download Failed", + `Error: ${errorMessage}. Please try again.`, + "top-center" + ); + }; + + const handleClearForm = () => { + setBoardSearch(""); + setSelectedBoard(""); + setBoards([]); + setProgress(0); + }; + + return ( +
+
+ + +
+ +
+ + +
+ + {isDownloading && ( +
+
+ Downloading PDF... + {progress}% +
+
+
+
+
+ )} + +
+ + +
+
+ ); +}; + +export default JiraPdfDownloader; \ No newline at end of file diff --git a/gurubu-client/src/app/jira-pdf/layout.tsx b/gurubu-client/src/app/jira-pdf/layout.tsx new file mode 100644 index 00000000..1d1c5a3d --- /dev/null +++ b/gurubu-client/src/app/jira-pdf/layout.tsx @@ -0,0 +1,20 @@ +import { Metadata } from "next"; +import { Suspense } from "react"; +import "@/styles/jira-pdf/style.scss"; + +export const metadata: Metadata = { + title: "GuruBu - Jira PDF Downloader", + description: "Download PDF reports from Jira boards with story point analysis", +}; + +export default function JiraPdfLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + Loading...}> +
{children}
+
+ ); +} \ No newline at end of file diff --git a/gurubu-client/src/app/jira-pdf/page.tsx b/gurubu-client/src/app/jira-pdf/page.tsx new file mode 100644 index 00000000..bb70d531 --- /dev/null +++ b/gurubu-client/src/app/jira-pdf/page.tsx @@ -0,0 +1,26 @@ +"use client"; +import { NextPage } from "next"; +import React from "react"; +import JiraPdfDownloader from "./components/JiraPdfDownloader"; +import { ToastProvider } from "@/contexts/ToastContext"; +import { LoaderProvider } from "@/contexts/LoaderContext"; + +const JiraPdfPage: NextPage = () => { + return ( +
+ + +
+

Jira Board PDF Report

+

+ Select a Jira board and download a comprehensive report of all issues with their initial and current story points. +

+ +
+
+
+
+ ); +}; + +export default JiraPdfPage; \ No newline at end of file diff --git a/gurubu-client/src/app/styles/jira-pdf/jira-pdf.scss b/gurubu-client/src/app/styles/jira-pdf/jira-pdf.scss new file mode 100644 index 00000000..ec960eac --- /dev/null +++ b/gurubu-client/src/app/styles/jira-pdf/jira-pdf.scss @@ -0,0 +1,151 @@ +.jira-pdf-page { + width: 100%; + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + min-height: 80vh; + + .jira-pdf-container { + max-width: 800px; + width: 100%; + background-color: white; + padding: 2rem; + border-radius: $radius-large; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + + h1 { + text-align: center; + margin-bottom: 1rem; + color: $gray-800; + font-weight: $bold; + } + + p { + text-align: center; + margin-bottom: 2rem; + color: $gray-600; + line-height: 1.5; + } + } + + .jira-pdf-form { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 2rem; + + .form-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + + label { + font-weight: $semibold; + color: $gray-700; + margin-bottom: 0.25rem; + font-size: $font-size-paragraph-3; + } + + input, select { + width: 100%; + padding: 10px 14px; + border: 1px solid $gray-300; + border-radius: $radius-large; + font-size: $font-size-paragraph-3; + background-color: white; + color: $gray-700; + + &:focus { + outline: none; + border-color: $primary-600; + } + + &:disabled { + background-color: $gray-100; + color: $gray-500; + cursor: not-allowed; + } + } + } + + .buttons { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1rem; + + button { + font-family: var(--font-inter); + width: 100%; + font-weight: $semibold; + font-size: $font-size-paragraph-3; + line-height: $line-height-paragraph-4; + padding: 10px 18px; + border-radius: $radius-large; + cursor: pointer; + transition: all 0.2s ease; + + &.primary { + background: $primary-600; + color: white; + border: 1px solid $primary-600; + + &:hover { + background-color: $purple-500; + } + + &:disabled { + background-color: $gray-300; + border-color: $gray-300; + color: $gray-500; + cursor: not-allowed; + } + } + + &.secondary { + background-color: $white; + border: 1px solid #cdd1d4; + color: $gray-700; + + &:hover { + background-color: $off-white; + } + + &:disabled { + background-color: $gray-100; + color: $gray-500; + cursor: not-allowed; + } + } + } + } + } + + .progress-container { + margin-top: 1rem; + width: 100%; + + .progress-label { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: $font-size-paragraph-4; + color: $gray-600; + } + + .progress-bar { + width: 100%; + height: 8px; + background-color: $gray-200; + border-radius: $radius-large; + overflow: hidden; + + .progress-fill { + height: 100%; + background-color: $primary-600; + transition: width 0.3s ease; + } + } + } +} \ No newline at end of file diff --git a/gurubu-client/src/app/styles/jira-pdf/style.scss b/gurubu-client/src/app/styles/jira-pdf/style.scss new file mode 100644 index 00000000..0a18662c --- /dev/null +++ b/gurubu-client/src/app/styles/jira-pdf/style.scss @@ -0,0 +1,3 @@ +@import "../variables.scss"; +@import "../mixins.scss"; +@import "./jira-pdf.scss";