Skip to content

Commit fcb6ce0

Browse files
add goal entity, endpoint, and admin dashboard integration (#112)
* add goal entity, endpoint, and admin dashboard integration * implement active goal feature with admin dashboard integration and UI updates --------- Co-authored-by: Thanin Kongkiatsophon <108406347+thaninbew@users.noreply.github.com>
1 parent aa17a38 commit fcb6ce0

14 files changed

Lines changed: 363 additions & 19 deletions

File tree

apps/backend/src/data-source.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
22
import { Donation } from './donations/donation.entity';
33
import { User } from './users/user.entity';
44
import * as dotenv from 'dotenv';
5+
import { Goal } from './donations/goal.entity';
56

67
dotenv.config();
78

@@ -12,7 +13,7 @@ const AppDataSource = new DataSource({
1213
username: process.env.NX_DB_USERNAME,
1314
password: process.env.NX_DB_PASSWORD,
1415
database: process.env.NX_DB_DATABASE,
15-
entities: [User, Donation],
16+
entities: [User, Donation, Goal],
1617
migrations: [],
1718
// Setting synchronize: true shouldn't be used in production - otherwise you can lose production data
1819
synchronize: true,

apps/backend/src/donations/donations.controller.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,55 @@ export class DonationsController {
142142
return stats;
143143
}
144144

145+
@Get('goal/active')
146+
@ApiOperation({
147+
summary: 'get active growing goal summary',
148+
description:
149+
'retrieve the active goal, the amount raised during that goal period, and progress percentage',
150+
})
151+
@ApiResponse({
152+
status: 200,
153+
description: 'active goal summary',
154+
schema: {
155+
type: 'object',
156+
properties: {
157+
goal: {
158+
nullable: true,
159+
type: 'object',
160+
properties: {
161+
id: { type: 'number', example: 1 },
162+
targetAmount: { type: 'number', example: 50000 },
163+
startDate: { type: 'string', example: '2026-01-01' },
164+
endDate: { type: 'string', example: '2026-06-30' },
165+
dateRangeLabel: {
166+
type: 'string',
167+
example: 'January - June 2026',
168+
},
169+
},
170+
},
171+
amountRaised: { type: 'number', example: 31336 },
172+
progressPercent: { type: 'number', example: 62.67 },
173+
},
174+
},
175+
})
176+
@ApiResponse({
177+
status: 401,
178+
description: 'unauthorized',
179+
})
180+
async getActiveGoalSummary(@Req() req: any): Promise<{
181+
goal: {
182+
id: number;
183+
targetAmount: number;
184+
startDate: string;
185+
endDate: string;
186+
dateRangeLabel: string;
187+
} | null;
188+
amountRaised: number;
189+
progressPercent: number;
190+
}> {
191+
return this.donationsService.getActiveGoalSummary();
192+
}
193+
145194
@Get('lapsed')
146195
@UseGuards(AuthGuard('jwt'))
147196
@ApiBearerAuth()

apps/backend/src/donations/donations.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import { User } from '../users/user.entity';
88
import { AuthService } from '../auth/auth.service';
99
import { UsersService } from '../users/users.service';
1010
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';
11+
import { Goal } from './goal.entity';
1112

1213
@Module({
13-
imports: [TypeOrmModule.forFeature([Donation, User])],
14+
imports: [TypeOrmModule.forFeature([Donation, Goal, User])],
1415
controllers: [DonationsController],
1516
providers: [
1617
DonationsService,

apps/backend/src/donations/donations.service.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
1111
import { CreateDonationRequest, Donation as DomainDonation } from './mappers';
1212
import { Readable } from 'stream';
1313
import { DonationsRepository } from './donations.repository';
14+
import { Goal } from './goal.entity';
1415

1516
interface PaymentIntentSyncPayload {
1617
donationId?: number;
@@ -32,6 +33,10 @@ export class DonationsService {
3233
constructor(
3334
@InjectRepository(Donation)
3435
private donationRepository: Repository<Donation>,
36+
37+
@InjectRepository(Goal)
38+
private goalRepository: Repository<Goal>,
39+
3540
private readonly donationsRepository: DonationsRepository,
3641
) {}
3742

@@ -342,4 +347,83 @@ export class DonationsService {
342347

343348
return stream;
344349
}
350+
351+
async getActiveGoalSummary() {
352+
// --- TEMPORARY MOCK FOR TESTING ---
353+
return {
354+
goal: {
355+
id: 999,
356+
targetAmount: 50000,
357+
startDate: '2026-01-01',
358+
endDate: '2026-06-30',
359+
dateRangeLabel: 'January - June 2026',
360+
},
361+
amountRaised: 31336,
362+
progressPercent: 62.67,
363+
};
364+
/*
365+
const today = new Date().toISOString().split('T')[0];
366+
367+
// 1. find active goal
368+
const goal = await this.goalRepository
369+
.createQueryBuilder('goal')
370+
.where(':today BETWEEN goal.startDate AND goal.endDate', { today })
371+
.orderBy('goal.startDate', 'DESC')
372+
.getOne();
373+
374+
if (!goal) {
375+
return {
376+
goal: null,
377+
amountRaised: 0,
378+
progressPercent: 0,
379+
};
380+
}
381+
382+
// 2. sum donations in goal period
383+
const result = await this.donationRepository
384+
.createQueryBuilder('donation')
385+
.select('COALESCE(SUM(donation.amount), 0)', 'amount')
386+
.where('donation.status = :status', { status: DonationStatus.SUCCEEDED })
387+
.andWhere('donation.createdAt >= :startDate', {
388+
startDate: goal.startDate,
389+
})
390+
.andWhere('donation.createdAt <= :endDate', {
391+
endDate: `${goal.endDate} 23:59:59`,
392+
})
393+
.getRawOne<{ amount: string }>();
394+
395+
const amountRaised = Number(result?.amount ?? 0);
396+
397+
const progressPercent =
398+
goal.targetAmount > 0
399+
? Math.min((amountRaised / goal.targetAmount) * 100, 100)
400+
: 0;
401+
402+
return {
403+
goal: {
404+
id: goal.id,
405+
targetAmount: goal.targetAmount,
406+
startDate: goal.startDate,
407+
endDate: goal.endDate,
408+
dateRangeLabel: this.formatDateRange(goal.startDate, goal.endDate),
409+
},
410+
amountRaised,
411+
progressPercent,
412+
};
413+
*/
414+
}
415+
416+
private formatDateRange(start: string, end: string): string {
417+
const startDate = new Date(start);
418+
const endDate = new Date(end);
419+
420+
const startMonth = startDate.toLocaleString('en-US', { month: 'long' });
421+
const endMonth = endDate.toLocaleString('en-US', { month: 'long' });
422+
423+
if (startDate.getFullYear() === endDate.getFullYear()) {
424+
return `${startMonth} - ${endMonth} ${startDate.getFullYear()}`;
425+
}
426+
427+
return `${startMonth} ${startDate.getFullYear()} - ${endMonth} ${endDate.getFullYear()}`;
428+
}
345429
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
} from 'typeorm';
8+
9+
@Entity('goals')
10+
export class Goal {
11+
@PrimaryGeneratedColumn('identity', {
12+
generatedIdentity: 'ALWAYS',
13+
})
14+
id: number;
15+
16+
@Column({ type: 'int' })
17+
targetAmount: number;
18+
19+
@Column({ type: 'date' })
20+
startDate: string;
21+
22+
@Column({ type: 'date' })
23+
endDate: string;
24+
25+
@CreateDateColumn({ type: 'timestamp', default: () => 'now()' })
26+
createdAt: Date;
27+
28+
@UpdateDateColumn({ type: 'timestamp', default: () => 'now()' })
29+
updatedAt: Date;
30+
}

apps/frontend/src/api/apiClient.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ export type DonationStatsResponse = {
2222
monthToDate: number;
2323
};
2424

25+
export type ActiveGoalResponse = {
26+
goal: {
27+
id: number;
28+
targetAmount: number;
29+
startDate: string;
30+
endDate: string;
31+
dateRangeLabel: string;
32+
} | null;
33+
amountRaised: number;
34+
progressPercent: number;
35+
};
36+
2537
export type SignInRequest = { email: string; password: string };
2638
export type SignUpRequest = {
2739
firstName: string;
@@ -87,6 +99,15 @@ export class ApiClient {
8799
}
88100
}
89101

102+
public async getActiveGoalSummary(): Promise<ActiveGoalResponse> {
103+
try {
104+
const res = await this.axiosInstance.get('/api/donations/goal/active');
105+
return res.data;
106+
} catch (err: unknown) {
107+
this.handleAxiosError(err, 'Failed to fetch active goal');
108+
}
109+
}
110+
90111
private handleAxiosError(err: unknown, defaultMsg: string): never {
91112
if (axios.isAxiosError<ApiError>(err)) {
92113
const data = err.response?.data;

apps/frontend/src/app.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ConfirmRegisteredPage } from '@containers/auth/ConfirmRegisteredPage';
1616
import { DashboardPage } from '@containers/dashboard/DashboardPage';
1717
import { DonorStatsChart } from '@components/DonorStatsChart';
1818
import SidebarTester from '@containers/dashboard/sidebar/SidebarTester';
19+
import { AdminGrowingGoalTester } from '@containers/dashboard/AdminGrowingGoalTester';
1920

2021
const router = createBrowserRouter([
2122
{
@@ -57,6 +58,10 @@ const router = createBrowserRouter([
5758
path: '/shadcn-example',
5859
element: <ShadcnExample />,
5960
},
61+
{
62+
path: '/admin-growing-goal-test',
63+
element: <AdminGrowingGoalTester />,
64+
},
6065
{
6166
path: '/chart',
6267
element: <AdminRoute />,

apps/frontend/src/components/GrowingGoal/GrowingGoal.module.css

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
display: flex;
55
flex-direction: column;
66
border-radius: 3%;
7-
border: 2% solid #cecece;
7+
border: 1px solid #cecece;
88
background: #f2f2f2;
99
text-align: center;
1010
justify-content: start;
1111
container-type: inline-size;
1212
gap: 4%;
13+
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
14+
font-family: 'Source Sans Pro', sans-serif;
1315
}
1416

1517
.description-label {
@@ -21,12 +23,12 @@
2123
justify-content: center;
2224
flex-shrink: 0;
2325
color: #fff;
24-
font-family: Helvetica;
26+
font-family: 'Source Sans Pro', sans-serif;
2527
font-weight: 700;
2628
white-space: nowrap;
2729
height: 12%;
2830
padding: 2%;
29-
background-color: #650D77;
31+
background-color: #3d3e6e;
3032
font-size: 5cqw;
3133
}
3234

@@ -47,12 +49,13 @@
4749
justify-content: center;
4850
align-items: center;
4951
background: #cecece;
52+
box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 4px 8px rgba(0, 0, 0, 0.1);
5053
}
5154

5255
.total-donation-label {
5356
color: #000;
5457
text-align: center;
55-
font-family: Helvetica;
58+
font-family: 'Source Sans Pro', sans-serif;
5659
font-size: 6cqw;
5760
font-weight: 400;
5861
}
@@ -65,6 +68,7 @@
6568
align-items: center;
6669
justify-content: center;
6770
align-self: center;
71+
font-family: 'Source Sans Pro', sans-serif;
6872
}
6973

7074
.sample-donor-profile {
@@ -92,7 +96,7 @@
9296

9397
.sample-donor-amount {
9498
color: #fff;
95-
font-family: Helvetica;
99+
font-family: 'Source Sans Pro', sans-serif;
96100
font-size: 4cqw;
97101
overflow: hidden;
98102
}
@@ -106,6 +110,7 @@
106110
aspect-ratio: 1 / 1;
107111
border-radius: 50%;
108112
background: #ffffff;
113+
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2);
109114
}
110115

111116
.growth-container-solid-grey-inner {
@@ -117,6 +122,7 @@
117122
aspect-ratio: 1 / 1;
118123
border-radius: 50%;
119124
background: #55565a;
125+
box-shadow: inset 0px 2px 5px rgba(0, 0, 0, 0.3);
120126
}
121127

122128
.growth-container-solid-teal {
@@ -135,6 +141,7 @@
135141
width: 100%;
136142
height: 100%;
137143
border-radius: 50%;
144+
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
138145
background: conic-gradient(
139146
from 180deg at 50% 50%,
140147
#c6be3b 0deg,

0 commit comments

Comments
 (0)