| | 1 | == Имплементација на случаи на употреба |
| | 2 | Заклучно со оваа фаза се имплементирани сите предвидени кориснички сценарија, односно: |
| | 3 | |
| | 4 | [[Image(p.png)]] |
| | 5 | |
| | 6 | == Ненајавен корисник |
| | 7 | = ИД 1 - Регистрација на корисник |
| | 8 | Корисникот се наоѓа на home страницата и го кликнува копчето за регистрација на корисник. |
| | 9 | |
| | 10 | [[Image(r1.png)]] |
| | 11 | |
| | 12 | Притоа, корисникот е пренасочен кон /sign-up каде што внесува потребни информации за регистрација (име, презиме, адреса, град, држава, поштенски код, SSN, датум на раѓање, email, лозинка). |
| | 13 | |
| | 14 | [[Image(r2.png)]] |
| | 15 | |
| | 16 | = ИД 1 |
| | 17 | За секоја потребна информација чуваме посебен Hook, и тие се ажурираат при секоја промена од страна на корисникот. |
| | 18 | |
| | 19 | |
| | 20 | {{{ |
| | 21 | const form = useForm<z.infer<typeof formSchema>>({ |
| | 22 | resolver: zodResolver(formSchema), |
| | 23 | defaultValues: { |
| | 24 | email: '', password: '', firstName: '', lastName: '', |
| | 25 | address1: '', city: '', state: '', postalCode: '', |
| | 26 | dateOfBirth: '', ssn: '', |
| | 27 | }, |
| | 28 | }); |
| | 29 | }}} |
| | 30 | |
| | 31 | |
| | 32 | = ИД 1 |
| | 33 | |
| | 34 | Во следните неколку слики е опишана обработката на регистрацијата на корисникот. |
| | 35 | При клик на копчето Sign Up, се испраќа POST барање до backend апликацијата во кој се сместени поднесените информации. |
| | 36 | {{{ |
| | 37 | const onSubmit = async (data: z.infer<typeof formSchema>) => { |
| | 38 | setIsLoading(true); |
| | 39 | try { |
| | 40 | if (type === 'sign-up') { |
| | 41 | await register({ |
| | 42 | firstName: data.firstName!, lastName: data.lastName!, |
| | 43 | email: data.email, password: data.password, |
| | 44 | address1: data.address1!, city: data.city!, |
| | 45 | state: data.state!, postalCode: data.postalCode!, |
| | 46 | dateOfBirth: data.dateOfBirth!, ssn: data.ssn!, |
| | 47 | }); |
| | 48 | setSuccess('Registration successful! Redirecting...'); |
| | 49 | } |
| | 50 | } catch (error) { |
| | 51 | setError(error instanceof Error ? error.message : 'Authentication failed'); |
| | 52 | } |
| | 53 | }; |
| | 54 | }}} |
| | 55 | = ИД 1 |
| | 56 | Во backend апликацијата, POST request-от е примен од страна на контролерот register_view кој го содржи соодветен API endpoint, кој ги препраќа потребните информации кон базата. |
| | 57 | {{{ |
| | 58 | @api_view(['POST']) |
| | 59 | @permission_classes([AllowAny]) |
| | 60 | def register_view(request): |
| | 61 | data = request.data |
| | 62 | email = data.get('email') |
| | 63 | password = data.get('password') |
| | 64 | first_name = data.get('firstName', '') |
| | 65 | last_name = data.get('lastName', '') |
| | 66 | |
| | 67 | user = User.objects.create_user( |
| | 68 | username=email, email=email, password=password, |
| | 69 | first_name=first_name, last_name=last_name |
| | 70 | ) |
| | 71 | |
| | 72 | refresh = RefreshToken.for_user(user) |
| | 73 | return Response({ |
| | 74 | 'user': {'id': user.id, 'email': user.email}, |
| | 75 | 'access': str(refresh.access_token), |
| | 76 | 'refresh': str(refresh) |
| | 77 | }, status=status.HTTP_201_CREATED) |
| | 78 | }}} |
| | 79 | При успешна регистрација, корисникот е пренасочен кон страницата за поврзување на банкарска сметка преку Plaid. |
| | 80 | |
| | 81 | [[Image(r3.png)]] |
| | 82 | |
| | 83 | == ИД 2 - Најава на корисник |
| | 84 | Корисникот се најавува и е редиректиран на home страницата како најавен корисник со дополнителни функции. |
| | 85 | |
| | 86 | [[Image(r1.png)]] |
| | 87 | |
| | 88 | = ИД 2 |
| | 89 | При клик на копчето Sign In, се испраќа POST барање до backend апликацијата за верификација на креденцијалите. |
| | 90 | {{{ |
| | 91 | const login = async (email: string, password: string) => { |
| | 92 | const response = await fetch('http://localhost:8000/api/auth/login/', { |
| | 93 | method: 'POST', |
| | 94 | headers: { 'Content-Type': 'application/json' }, |
| | 95 | body: JSON.stringify({ email, password }), |
| | 96 | }); |
| | 97 | |
| | 98 | if (response.ok) { |
| | 99 | const data = await response.json(); |
| | 100 | localStorage.setItem('access_token', data.access); |
| | 101 | localStorage.setItem('refresh_token', data.refresh); |
| | 102 | setUser(data.user); |
| | 103 | router.push('/'); |
| | 104 | } |
| | 105 | }; |
| | 106 | }}} |
| | 107 | = ИД 2 |
| | 108 | Во backend апликацијата, се верификуваат креденцијалите и се генерираат JWT токени. |
| | 109 | {{{ |
| | 110 | @api_view(['POST']) |
| | 111 | @permission_classes([AllowAny]) |
| | 112 | def login_view(request): |
| | 113 | email = request.data.get('email') |
| | 114 | password = request.data.get('password') |
| | 115 | |
| | 116 | user = authenticate(request, username=email, password=password) |
| | 117 | |
| | 118 | if user is None: |
| | 119 | return Response({'error': 'Invalid credentials'}, |
| | 120 | status=status.HTTP_401_UNAUTHORIZED) |
| | 121 | |
| | 122 | refresh = RefreshToken.for_user(user) |
| | 123 | return Response({ |
| | 124 | 'user': {'id': user.id, 'email': user.email}, |
| | 125 | 'access': str(refresh.access_token), |
| | 126 | 'refresh': str(refresh) |
| | 127 | }) |
| | 128 | }}} |
| | 129 | |
| | 130 | [[Image(r5.png)]] |
| | 131 | |
| | 132 | == Најавен корисник (User) |
| | 133 | == ИД 3 - Преглед на контролен панел (Home) |
| | 134 | Корисникот го кликнува копчето Home во навигацијата за преглед на контролниот панел со вкупен баланс, број на сметки и последни трансакции. |
| | 135 | |
| | 136 | [[Image(r5.png)]] |
| | 137 | |
| | 138 | = ИД 3 |
| | 139 | Се користи custom hook useDashboard за превземање на податоците од backend. |
| | 140 | {{{ |
| | 141 | export const useDashboard = () => { |
| | 142 | const [data, setData] = useState<DashboardData | null>(null); |
| | 143 | |
| | 144 | const fetchDashboard = async () => { |
| | 145 | const accessToken = localStorage.getItem('access_token'); |
| | 146 | const response = await fetch('http://localhost:8000/api/banking/dashboard/', { |
| | 147 | headers: { |
| | 148 | 'Authorization': `Bearer ${accessToken}`, |
| | 149 | 'Content-Type': 'application/json', |
| | 150 | }, |
| | 151 | }); |
| | 152 | |
| | 153 | if (response.ok) { |
| | 154 | const dashboardData = await response.json(); |
| | 155 | setData(dashboardData); |
| | 156 | } |
| | 157 | }; |
| | 158 | |
| | 159 | return { data, loading, error, refetch: fetchDashboard }; |
| | 160 | }; |
| | 161 | }}} |
| | 162 | = ИД 3 |
| | 163 | Backend API го враќа вкупниот баланс, бројот на сметки и последните трансакции. |
| | 164 | {{{ |
| | 165 | @api_view(['GET']) |
| | 166 | @permission_classes([IsAuthenticated]) |
| | 167 | def dashboard_view(request): |
| | 168 | user_accounts = Account.objects.filter(user=request.user) |
| | 169 | total_balance = sum(account.balance for account in user_accounts) |
| | 170 | recent_transactions = Transaction.objects.filter( |
| | 171 | account__in=user_accounts |
| | 172 | ).order_by('-created_at')[:5] |
| | 173 | |
| | 174 | return Response({ |
| | 175 | 'total_balance': float(total_balance), |
| | 176 | 'accounts_count': user_accounts.count(), |
| | 177 | 'accounts': accounts_data, |
| | 178 | 'recent_transactions': transactions_data, |
| | 179 | }) |
| | 180 | }}} |
| | 181 | == ИД 4 - Преглед на банкарски сметки (My Banks) |
| | 182 | Корисникот кликнува на My Banks во навигацијата и ги гледа сите поврзани банкарски сметки како визуелни картички. |
| | 183 | |
| | 184 | [[Image(r8.png)]] |
| | 185 | |
| | 186 | = ИД 4 |
| | 187 | Компонентата BankCard ги прикажува деталите за секоја банкарска сметка. |
| | 188 | {{{ |
| | 189 | const BankCard = ({account, userName, showBalance = true}: CreditCardProps) => { |
| | 190 | return ( |
| | 191 | <div className='flex flex-col'> |
| | 192 | <Link href='/' className='bank-card relative overflow-hidden'> |
| | 193 | <div className='bank-card-content'> |
| | 194 | <h1 className='text-18 font-semibold text-white'> |
| | 195 | {account.name || userName} |
| | 196 | </h1> |
| | 197 | <p className='font-bold text-white text-24'> |
| | 198 | {formatAmount(account.currentBalance)} |
| | 199 | </p> |
| | 200 | </div> |
| | 201 | </Link> |
| | 202 | </div> |
| | 203 | ) |
| | 204 | } |
| | 205 | }}} |
| | 206 | = ИД 4 |
| | 207 | Backend API ги враќа сите банкарски сметки поврзани со корисникот. |
| | 208 | {{{ |
| | 209 | @api_view(['GET']) |
| | 210 | @permission_classes([IsAuthenticated]) |
| | 211 | def accounts_view(request): |
| | 212 | user_accounts = Account.objects.filter(user=request.user) |
| | 213 | accounts_data = [] |
| | 214 | |
| | 215 | for account in user_accounts: |
| | 216 | accounts_data.append({ |
| | 217 | 'id': str(account.id), |
| | 218 | 'name': f"{account.bank.name} {account.account_type.title()}", |
| | 219 | 'currentBalance': float(account.balance), |
| | 220 | 'type': account.account_type, |
| | 221 | 'account_number': account.account_number, |
| | 222 | }) |
| | 223 | |
| | 224 | return Response({'accounts': accounts_data}) |
| | 225 | }}} |
| | 226 | == ИД 5 - Преглед на историја на трансакции |
| | 227 | Корисникот кликнува на Transaction History и ја гледа табелата со сите трансакции. Може да филтрира по сметка, статус и категорија. |
| | 228 | |
| | 229 | [[Image(r9.png)]] |
| | 230 | |
| | 231 | == ИД 5 |
| | 232 | Frontend компонентата овозможува филтрирање и пагинација на трансакциите. |
| | 233 | {{{ |
| | 234 | const fetchTransactions = async () => { |
| | 235 | const params = new URLSearchParams({ |
| | 236 | page: currentPage.toString(), |
| | 237 | per_page: '10', |
| | 238 | }); |
| | 239 | |
| | 240 | if (searchQuery) params.append('search', searchQuery); |
| | 241 | if (statusFilter !== 'all') params.append('status', statusFilter); |
| | 242 | if (categoryFilter !== 'all') params.append('category', categoryFilter); |
| | 243 | |
| | 244 | const response = await fetch( |
| | 245 | `http://localhost:8000/api/banking/transactions/?${params}`, |
| | 246 | { headers: { 'Authorization': `Bearer ${accessToken}` } } |
| | 247 | ); |
| | 248 | |
| | 249 | const data = await response.json(); |
| | 250 | setTransactions(data.transactions); |
| | 251 | setTotalPages(data.total_pages); |
| | 252 | }; |
| | 253 | }}} |
| | 254 | = ИД 5 |
| | 255 | Backend API ги враќа трансакциите со поддршка за филтрирање и пагинација. |
| | 256 | {{{ |
| | 257 | @api_view(['GET']) |
| | 258 | @permission_classes([IsAuthenticated]) |
| | 259 | def transactions_view(request): |
| | 260 | user_accounts = Account.objects.filter(user=request.user) |
| | 261 | transactions_query = Transaction.objects.filter(account__in=user_accounts) |
| | 262 | |
| | 263 | # Apply filters |
| | 264 | if category and category != 'all': |
| | 265 | transactions_query = transactions_query.filter(category=category) |
| | 266 | if status and status != 'all': |
| | 267 | transactions_query = transactions_query.filter(status=status) |
| | 268 | |
| | 269 | return Response({ |
| | 270 | 'transactions': transactions_data, |
| | 271 | 'total': total_transactions, |
| | 272 | 'page': page, |
| | 273 | 'total_pages': total_pages |
| | 274 | }) |
| | 275 | }}} |
| | 276 | == ИД 6 - Префрлање на средства (Transfer Funds) |
| | 277 | Корисникот кликнува на Transfer Funds, избира тип на трансфер (Internal/External), изворна и дестинациска сметка, категорија, износ и кликнува Transfer Funds. Трансферот се процесира преку Dwolla API. |
| | 278 | |
| | 279 | [[Image(r11.png)]] |
| | 280 | |
| | 281 | |
| | 282 | [[Image(r12.png)]] |
| | 283 | |
| | 284 | |
| | 285 | ИД 6 |
| | 286 | Frontend формата за трансфер користи react-hook-form за валидација. |
| | 287 | {{{ |
| | 288 | const onSubmit = async (data: TransferFormData) => { |
| | 289 | const requestBody = { |
| | 290 | source_account_id: data.sourceAccount, |
| | 291 | amount: data.amount, |
| | 292 | category: data.category, |
| | 293 | note: data.note, |
| | 294 | }; |
| | 295 | |
| | 296 | if (data.transferType === 'internal') { |
| | 297 | requestBody.destination_account_id = data.destinationAccount; |
| | 298 | } else { |
| | 299 | requestBody.recipient_name = data.recipientName; |
| | 300 | requestBody.recipient_email = data.recipientEmail; |
| | 301 | } |
| | 302 | |
| | 303 | const response = await fetch('http://localhost:8000/api/banking/transfer/', { |
| | 304 | method: 'POST', |
| | 305 | headers: { 'Authorization': `Bearer ${accessToken}` }, |
| | 306 | body: JSON.stringify(requestBody), |
| | 307 | }); |
| | 308 | }; |
| | 309 | }}} |
| | 310 | = ИД 6 |
| | 311 | Backend API го процесира трансферот и ги ажурира балансите на сметките. |
| | 312 | {{{ |
| | 313 | @api_view(['POST']) |
| | 314 | @permission_classes([IsAuthenticated]) |
| | 315 | def transfer_funds(request): |
| | 316 | source_account = Account.objects.get(id=source_account_id, user=request.user) |
| | 317 | |
| | 318 | with transaction.atomic(): |
| | 319 | Transaction.objects.create( |
| | 320 | account=source_account, |
| | 321 | transaction_type='debit', |
| | 322 | amount=amount, |
| | 323 | description=f"Transfer to {destination_account.bank.name}", |
| | 324 | category=category, |
| | 325 | status='completed' |
| | 326 | ) |
| | 327 | |
| | 328 | source_account.balance -= amount |
| | 329 | source_account.save() |
| | 330 | |
| | 331 | destination_account.balance += amount |
| | 332 | destination_account.save() |
| | 333 | |
| | 334 | return Response({'message': 'Transfer completed successfully'}) |
| | 335 | }}} |
| | 336 | |
| | 337 | [[Image(r13.png)]] |
| | 338 | |
| | 339 | |
| | 340 | == ИД 7 - Преглед на аналитика (Analytics) |
| | 341 | Корисникот кликнува на Analytics и ги гледа графиците за Total Income, Total Expenses, Net Savings, pie chart по категории и месечна споредба. |
| | 342 | |
| | 343 | [[Image(r14.png)]] |
| | 344 | |
| | 345 | |
| | 346 | = ИД 7 |
| | 347 | Frontend компонентата користи Recharts библиотека за визуелизација на податоците. |
| | 348 | {{{ |
| | 349 | const Analytics = () => { |
| | 350 | const { data, loading } = useAnalytics(dateRange); |
| | 351 | |
| | 352 | return ( |
| | 353 | <ResponsiveContainer width="100%" height={300}> |
| | 354 | <PieChart> |
| | 355 | <Pie |
| | 356 | data={data.categoryBreakdown} |
| | 357 | dataKey="amount" |
| | 358 | label={({ category, percentage }) => `${category}: ${percentage}%`} |
| | 359 | > |
| | 360 | {data.categoryBreakdown.map((entry, index) => ( |
| | 361 | <Cell key={index} fill={COLORS[index % COLORS.length]} /> |
| | 362 | ))} |
| | 363 | </Pie> |
| | 364 | <Tooltip formatter={(value) => `$${value.toFixed(2)}`} /> |
| | 365 | </PieChart> |
| | 366 | </ResponsiveContainer> |
| | 367 | ); |
| | 368 | }; |
| | 369 | }}} |
| | 370 | = ИД 7 |
| | 371 | Backend API ги пресметува аналитичките податоци од трансакциите. |
| | 372 | {{{ |
| | 373 | @api_view(['GET']) |
| | 374 | @permission_classes([IsAuthenticated]) |
| | 375 | def analytics_view(request): |
| | 376 | transactions = Transaction.objects.filter(account__in=user_accounts) |
| | 377 | |
| | 378 | # Category Breakdown |
| | 379 | category_data = {} |
| | 380 | for txn in transactions: |
| | 381 | if txn.transaction_type == 'debit': |
| | 382 | category = txn.category or 'other' |
| | 383 | category_data[category]['amount'] += float(txn.amount) |
| | 384 | |
| | 385 | total_income = transactions.filter(transaction_type='credit').aggregate(Sum('amount')) |
| | 386 | total_expenses = transactions.filter(transaction_type='debit').aggregate(Sum('amount')) |
| | 387 | |
| | 388 | return Response({ |
| | 389 | 'categoryBreakdown': category_breakdown, |
| | 390 | 'incomeVsExpenses': { |
| | 391 | 'totalIncome': total_income, |
| | 392 | 'totalExpenses': total_expenses, |
| | 393 | 'netSavings': total_income - total_expenses |
| | 394 | } |
| | 395 | }) |
| | 396 | }}} |
| | 397 | == ИД 8 - Управување со буџети (Budgets) |
| | 398 | Корисникот кликнува на Budgets и го гледа Budget Manager со вкупно буџетирано, потрошено и останато. Може да креира нови буџети по категории. |
| | 399 | |
| | 400 | [[Image(r17.png)]] |
| | 401 | |
| | 402 | |
| | 403 | = ИД 8 |
| | 404 | Budget моделот во Django ги пресметува потрошените средства автоматски. |
| | 405 | {{{ |
| | 406 | class Budget(models.Model): |
| | 407 | user = models.ForeignKey(User, on_delete=models.CASCADE) |
| | 408 | category = models.CharField(max_length=100) |
| | 409 | amount = models.DecimalField(max_digits=10, decimal_places=2) |
| | 410 | month = models.DateField() |
| | 411 | |
| | 412 | @property |
| | 413 | def spent_amount(self): |
| | 414 | spent = Transaction.objects.filter( |
| | 415 | account__user=self.user, |
| | 416 | category__iexact=self.category, |
| | 417 | transaction_type='debit', |
| | 418 | created_at__gte=self.month |
| | 419 | ).aggregate(total=Sum('amount'))['total'] or Decimal('0.00') |
| | 420 | return float(spent) |
| | 421 | |
| | 422 | @property |
| | 423 | def percentage_used(self): |
| | 424 | return round((self.spent_amount / float(self.amount)) * 100, 1) |
| | 425 | }}} |
| | 426 | = ИД 8 |
| | 427 | Backend API за креирање на нов буџет. |
| | 428 | {{{ |
| | 429 | @api_view(['POST']) |
| | 430 | @permission_classes([IsAuthenticated]) |
| | 431 | def create_budget(request): |
| | 432 | category = request.data.get('category') |
| | 433 | amount = Decimal(str(request.data.get('amount', 0))) |
| | 434 | |
| | 435 | budget = Budget.objects.create( |
| | 436 | user=request.user, |
| | 437 | category=category, |
| | 438 | amount=amount, |
| | 439 | month=month_date |
| | 440 | ) |
| | 441 | |
| | 442 | return Response({ |
| | 443 | 'budget': { |
| | 444 | 'id': budget.id, |
| | 445 | 'category': budget.category, |
| | 446 | 'amount': float(budget.amount), |
| | 447 | 'spent': budget.spent_amount, |
| | 448 | 'percentage_used': budget.percentage_used |
| | 449 | } |
| | 450 | }) |
| | 451 | }}} |
| | 452 | == ИД 9 - Поврзување на банкарска сметка (Connect Bank) |
| | 453 | Корисникот кликнува на Connect Bank, ја гледа информацијата за Secure Bank Connection со 256-bit енкрипција, избира банка и кликнува Connect with Plaid. |
| | 454 | |
| | 455 | [[Image(r19.png)]] |
| | 456 | |
| | 457 | |
| | 458 | = ИД 9 |
| | 459 | PlaidLink компонентата го иницира процесот на поврзување со Plaid API. |
| | 460 | {{{ |
| | 461 | const PlaidLink = ({ variant, onSuccess }: PlaidLinkProps) => { |
| | 462 | const [linkToken, setLinkToken] = useState<string | null>(null); |
| | 463 | |
| | 464 | useEffect(() => { |
| | 465 | const createLinkToken = async () => { |
| | 466 | const response = await fetch( |
| | 467 | 'http://localhost:8000/api/banking/plaid/create-link-token/', |
| | 468 | { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}` } } |
| | 469 | ); |
| | 470 | const data = await response.json(); |
| | 471 | setLinkToken(data.link_token); |
| | 472 | }; |
| | 473 | createLinkToken(); |
| | 474 | }, []); |
| | 475 | |
| | 476 | const onPlaidSuccess = useCallback(async (public_token, metadata) => { |
| | 477 | await fetch('http://localhost:8000/api/banking/plaid/exchange-token/', { |
| | 478 | method: 'POST', |
| | 479 | body: JSON.stringify({ public_token }), |
| | 480 | }); |
| | 481 | router.push('/my-banks'); |
| | 482 | }, []); |
| | 483 | |
| | 484 | const { open, ready } = usePlaidLink({ token: linkToken, onSuccess: onPlaidSuccess }); |
| | 485 | }; |
| | 486 | }}} |
| | 487 | = ИД 9 |
| | 488 | Backend API за креирање на Plaid Link Token. |
| | 489 | {{{ |
| | 490 | @api_view(['POST']) |
| | 491 | @permission_classes([IsAuthenticated]) |
| | 492 | def create_link_token(request): |
| | 493 | link_request = LinkTokenCreateRequest( |
| | 494 | user=LinkTokenCreateRequestUser(client_user_id=str(request.user.id)), |
| | 495 | client_name='Banker App', |
| | 496 | products=[Products('auth'), Products('transactions')], |
| | 497 | country_codes=[CountryCode('US')], |
| | 498 | language='en' |
| | 499 | ) |
| | 500 | response = client.link_token_create(link_request) |
| | 501 | return Response({'link_token': response['link_token']}) |
| | 502 | }}} |
| | 503 | = ИД 9 |
| | 504 | По успешно поврзување преку Plaid, се креираат банкарски сметки во базата. |
| | 505 | {{{ |
| | 506 | @api_view(['POST']) |
| | 507 | @permission_classes([IsAuthenticated]) |
| | 508 | def exchange_public_token(request): |
| | 509 | exchange_response = client.item_public_token_exchange( |
| | 510 | ItemPublicTokenExchangeRequest(public_token=public_token) |
| | 511 | ) |
| | 512 | access_token = exchange_response['access_token'] |
| | 513 | |
| | 514 | accounts_response = client.accounts_get(AccountsGetRequest(access_token=access_token)) |
| | 515 | |
| | 516 | for plaid_account in accounts_response['accounts']: |
| | 517 | account = Account.objects.create( |
| | 518 | user=request.user, |
| | 519 | bank=bank, |
| | 520 | account_type=account_subtype, |
| | 521 | balance=Decimal(str(current_balance)), |
| | 522 | status='active' |
| | 523 | ) |
| | 524 | |
| | 525 | return Response({'message': 'Bank accounts connected via Plaid'}) |
| | 526 | }}} |
| | 527 | |
| | 528 | [[Image(r20.png)]] |
| | 529 | |
| | 530 | |
| | 531 | |
| | 532 | [[Image(r21.png)]] |
| | 533 | |
| | 534 | |