Skip to content

Commit b883fa4

Browse files
Growing goal admin dashboard design (#118)
* Add Edit Donation Goal UI with reset functionality and tester route * Refine spacing * feat: add goal update functionality with admin interface and validation * fix: remove unused subMessage prop from GrowingGoal component * Default edit field & admin growing goal title * reflect currently raised instead of hardcode in editgoal * MM-DD-YYYY format via helper for frontend to conform w/ design, backend still uses YYYY-MM-DD --------- Co-authored-by: Thanin Kongkiatsophon <108406347+thaninbew@users.noreply.github.com>
1 parent fcb6ce0 commit b883fa4

14 files changed

Lines changed: 535 additions & 67 deletions

File tree

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {
22
Controller,
33
Get,
44
Post,
5+
Patch,
6+
Param,
57
Body,
68
Query,
79
ParseIntPipe,
@@ -25,9 +27,12 @@ import {
2527
import { AuthGuard } from '@nestjs/passport';
2628
import { DonationsService } from './donations.service';
2729
import { DonationsRepository, PaginationFilters } from './donations.repository';
28-
import { CreateDonationDto } from './dtos/create-donation-dto';
29-
import { DonationResponseDto } from './dtos/donation-response-dto';
30-
import { PublicDonationDto } from './dtos/public-donation-dto';
30+
import {
31+
CreateDonationDto,
32+
DonationResponseDto,
33+
PublicDonationDto,
34+
UpdateGoalDto,
35+
} from './dtos';
3136
import { DonationMappers } from './mappers';
3237
import {
3338
DonationType,
@@ -36,6 +41,7 @@ import {
3641
} from './donation.entity';
3742
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';
3843
import { Status } from '../users/types';
44+
import { Goal } from './goal.entity';
3945

4046
@ApiTags('Donations')
4147
@Controller('donations')
@@ -191,6 +197,31 @@ export class DonationsController {
191197
return this.donationsService.getActiveGoalSummary();
192198
}
193199

200+
@Patch('goal/:id?')
201+
@UseGuards(AuthGuard('jwt'))
202+
@ApiBearerAuth()
203+
@ApiOperation({
204+
summary: 'update an existing goal (admin)',
205+
description:
206+
'update the details of a specific donation goal. If no ID is provided, the current active goal is updated. Requires authentication.',
207+
})
208+
@ApiResponse({
209+
status: 200,
210+
description: 'goal successfully updated',
211+
type: Goal,
212+
})
213+
@ApiResponse({
214+
status: 404,
215+
description: 'goal not found',
216+
})
217+
async updateGoal(
218+
@Param('id', new ParseIntPipe({ optional: true })) id: number | null,
219+
@Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
220+
updateGoalDto: UpdateGoalDto,
221+
): Promise<Goal> {
222+
return this.donationsService.updateGoal(id, updateGoalDto);
223+
}
224+
194225
@Get('lapsed')
195226
@UseGuards(AuthGuard('jwt'))
196227
@ApiBearerAuth()

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

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CreateDonationRequest, Donation as DomainDonation } from './mappers';
1212
import { Readable } from 'stream';
1313
import { DonationsRepository } from './donations.repository';
1414
import { Goal } from './goal.entity';
15+
import { UpdateGoalDto } from './dtos';
1516

1617
interface PaymentIntentSyncPayload {
1718
donationId?: number;
@@ -348,26 +349,27 @@ export class DonationsService {
348349
return stream;
349350
}
350351

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-
/*
352+
async getActiveGoalSummary(): Promise<{
353+
goal: {
354+
id: number;
355+
title: string;
356+
targetAmount: number;
357+
startDate: string;
358+
endDate: string;
359+
dateRangeLabel: string;
360+
} | null;
361+
amountRaised: number;
362+
progressPercent: number;
363+
}> {
365364
const today = new Date().toISOString().split('T')[0];
366-
365+
367366
// 1. find active goal
368367
const goal = await this.goalRepository
369368
.createQueryBuilder('goal')
370-
.where(':today BETWEEN goal.startDate AND goal.endDate', { today })
369+
.where(
370+
'(:today BETWEEN goal.startDate AND goal.endDate) OR (goal.startDate <= :today AND goal.endDate IS NULL) OR (goal.startDate IS NULL AND goal.endDate >= :today) OR (goal.startDate IS NULL AND goal.endDate IS NULL)',
371+
{ today },
372+
)
371373
.orderBy('goal.startDate', 'DESC')
372374
.getOne();
373375

@@ -384,10 +386,10 @@ export class DonationsService {
384386
.createQueryBuilder('donation')
385387
.select('COALESCE(SUM(donation.amount), 0)', 'amount')
386388
.where('donation.status = :status', { status: DonationStatus.SUCCEEDED })
387-
.andWhere('donation.createdAt >= :startDate', {
389+
.andWhere(goal.startDate ? 'donation.createdAt >= :startDate' : '1=1', {
388390
startDate: goal.startDate,
389391
})
390-
.andWhere('donation.createdAt <= :endDate', {
392+
.andWhere(goal.endDate ? 'donation.createdAt <= :endDate' : '1=1', {
391393
endDate: `${goal.endDate} 23:59:59`,
392394
})
393395
.getRawOne<{ amount: string }>();
@@ -402,20 +404,77 @@ export class DonationsService {
402404
return {
403405
goal: {
404406
id: goal.id,
407+
title: goal.title ?? '',
405408
targetAmount: goal.targetAmount,
406-
startDate: goal.startDate,
407-
endDate: goal.endDate,
408-
dateRangeLabel: this.formatDateRange(goal.startDate, goal.endDate),
409+
startDate: goal.startDate ?? '',
410+
endDate: goal.endDate ?? '',
411+
dateRangeLabel: this.formatDateRange(
412+
goal.startDate ?? null,
413+
goal.endDate ?? null,
414+
),
409415
},
410416
amountRaised,
411417
progressPercent,
412418
};
413-
*/
414419
}
415420

416-
private formatDateRange(start: string, end: string): string {
417-
const startDate = new Date(start);
418-
const endDate = new Date(end);
421+
async updateGoal(
422+
id: number | null,
423+
updateGoalDto: UpdateGoalDto,
424+
): Promise<Goal> {
425+
let goal: Goal | null;
426+
427+
if (id) {
428+
goal = await this.goalRepository.findOneBy({ id });
429+
} else {
430+
const today = new Date().toISOString().split('T')[0];
431+
goal = await this.goalRepository
432+
.createQueryBuilder('goal')
433+
.where(':today BETWEEN goal.startDate AND goal.endDate', { today })
434+
.orderBy('goal.startDate', 'DESC')
435+
.getOne();
436+
}
437+
438+
if (!goal) {
439+
return this.createGoal(updateGoalDto);
440+
}
441+
442+
Object.assign(goal, updateGoalDto);
443+
return this.goalRepository.save(goal);
444+
}
445+
446+
async createGoal(updateGoalDto: UpdateGoalDto): Promise<Goal> {
447+
const goal = this.goalRepository.create({
448+
...updateGoalDto,
449+
});
450+
return this.goalRepository.save(goal);
451+
}
452+
453+
private formatDateRange(start: string | null, end: string | null): string {
454+
if (!start && !end) return '';
455+
456+
const options: Intl.DateTimeFormatOptions = {
457+
month: 'long',
458+
day: 'numeric',
459+
year: 'numeric',
460+
};
461+
462+
if (start && !end) {
463+
const startDate = new Date(start);
464+
return `Started ${startDate.toLocaleDateString('en-US', options)}`;
465+
}
466+
467+
if (!start && end) {
468+
const endDate = new Date(end);
469+
return `By ${endDate.toLocaleDateString('en-US', options)}`;
470+
}
471+
472+
if (!start && !end) {
473+
return 'Ongoing';
474+
}
475+
476+
const startDate = new Date(start as string);
477+
const endDate = new Date(end as string);
419478

420479
const startMonth = startDate.toLocaleString('en-US', { month: 'long' });
421480
const endMonth = endDate.toLocaleString('en-US', { month: 'long' });
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './create-donation-dto';
22
export * from './donation-response-dto';
33
export * from './public-donation-dto';
4+
export * from './update-goal-dto';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsInt, IsString, Min, IsOptional } from 'class-validator';
3+
4+
export class UpdateGoalDto {
5+
@ApiProperty({ example: 50000 })
6+
@IsInt()
7+
@Min(0)
8+
targetAmount!: number;
9+
10+
@ApiProperty({ example: 'Summer Goal' })
11+
@IsString()
12+
@IsOptional()
13+
title?: string;
14+
15+
@ApiProperty({ example: '2026-01-01' })
16+
@IsString()
17+
@IsOptional()
18+
startDate?: string;
19+
20+
@ApiProperty({ example: '2026-06-30' })
21+
@IsString()
22+
@IsOptional()
23+
endDate?: string;
24+
}

apps/backend/src/donations/goal.entity.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,23 @@ export class Goal {
1111
@PrimaryGeneratedColumn('identity', {
1212
generatedIdentity: 'ALWAYS',
1313
})
14-
id: number;
14+
id!: number;
1515

1616
@Column({ type: 'int' })
17-
targetAmount: number;
17+
targetAmount!: number;
1818

19-
@Column({ type: 'date' })
20-
startDate: string;
19+
@Column({ type: 'text', nullable: true })
20+
title?: string;
2121

22-
@Column({ type: 'date' })
23-
endDate: string;
22+
@Column({ type: 'date', nullable: true })
23+
startDate?: string;
24+
25+
@Column({ type: 'date', nullable: true })
26+
endDate?: string;
2427

2528
@CreateDateColumn({ type: 'timestamp', default: () => 'now()' })
26-
createdAt: Date;
29+
createdAt!: Date;
2730

2831
@UpdateDateColumn({ type: 'timestamp', default: () => 'now()' })
29-
updatedAt: Date;
32+
updatedAt!: Date;
3033
}

apps/backend/src/main.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ async function bootstrap() {
1313
const app = await NestFactory.create(AppModule, {
1414
rawBody: true,
1515
});
16-
app.enableCors();
16+
app.enableCors({
17+
origin: 'http://localhost:4200',
18+
credentials: true,
19+
});
1720

1821
const globalPrefix = 'api';
1922
app.setGlobalPrefix(globalPrefix);

apps/frontend/src/api/apiClient.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type DonationStatsResponse = {
2525
export type ActiveGoalResponse = {
2626
goal: {
2727
id: number;
28+
title: string;
2829
targetAmount: number;
2930
startDate: string;
3031
endDate: string;
@@ -34,6 +35,13 @@ export type ActiveGoalResponse = {
3435
progressPercent: number;
3536
};
3637

38+
export type UpdateGoalRequest = {
39+
title?: string;
40+
targetAmount: number;
41+
startDate: string;
42+
endDate: string;
43+
};
44+
3745
export type SignInRequest = { email: string; password: string };
3846
export type SignUpRequest = {
3947
firstName: string;
@@ -108,6 +116,18 @@ export class ApiClient {
108116
}
109117
}
110118

119+
public async updateGoal(
120+
id: number | null,
121+
body: UpdateGoalRequest,
122+
): Promise<void> {
123+
try {
124+
const url = id ? `/api/donations/goal/${id}` : '/api/donations/goal';
125+
await this.axiosInstance.patch(url, body);
126+
} catch (err: unknown) {
127+
this.handleAxiosError(err, 'Failed to update goal');
128+
}
129+
}
130+
111131
private handleAxiosError(err: unknown, defaultMsg: string): never {
112132
if (axios.isAxiosError<ApiError>(err)) {
113133
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 EditDonationGoalTester from '@components/DonationGoal/EditDonationGoalTester';
1920
import { AdminGrowingGoalTester } from '@containers/dashboard/AdminGrowingGoalTester';
2021

2122
const router = createBrowserRouter([
@@ -50,6 +51,10 @@ const router = createBrowserRouter([
5051
path: '/sidebar-test',
5152
element: <SidebarTester />,
5253
},
54+
{
55+
path: '/edit-donation-goal-test',
56+
element: <EditDonationGoalTester />,
57+
},
5358
{
5459
path: '/test',
5560
element: <TestimonialTester />,

0 commit comments

Comments
 (0)