Skip to content

Commit 2ae9e56

Browse files
committed
Add Project Report Generator and Flowboard SVG assets
- Implemented ProjectReportGenerator component for generating PDF reports with project analytics. - Added flowboard logo SVG asset for use in reports. - Created a basic MemberProfile component as a placeholder for future development.
1 parent 57bcc1d commit 2ae9e56

18 files changed

Lines changed: 1147 additions & 426 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@fluentui/react-datepicker-compat": "^0.6.20",
2020
"axios": "^1.13.2",
2121
"browser-image-compression": "^2.0.2",
22+
"jspdf": "^3.0.4",
2223
"react": "19",
2324
"react-dom": "19",
2425
"react-hook-form": "^7.66.0",

src/assets/flowboard.svg

Lines changed: 225 additions & 0 deletions
Loading
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { Button } from '@fluentui/react-components';
2+
import { DocumentPdfRegular } from '@fluentui/react-icons';
3+
import jsPDF from 'jspdf';
4+
import flowboardLogoRaw from '../../assets/flowboard.svg?raw';
5+
import type { ProjectStats } from '../apis/analytics';
6+
import type { ProjectMember } from '../apis/projects';
7+
8+
interface ProjectReportGeneratorProps {
9+
projectStats: ProjectStats;
10+
projectMembers?: ProjectMember[];
11+
projectOwnerId?: string;
12+
appearance?: 'primary' | 'secondary' | 'outline';
13+
}
14+
15+
const formatMemberName = (member: ProjectMember) => {
16+
const pieces = [member.firstName, member.middleName, member.lastName].filter(Boolean);
17+
return pieces.join(' ');
18+
};
19+
20+
const encodeSvgToDataUrl = (svg: string) => `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`;
21+
22+
const captureLogoPng = async (): Promise<string | null> => {
23+
if (typeof window === 'undefined' || typeof document === 'undefined') {
24+
return null;
25+
}
26+
27+
return new Promise((resolve) => {
28+
const img = new Image();
29+
img.onload = () => {
30+
const canvas = document.createElement('canvas');
31+
const ratio = img.width && img.height ? img.height / img.width : 1;
32+
const width = 90;
33+
canvas.width = width;
34+
canvas.height = width * ratio;
35+
const ctx = canvas.getContext('2d');
36+
if (!ctx) {
37+
resolve(null);
38+
return;
39+
}
40+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
41+
resolve(canvas.toDataURL('image/png'));
42+
};
43+
img.onerror = () => resolve(null);
44+
img.src = encodeSvgToDataUrl(flowboardLogoRaw);
45+
});
46+
};
47+
48+
export default function ProjectReportGenerator({
49+
projectStats,
50+
projectMembers = [],
51+
projectOwnerId,
52+
appearance = 'primary',
53+
}: ProjectReportGeneratorProps) {
54+
const handleGenerate = async () => {
55+
const doc = new jsPDF('portrait', 'mm', 'a4');
56+
const pageWidth = doc.internal.pageSize.getWidth();
57+
const pageHeight = doc.internal.pageSize.getHeight();
58+
const margin = 15;
59+
let yPos = margin;
60+
61+
// Compact header bar
62+
doc.setFillColor(3, 66, 128);
63+
doc.rect(0, 0, pageWidth, 28, 'F');
64+
65+
const logoData = await captureLogoPng();
66+
if (logoData) {
67+
doc.addImage(logoData, 'PNG', margin, 4, 20, 20);
68+
}
69+
70+
doc.setTextColor(255, 255, 255);
71+
doc.setFontSize(16);
72+
doc.setFont('helvetica', 'bold');
73+
doc.text('FLOWBOARD', margin + 25, 12);
74+
75+
doc.setFontSize(8);
76+
doc.setFont('helvetica', 'normal');
77+
doc.text('Project Analytics Report', margin + 25, 18);
78+
79+
yPos = 35;
80+
81+
doc.setTextColor(0, 0, 0);
82+
doc.setFontSize(14);
83+
doc.setFont('helvetica', 'bold');
84+
doc.text(projectStats.projectName, margin, yPos);
85+
yPos += 6;
86+
87+
doc.setFontSize(7);
88+
doc.setFont('helvetica', 'normal');
89+
doc.text(
90+
`Generated: ${new Date().toLocaleString('en-US', {
91+
month: 'short',
92+
day: 'numeric',
93+
year: 'numeric',
94+
hour: '2-digit',
95+
minute: '2-digit',
96+
})}`,
97+
margin,
98+
yPos
99+
);
100+
yPos += 8;
101+
102+
const owner =
103+
projectMembers.find(
104+
(member) =>
105+
member.role?.toLowerCase() === 'owner' ||
106+
member.role?.toLowerCase() === 'project manager' ||
107+
member.role?.toLowerCase() === 'manager'
108+
) || projectMembers.find((member) => member.id === projectOwnerId);
109+
110+
doc.setFontSize(8);
111+
doc.setFont('helvetica', 'bold');
112+
doc.text('Owner:', margin, yPos);
113+
doc.setFont('helvetica', 'normal');
114+
const ownerName = owner ? formatMemberName(owner) : '—';
115+
doc.text(ownerName, margin + 18, yPos);
116+
117+
doc.setFont('helvetica', 'bold');
118+
doc.text('Members:', margin + 80, yPos);
119+
doc.setFont('helvetica', 'normal');
120+
doc.text(`${projectMembers.length}`, margin + 100, yPos);
121+
yPos += 7;
122+
123+
const statsHighlight = [
124+
{ label: 'Team Members', value: projectStats.memberCount.toString() },
125+
{ label: 'Main Tasks', value: projectStats.mainTaskCount.toString() },
126+
{ label: 'Total Tasks', value: projectStats.subTaskCount.toString() },
127+
{ label: 'Completion Rate', value: `${Math.round(projectStats.completionRate * 100)}%` },
128+
];
129+
130+
doc.setFontSize(11);
131+
doc.setFont('helvetica', 'bold');
132+
doc.text('Project Overview', margin, yPos);
133+
yPos += 5;
134+
doc.setDrawColor(220, 220, 220);
135+
doc.line(margin, yPos, pageWidth - margin, yPos);
136+
yPos += 6;
137+
138+
doc.setFont('helvetica', 'normal');
139+
const colWidth = (pageWidth - 2 * margin) / 2;
140+
statsHighlight.forEach((stat, index) => {
141+
const col = index % 2;
142+
const row = Math.floor(index / 2);
143+
const valueY = yPos + row * 12;
144+
const xPos = margin + col * colWidth;
145+
doc.setFontSize(7);
146+
doc.setTextColor(120, 120, 120);
147+
doc.text(stat.label, xPos, valueY);
148+
doc.setFont('helvetica', 'bold');
149+
doc.setFontSize(12);
150+
doc.setTextColor(3, 66, 128);
151+
doc.text(stat.value, xPos, valueY + 5);
152+
doc.setTextColor(0, 0, 0);
153+
});
154+
yPos += 28;
155+
156+
doc.setFontSize(11);
157+
doc.setFont('helvetica', 'bold');
158+
doc.text('Task Status Snapshot', margin, yPos);
159+
yPos += 5;
160+
doc.setDrawColor(220, 220, 220);
161+
doc.line(margin, yPos, pageWidth - margin, yPos);
162+
yPos += 5;
163+
164+
const statusBuckets = [
165+
{ label: 'Completed', value: projectStats.completedSubTasks, color: [76, 175, 80] },
166+
{ label: 'Remaining', value: projectStats.subTaskCount - projectStats.completedSubTasks, color: [255, 152, 0] },
167+
{ label: 'Overdue', value: projectStats.overdueSubTasks, color: [244, 67, 54] },
168+
];
169+
170+
statusBuckets.forEach((bucket) => {
171+
const colorTuple: [number, number, number] = [bucket.color[0], bucket.color[1], bucket.color[2]];
172+
doc.setFillColor(...colorTuple);
173+
doc.circle(margin + 3, yPos - 1, 1.5, 'F');
174+
doc.setFontSize(8);
175+
doc.setFont('helvetica', 'bold');
176+
doc.text(bucket.label, margin + 7, yPos);
177+
doc.setFont('helvetica', 'normal');
178+
doc.text(`${bucket.value}`, pageWidth - margin - 15, yPos);
179+
yPos += 5;
180+
});
181+
yPos += 4;
182+
183+
if (Object.keys(projectStats.tasksByStatus).length > 0) {
184+
doc.setFont('helvetica', 'bold');
185+
doc.setFontSize(11);
186+
doc.text('Task Distribution by Status', margin, yPos);
187+
yPos += 5;
188+
doc.setDrawColor(220, 220, 220);
189+
doc.line(margin, yPos, pageWidth - margin, yPos);
190+
yPos += 5;
191+
192+
let total = 0;
193+
Object.values(projectStats.tasksByStatus).forEach((value) => (total += value));
194+
const maxBarWidth = 60;
195+
196+
Object.entries(projectStats.tasksByStatus).forEach(([status, count]) => {
197+
const currentY = yPos;
198+
doc.setFont('helvetica', 'normal');
199+
doc.setFontSize(8);
200+
doc.text(status, margin + 4, currentY);
201+
doc.setFont('helvetica', 'bold');
202+
doc.text(count.toString(), pageWidth - margin - 12, currentY);
203+
204+
const ratio = total === 0 ? 0 : count / total;
205+
doc.setFillColor(230, 230, 230);
206+
doc.rect(pageWidth - margin - 70, currentY - 3, maxBarWidth, 3, 'F');
207+
doc.setFillColor(3, 66, 128);
208+
doc.rect(pageWidth - margin - 70, currentY - 3, maxBarWidth * ratio, 3, 'F');
209+
yPos += 6;
210+
});
211+
yPos += 3;
212+
}
213+
214+
if (projectStats.tasksByPriority && Object.keys(projectStats.tasksByPriority).length > 0) {
215+
doc.setFont('helvetica', 'bold');
216+
doc.setFontSize(11);
217+
doc.text('Task Distribution by Priority', margin, yPos);
218+
yPos += 5;
219+
doc.setDrawColor(220, 220, 220);
220+
doc.line(margin, yPos, pageWidth - margin, yPos);
221+
yPos += 5;
222+
223+
const halfWidth = (pageWidth - 2 * margin) / 2;
224+
let col = 0;
225+
let rowY = yPos;
226+
227+
Object.entries(projectStats.tasksByPriority).forEach(([priority, count]) => {
228+
const xPos = margin + 4 + col * halfWidth;
229+
doc.setFont('helvetica', 'normal');
230+
doc.setFontSize(8);
231+
doc.text(`${priority.charAt(0).toUpperCase() + priority.slice(1)}`, xPos, rowY);
232+
doc.setFont('helvetica', 'bold');
233+
doc.text(count.toString(), xPos + 40, rowY);
234+
235+
col++;
236+
if (col >= 2) {
237+
col = 0;
238+
rowY += 5;
239+
}
240+
});
241+
yPos = rowY + (col > 0 ? 5 : 0) + 3;
242+
}
243+
244+
if (projectStats.tasksByCategory.length > 0) {
245+
doc.setFont('helvetica', 'bold');
246+
doc.setFontSize(11);
247+
doc.text('Task Distribution by Category', margin, yPos);
248+
yPos += 5;
249+
doc.setDrawColor(220, 220, 220);
250+
doc.line(margin, yPos, pageWidth - margin, yPos);
251+
yPos += 5;
252+
253+
const halfWidth = (pageWidth - 2 * margin) / 2;
254+
let col = 0;
255+
let rowY = yPos;
256+
257+
projectStats.tasksByCategory.slice(0, 10).forEach((cat) => {
258+
const xPos = margin + 4 + col * halfWidth;
259+
doc.setFont('helvetica', 'normal');
260+
doc.setFontSize(8);
261+
const catName = cat.categoryName.length > 20 ? cat.categoryName.substring(0, 18) + '...' : cat.categoryName;
262+
doc.text(catName, xPos, rowY);
263+
doc.setFont('helvetica', 'bold');
264+
doc.text(cat.totalTasks.toString(), xPos + 55, rowY);
265+
266+
col++;
267+
if (col >= 2) {
268+
col = 0;
269+
rowY += 5;
270+
}
271+
});
272+
yPos = rowY + (col > 0 ? 5 : 0);
273+
}
274+
275+
const totalPages = doc.getNumberOfPages();
276+
for (let i = 1; i <= totalPages; i++) {
277+
doc.setPage(i);
278+
doc.setFontSize(7);
279+
doc.setTextColor(120, 120, 120);
280+
doc.text('Generated by Flowboard Project Management System', pageWidth / 2, pageHeight - 8, {
281+
align: 'center',
282+
});
283+
}
284+
285+
doc.save(`${projectStats.projectName.replace(/\s+/g, '_')}_Analytics_Report.pdf`);
286+
};
287+
288+
return (
289+
<Button appearance={appearance} icon={<DocumentPdfRegular />} onClick={() => void handleGenerate()}>
290+
Generate PDF Report
291+
</Button>
292+
);
293+
}

src/components/apis/maintasks.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export interface UpdateMainTaskData {
22
title: string;
33
description?: string;
4+
startDate?: string | null;
5+
endDate?: string | null;
46
}
57
import axiosInstance from './axiosInstance';
68
import type { SubTaskResponse } from './subtasks';
@@ -9,6 +11,8 @@ export interface CreateMainTaskData {
911
title: string;
1012
description?: string;
1113
projectId?: string;
14+
startDate?: string | null;
15+
endDate?: string | null;
1216
}
1317

1418
export interface MainTaskResponse {
@@ -18,6 +22,8 @@ export interface MainTaskResponse {
1822
projectId?: string;
1923
createdAt?: string;
2024
subTaskIds?: string[];
25+
startDate?: string | null;
26+
endDate?: string | null;
2127
}
2228

2329
export const mainTasksApi = {

src/components/apis/subtasks.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,15 @@ export const subTasksApi = {
160160
return response.data;
161161
},
162162

163+
/**
164+
* Update category of a subtask
165+
* Backend path: PATCH /api/subtasks/{id}/category
166+
*/
167+
updateCategory: async (subTaskId: string, categoryData: { categoryId?: string; category?: string }): Promise<{ message: string }> => {
168+
const response = await axiosInstance.patch<{ message: string }>(`/api/subtasks/${subTaskId}/category`, categoryData);
169+
return response.data;
170+
},
171+
163172
/**
164173
* Delete a subtask
165174
* Backend path: DELETE /api/subtasks/{id}

src/components/auth/Register.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ export default function Register() {
495495
<Input
496496
id="middleName"
497497
type="text"
498-
placeholder="A"
498+
placeholder="Austin"
499499
autoComplete="additional-name"
500500
{...register('middleName', {
501501
required: 'Middle name is required',
@@ -812,7 +812,6 @@ export default function Register() {
812812
<Input
813813
id="userName"
814814
type="text"
815-
placeholder="jdoe"
816815
autoComplete="username"
817816
{...register('userName', {
818817
required: 'Username is required',
@@ -854,7 +853,6 @@ export default function Register() {
854853
<Input
855854
id="password"
856855
type="password"
857-
placeholder="P@ssw0rd!"
858856
autoComplete="new-password"
859857
{...register('password', {
860858
required: 'Password is required',
@@ -924,7 +922,7 @@ export default function Register() {
924922
)}
925923

926924
{/* Navigation Buttons */}
927-
<div className={styles.actionsRight} style={{ marginTop: '24px' }}>
925+
<div className={styles.actionsRight} style={{ marginTop: '24px', marginInline: '24px' }}>
928926
{currentStep === 1 ? (
929927
<Button
930928
appearance="secondary"

0 commit comments

Comments
 (0)