Payslips: format amounts in the org currency
/api/v1/me/payslips items and /api/v1/me/profile now carry the org base currency. Payslip and Profile read it; the payslips screen formats with the payslip currency, else the last profile's, else INR. INR output is unchanged (rupee sign, lakh grouping, no decimals); other currencies show the ISO code with their own minor units (AED 1,234.50, BHD 1,234.500). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2dbd1a2ea9
commit
2e0d825c24
+17
-3
@@ -14,6 +14,8 @@ class Profile {
|
|||||||
final String gid, fullName, role;
|
final String gid, fullName, role;
|
||||||
final String? employeeCode, departmentName, designationTitle, reportingManagerName;
|
final String? employeeCode, departmentName, designationTitle, reportingManagerName;
|
||||||
final bool isManager;
|
final bool isManager;
|
||||||
|
/// Org base currency (ISO 4217); null on older servers ⇒ INR.
|
||||||
|
final String? currency;
|
||||||
Profile.fromJson(Map<String, dynamic> j)
|
Profile.fromJson(Map<String, dynamic> j)
|
||||||
: gid = j['gid'] ?? '',
|
: gid = j['gid'] ?? '',
|
||||||
fullName = j['fullName'] ?? '',
|
fullName = j['fullName'] ?? '',
|
||||||
@@ -22,7 +24,8 @@ class Profile {
|
|||||||
departmentName = j['departmentName'],
|
departmentName = j['departmentName'],
|
||||||
designationTitle = j['designationTitle'],
|
designationTitle = j['designationTitle'],
|
||||||
reportingManagerName = j['reportingManagerName'],
|
reportingManagerName = j['reportingManagerName'],
|
||||||
isManager = j['isManager'] == true;
|
isManager = j['isManager'] == true,
|
||||||
|
currency = j['currency'] as String?;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Today {
|
class Today {
|
||||||
@@ -81,13 +84,16 @@ class LeaveRequest {
|
|||||||
class Payslip {
|
class Payslip {
|
||||||
final String gid, periodMonth, status;
|
final String gid, periodMonth, status;
|
||||||
final num gross, totalDeductions, netPay;
|
final num gross, totalDeductions, netPay;
|
||||||
|
/// Org base currency (ISO 4217); null on older servers ⇒ INR.
|
||||||
|
final String? currency;
|
||||||
Payslip.fromJson(Map<String, dynamic> j)
|
Payslip.fromJson(Map<String, dynamic> j)
|
||||||
: gid = j['gid'] ?? '',
|
: gid = j['gid'] ?? '',
|
||||||
periodMonth = j['periodMonth'] ?? '',
|
periodMonth = j['periodMonth'] ?? '',
|
||||||
status = j['status'] ?? '',
|
status = j['status'] ?? '',
|
||||||
gross = j['gross'] ?? 0,
|
gross = j['gross'] ?? 0,
|
||||||
totalDeductions = j['totalDeductions'] ?? 0,
|
totalDeductions = j['totalDeductions'] ?? 0,
|
||||||
netPay = j['netPay'] ?? 0;
|
netPay = j['netPay'] ?? 0,
|
||||||
|
currency = j['currency'] as String?;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TeamMember {
|
class TeamMember {
|
||||||
@@ -110,6 +116,9 @@ class ApiService {
|
|||||||
final Settings settings;
|
final Settings settings;
|
||||||
ApiService(this.settings);
|
ApiService(this.settings);
|
||||||
|
|
||||||
|
/// Org base currency from the last profile seen (login or /me/profile).
|
||||||
|
String? currency;
|
||||||
|
|
||||||
Uri _uri(String p, [Map<String, String>? q]) =>
|
Uri _uri(String p, [Map<String, String>? q]) =>
|
||||||
Uri.parse('${Settings.baseUrl}$p').replace(queryParameters: q);
|
Uri.parse('${Settings.baseUrl}$p').replace(queryParameters: q);
|
||||||
|
|
||||||
@@ -135,12 +144,17 @@ class ApiService {
|
|||||||
throw ApiException(_msg(body) ?? 'Invalid email or PIN', res.statusCode);
|
throw ApiException(_msg(body) ?? 'Invalid email or PIN', res.statusCode);
|
||||||
}
|
}
|
||||||
final p = Profile.fromJson(body['profile'] as Map<String, dynamic>);
|
final p = Profile.fromJson(body['profile'] as Map<String, dynamic>);
|
||||||
|
currency = p.currency ?? currency;
|
||||||
await settings.saveSession(token: body['accessToken'] as String, name: p.fullName, role: p.role);
|
await settings.saveSession(token: body['accessToken'] as String, name: p.fullName, role: p.role);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- me ----
|
// ---- me ----
|
||||||
Future<Profile> profile() async => Profile.fromJson(await _get('/api/v1/me/profile'));
|
Future<Profile> profile() async {
|
||||||
|
final p = Profile.fromJson(await _get('/api/v1/me/profile'));
|
||||||
|
currency = p.currency ?? currency;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
Future<Today> today() async => Today.fromJson(await _get('/api/v1/me/attendance/today'));
|
Future<Today> today() async => Today.fromJson(await _get('/api/v1/me/attendance/today'));
|
||||||
|
|
||||||
Future<void> checkIn({double? lat, double? lng, String? selfie}) =>
|
Future<void> checkIn({double? lat, double? lng, String? selfie}) =>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// Formats a payroll amount in the organisation's currency.
|
||||||
|
///
|
||||||
|
/// [currency] is the ISO 4217 code the server sends on payslips / profile
|
||||||
|
/// (`currency`). Missing or blank ⇒ INR, which keeps the original output:
|
||||||
|
/// rupee sign, Indian lakh grouping, no decimals (e.g. `₹1,23,457`).
|
||||||
|
/// Other currencies print the ISO code and the currency's own minor units
|
||||||
|
/// (e.g. `AED 12,345.50`, `BHD 1,234.500`).
|
||||||
|
String formatMoney(num v, [String? currency]) {
|
||||||
|
final code = (currency ?? '').trim().toUpperCase();
|
||||||
|
if (code.isEmpty || code == 'INR') {
|
||||||
|
return '₹${NumberFormat('#,##,###').format(v)}';
|
||||||
|
}
|
||||||
|
// NumberFormat.currency picks the ISO default fraction digits for `name`
|
||||||
|
// (BHD/KWD/OMR 3, JPY 0, most others 2).
|
||||||
|
return NumberFormat.currency(locale: 'en', name: code, symbol: '$code ').format(v);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../api_service.dart';
|
import '../api_service.dart';
|
||||||
|
import '../money.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
|
|
||||||
class PayslipsScreen extends StatefulWidget {
|
class PayslipsScreen extends StatefulWidget {
|
||||||
@@ -40,7 +41,8 @@ class _PayslipsScreenState extends State<PayslipsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _inr(num v) => '₹${NumberFormat('#,##,###').format(v)}';
|
/// Payslip currency, else the profile's, else INR (older servers).
|
||||||
|
String _money(Payslip p, num v) => formatMoney(v, p.currency ?? widget.api.currency);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -62,7 +64,7 @@ class _PayslipsScreenState extends State<PayslipsScreen> {
|
|||||||
title: Text(_month(p.periodMonth), style: const TextStyle(fontWeight: FontWeight.w600)),
|
title: Text(_month(p.periodMonth), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
subtitle: const Text('Net pay'),
|
subtitle: const Text('Net pay'),
|
||||||
trailing: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [
|
trailing: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||||
Text(_inr(p.netPay), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.emerald)),
|
Text(_money(p, p.netPay), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.emerald)),
|
||||||
Text(p.status, style: const TextStyle(fontSize: 11, color: AppColors.muted)),
|
Text(p.status, style: const TextStyle(fontSize: 11, color: AppColors.muted)),
|
||||||
]),
|
]),
|
||||||
onTap: () => showModalBottomSheet(
|
onTap: () => showModalBottomSheet(
|
||||||
@@ -72,10 +74,10 @@ class _PayslipsScreenState extends State<PayslipsScreen> {
|
|||||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
Text(_month(p.periodMonth), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
Text(_month(p.periodMonth), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_row('Gross earnings', _inr(p.gross)),
|
_row('Gross earnings', _money(p, p.gross)),
|
||||||
_row('Total deductions', '− ${_inr(p.totalDeductions)}'),
|
_row('Total deductions', '− ${_money(p, p.totalDeductions)}'),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_row('Net pay', _inr(p.netPay), bold: true),
|
_row('Net pay', _money(p, p.netPay), bold: true),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Full PDF payslip is available on the Scalar360 web portal.',
|
const Text('Full PDF payslip is available on the Scalar360 web portal.',
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.muted)),
|
style: TextStyle(fontSize: 12, color: AppColors.muted)),
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
// This is a basic Flutter widget test.
|
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:scalar_attendance/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user