From 243b7e02aa7a82ba685399a0928e00eae0ed6dc1 Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 4 Oct 2026 22:31:01 +0700 Subject: [PATCH] feat: update profit shareing --- docs/migrasi-profit-sharing.md | 289 ++++++++++++++++++ lib/common/url/api_path.dart | 7 +- lib/domain/analytic/analytic.freezed.dart | 216 +++++++++---- .../entities/profit_sharing_entity.dart | 16 +- .../analytic/failures/analytic_failure.dart | 3 + .../analytic/analytic_dtos.freezed.dart | 65 +--- .../analytic/analytic_dtos.g.dart | 9 +- .../datasource/remote_data_provider.dart | 77 ++++- .../analytic/dto/profit_sharing_dto.dart | 4 - lib/l10n/app_en.arb | 23 +- lib/l10n/app_id.arb | 23 +- lib/l10n/app_localizations.dart | 38 ++- lib/l10n/app_localizations_en.dart | 21 +- lib/l10n/app_localizations_id.dart | 21 +- .../profit_sharing_detail_page.dart | 177 ++++++----- .../profit_sharing/profit_sharing_page.dart | 75 ++++- .../widgets/profit_sharing_allocation.dart | 52 ++-- .../widgets/profit_sharing_categories.dart | 82 +++-- .../widgets/profit_sharing_periods.dart | 124 ++++---- .../widgets/profit_sharing_sdl.dart | 283 +++++++++++++++++ .../widgets/profit_sharing_status.dart | 183 ++++++----- .../widgets/profit_sharing_subcategories.dart | 10 +- pubspec.yaml | 2 +- 23 files changed, 1349 insertions(+), 451 deletions(-) create mode 100644 docs/migrasi-profit-sharing.md create mode 100644 lib/presentation/pages/profit_sharing/widgets/profit_sharing_sdl.dart diff --git a/docs/migrasi-profit-sharing.md b/docs/migrasi-profit-sharing.md new file mode 100644 index 0000000..32a838c --- /dev/null +++ b/docs/migrasi-profit-sharing.md @@ -0,0 +1,289 @@ +# Migrasi profit sharing + +4 Oktober 2026 + +## Ringkasan + +Ada tiga perubahan di backend: + +1. **Parent category bisa ditandai bukan team.** Kategori punya field baru `is_team`. Parent category dengan `is_team: false` tidak bisa dipilih sebagai team di purchase order dan cash advance, dan tidak ikut laporan profit sharing. +2. **Endpoint laporan pindah path.** `/api/v1/analytics/parent-categories` menjadi `/api/v1/analytics/profit-sharing`. +3. **Pembagian revenue tinggal dua porsi.** Porsi purchase (60%) dihapus. Revenue sekarang dibagi ke owner (SDL) dan team, dengan porsi team = 100% − fee owner. + +Nomor 2 dan 3 adalah breaking change. Setelah backend baru dirilis, client yang masih memanggil path lama mendapat 404, dan `limit_purchase` serta `percentages.purchase` tidak ada lagi di response. Karena itu client harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis). + +Yang perlu bertindak, di dashboard maupun app mobile, mana pun yang punya layarnya: + +- **Form kategori**: tambah toggle team untuk parent category. +- **Laporan profit sharing**: ganti path, hapus porsi purchase, tampilkan dua porsi. +- **Form purchase order dan cash advance**: picker team tidak perlu diubah, tapi form edit perlu menyesuaikan, lihat [Purchase order dan cash advance](#purchase-order-dan-cash-advance). + +## Kategori: field `is_team` + +### Response + +Semua response kategori membawa `is_team` (boolean, tidak pernah `null`): `POST /api/v1/categories`, `PUT /api/v1/categories/:id`, `GET /api/v1/categories`, dan `GET /api/v1/categories/:id`. + +```json +{ + "id": "", + "name": "Merchandise", + "parent_id": null, + "owner_fee_percent": null, + "is_team": false +} +``` + +### Request + +`POST /api/v1/categories` dan `PUT /api/v1/categories/:id` menerima `is_team`. + +```json +{ + "name": "Merchandise", + "is_team": false +} +``` + +| Request | `is_team` yang dikirim | Hasil | +| --- | --- | --- | +| Create | tidak dikirim | `true` | +| Create | `false` | `false` | +| Update | tidak dikirim atau `null` | tidak berubah | +| Update | `true` atau `false` | diganti | + +Update yang hanya berisi `is_team` diterima. + +Aturan nilainya: + +- `is_team` hanya berpengaruh di parent category, yaitu kategori dengan `parent_id: null`. Di sub-category nilainya disimpan tapi tidak dipakai, jadi tampilkan toggle hanya untuk parent category. +- Semua kategori yang sudah ada sebelum rilis bernilai `true`. Tidak ada yang berubah sampai admin mematikannya. +- Flag dibaca saat request, bukan saat transaksi. Kalau parent category dimatikan, penjualannya di periode lampau juga hilang dari laporan profit sharing. Kalau dinyalakan lagi, semuanya muncul kembali. + +### Efek `is_team: false` + +| Endpoint | Efek | +| --- | --- | +| `GET /api/v1/purchase-orders/teams`, `GET /api/v1/cash-advances/teams` | Kategori tidak muncul. Pusat tetap ada. | +| Create dan update purchase order dan cash advance | `team_scope: "category"` dengan `team_category_id` kategori ini ditolak. | +| `GET /api/v1/analytics/profit-sharing` | Kategori tidak muncul di `data`, dan revenue-nya tidak dihitung di `budget`. | +| `GET /api/v1/analytics/profit-sharing/:parent_category_id` | Ditolak. | + +Data yang sudah ada tidak diubah. Purchase order dan cash advance yang sudah tercatat ke kategori itu tetap menyimpan team-nya. Laporan purchasing (`GET /api/v1/analytics/purchasing`) masih menampilkannya di `team_data`, dan filter `team=` tetap bisa dipakai. + +Laporan lain yang tidak menyaring `is_team`, misalnya `GET /api/v1/analytics/categories`, tetap menampilkan penjualan kategori itu. + +## Purchase order dan cash advance + +Picker team sudah mengambil dari `GET /api/v1/purchase-orders/teams` dan `GET /api/v1/cash-advances/teams`, jadi kategori non-team otomatis tidak muncul tanpa perubahan di client. + +Yang perlu diubah ada di form edit. Purchase order atau cash advance lama bisa tercatat ke kategori yang sekarang sudah non-team. Kalau form edit mengirim ulang `team_scope` dan `team_category_id` yang sama, request ditolak, walaupun user tidak mengubah team-nya. + +1. Kirim `team_scope` dan `team_category_id` hanya kalau user mengganti team. Kalau `team_scope` tidak dikirim, team yang tersimpan tidak berubah. +2. Kalau team yang tersimpan tidak ada di daftar `/teams`, tampilkan namanya dari field `team` di response apa adanya, dan jangan memilihkan team lain secara otomatis. + +Contoh error saat team ditolak, dengan HTTP status 500: + +```json +{ + "success": false, + "data": null, + "errors": [ + { + "code": "900", + "entity": "purchase_order_service", + "cause": "category Merchandise is not a team" + } + ] +} +``` + +Untuk cash advance, `entity` bernilai `cash_advance_service`. Cocokkan dengan teks `is not a team` di `cause` kalau perlu menampilkan pesan khusus. + +## Laporan profit sharing + +### Path baru + +| Lama | Baru | +| --- | --- | +| `GET /api/v1/analytics/parent-categories` | `GET /api/v1/analytics/profit-sharing` | +| `GET /api/v1/analytics/parent-categories/:parent_category_id` | `GET /api/v1/analytics/profit-sharing/:parent_category_id` | + +Query parameter dan role tidak berubah: + +- `date_from` dan `date_to` wajib, dengan format `DD-MM-YYYY`, misalnya `28-09-2026`. +- `outlet_id` opsional. +- Hanya bisa diakses superadmin, admin, manager, owner, dan purchasing. + +Path lama sudah tidak ada dan mengembalikan 404. + +### Pembagian revenue + +Revenue tiap parent category yang team dibagi dua: + +- **SDL (fee owner)** = revenue × `owner_fee_percent` / 100. Default-nya 20%, dan bisa diganti per parent category lewat `owner_fee_percent` di kategori. +- **Team** = revenue − SDL. + +Contoh dengan tiga parent category dalam satu minggu: + +| Parent category | `is_team` | Fee owner | Revenue | SDL | Team | +| --- | --- | --- | --- | --- | --- | +| Food | `true` | 20% (default) | 1.000.000 | 200.000 | 800.000 | +| Drink | `true` | 35% | 2.000.000 | 700.000 | 1.300.000 | +| Merchandise | `false` | - | 500.000 | tidak dihitung | tidak dihitung | +| **Budget minggu ini** | | | **3.000.000** | **900.000** | **2.100.000** | + +### Perubahan field + +| Field | Sebelum | Sesudah | +| --- | --- | --- | +| `data[]` | semua parent category | hanya parent category team | +| `budget.percentages.purchase` | `60` | dihapus | +| `budget.percentages.owner` | `20`, atau fee parent itu di endpoint detail | tidak berubah | +| `budget.percentages.team` | `20` | `100 − owner`: `80` di list, `100 − fee parent` di detail | +| `limit_purchase` di `budget.total`, `budget.weekly[]`, `budget.monthly[]` | 60% revenue | dihapus | +| `limit_team` di tempat yang sama | 20% revenue | `revenue − sdl` | +| `revenue` dan `sdl` di `budget` | semua parent category | hanya parent category team | + +Di endpoint list, `budget.percentages` selalu berisi default `20` dan `80`, walaupun ada parent dengan fee berbeda. Angka `sdl` dan `limit_team` dihitung per parent dengan fee masing-masing, jadi `limit_team / revenue` bisa tidak persis 80%. Tampilkan angka rupiah dari response, jangan dihitung ulang dari persentase. + +Baris di `data[]` membawa `sdl` tapi tidak membawa porsi team. Kalau porsi team per parent perlu ditampilkan, hitung dari `total_revenue − sdl`. + +Contoh response list, dipotong: + +```json +{ + "success": true, + "data": { + "date_from": "2026-09-28T00:00:00+07:00", + "date_to": "2026-10-04T23:59:59.999999999+07:00", + "data": [ + { + "parent_category_id": "", + "parent_category_name": "Drink", + "owner_fee_percent": 35, + "sdl": 700000, + "total_revenue": 2000000 + }, + { + "parent_category_id": "", + "parent_category_name": "Food", + "owner_fee_percent": 20, + "sdl": 200000, + "total_revenue": 1000000 + } + ], + "budget": { + "percentages": { "owner": 20, "team": 80 }, + "cut_off_from": "2026-09-28T00:00:00+07:00", + "cut_off_to": "2026-10-04T23:59:59.999999999+07:00", + "total": { + "period_start": "2026-09-28T00:00:00+07:00", + "period_end": "2026-10-04T23:59:59.999999999+07:00", + "revenue": 3000000, + "order_count": 4, + "sdl": 900000, + "limit_team": 2100000 + }, + "weekly": [ + { + "period_start": "2026-09-28T00:00:00+07:00", + "period_end": "2026-10-04T23:59:59.999999999+07:00", + "revenue": 3000000, + "order_count": 4, + "sdl": 900000, + "limit_team": 2100000 + } + ], + "monthly": [ + { + "month": "2026-09", + "week_count": 1, + "period_start": "2026-09-28T00:00:00+07:00", + "period_end": "2026-10-04T23:59:59.999999999+07:00", + "revenue": 3000000, + "order_count": 4, + "sdl": 900000, + "limit_team": 2100000 + } + ] + } + }, + "errors": null +} +``` + +Di endpoint detail, `budget` bentuknya sama, tapi `percentages` memakai fee parent itu, misalnya `{ "owner": 35, "team": 65 }` untuk Drink. + +### Detail kategori non-team + +Detail untuk parent category non-team ditolak dengan HTTP status 500: + +```json +{ + "success": false, + "data": null, + "errors": [ + { + "code": "internal_error", + "entity": "AnalyticsHandler::GetParentCategoryAnalyticsDetail", + "cause": "failed to get parent category analytics detail: failed to get parent category analytics detail: category Merchandise is not a team" + } + ] +} +``` + +Ini bisa terjadi kalau user membuka link lama atau bookmark ke parent yang baru dimatikan. Cocokkan dengan teks `is not a team` di `cause`, lalu arahkan user kembali ke list. + +## Migrasi client + +### Laporan profit sharing + +1. Ganti path ke `/api/v1/analytics/profit-sharing`. Selama backend lama masih jalan, path baru mengembalikan 404. Kalau dapat 404, panggil path lama `/api/v1/analytics/parent-categories`, supaya client baru bisa dirilis sebelum backend. +2. Hapus tampilan limit purchase dan persentase purchase. Jangan menganggap `limit_purchase` atau `percentages.purchase` selalu ada. +3. Tampilkan dua porsi dengan label "SDL / Fee owner" dan "Team", ambil angkanya dari `sdl` dan `limit_team`. +4. Kalau detail ditolak dengan `is not a team`, arahkan kembali ke list. + +Selama fallback ke backend lama, `limit_team` masih berisi 20% revenue. Angkanya baru jadi `revenue − sdl` setelah backend baru dirilis. + +### Form kategori + +1. Tambah toggle `is_team` untuk parent category, misalnya berlabel "Ikut profit sharing (team)". Untuk kategori baru, toggle menyala secara default. +2. Saat membuka form edit, isi toggle dari `is_team`. Selama backend lama masih jalan, field ini tidak ada di response, jadi anggap `true`. +3. Saat admin mematikan toggle, tampilkan konfirmasi bahwa kategori itu tidak akan muncul di pilihan team dan di laporan profit sharing, termasuk untuk periode lampau. +4. Di daftar kategori, beri penanda untuk parent category dengan `is_team: false`. + +Backend lama mengabaikan `is_team` di request, jadi toggle bisa dirilis lebih dulu, tapi belum berpengaruh sampai backend baru jalan. Pengecualiannya update yang hanya berisi `is_team`: backend lama menolaknya dengan error code `303` dan pesan `at least one field must be provided for update`. Selama masa transisi, kirim `is_team` bersama field form lainnya. + +## Database dan urutan rilis + +Migration `000101_add_is_team_to_categories` menambah kolom `categories.is_team` (`BOOLEAN NOT NULL DEFAULT TRUE`). Semua kategori yang ada otomatis bernilai `true`. Migration ini hanya menambah kolom, jadi backend lama tetap jalan normal setelahnya. + +Urutan rilis: + +1. Rilis client baru: path profit sharing dengan fallback ke path lama, tanpa porsi purchase, dan dengan toggle `is_team`. Untuk app mobile, pastikan versi baru sudah dipakai sebagian besar user sebelum langkah 3, karena versi lama yang memanggil `/parent-categories` mendapat 404 setelah itu. +2. Jalankan migration `000101`. +3. Deploy backend baru. +4. Hapus fallback path lama di client. +5. Admin mematikan `is_team` di parent category yang bukan team. + +Rollback: deploy backend lama, lalu jalankan down migration yang menghapus kolom `is_team`. Client dengan fallback tetap jalan di backend lama. Nilai `is_team` yang sudah diatur admin hilang saat kolom dihapus. + +## Checklist + +- [ ] Client baru (fallback path, tanpa porsi purchase, toggle `is_team`) dirilis +- [ ] Migration `000101` dan backend baru dirilis di staging +- [ ] Uji: parent category yang dimatikan hilang dari `/purchase-orders/teams` dan `/cash-advances/teams` +- [ ] Uji: purchase order dan cash advance baru dengan kategori itu ditolak, dan edit purchase order lama tanpa mengganti team tetap berhasil +- [ ] Uji: kategori itu hilang dari `/analytics/profit-sharing`, dan detailnya ditolak +- [ ] Uji: `limit_team = revenue − sdl`, dan `sdl` mengikuti fee masing-masing parent +- [ ] Migration `000101` dan backend baru dirilis di production +- [ ] Fallback path lama di client dihapus + +## FAQ + +**Kenapa porsi team jadi 100% − fee owner, bukan tetap 20%?** Porsi purchase sudah tidak ada, jadi seluruh revenue dibagi dua. Owner mengambil fee-nya, dan sisanya untuk team. Kalau fee owner sebuah parent dinaikkan, porsi team parent itu turun sebesar yang sama. + +**Apakah sub-category bisa dijadikan non-team sendiri?** Tidak. Team dan profit sharing dihitung per parent category, jadi semua sub-category ikut status parent-nya. + +**Bagaimana dengan kategori top-level yang tidak punya sub-category?** Kategori itu tetap parent category, jadi `is_team` berlaku untuknya. diff --git a/lib/common/url/api_path.dart b/lib/common/url/api_path.dart index bfa7ac4..11f1352 100644 --- a/lib/common/url/api_path.dart +++ b/lib/common/url/api_path.dart @@ -14,7 +14,12 @@ class ApiPath { static const String purchasingAnalytic = '/api/v1/analytics/purchasing'; static const String exclusiveSummaryAnalytic = '/api/v1/analytics/exclusive-summary/period'; - static const String parentCategoryAnalytic = + static const String profitSharingAnalytic = + '/api/v1/analytics/profit-sharing'; + + /// Path lama profit sharing, hanya untuk fallback selama backend lama masih + /// jalan. Hapus setelah backend baru rilis (docs/migrasi-profit-sharing.md). + static const String profitSharingAnalyticLegacy = '/api/v1/analytics/parent-categories'; // Inventory diff --git a/lib/domain/analytic/analytic.freezed.dart b/lib/domain/analytic/analytic.freezed.dart index e763773..3b880d2 100644 --- a/lib/domain/analytic/analytic.freezed.dart +++ b/lib/domain/analytic/analytic.freezed.dart @@ -13342,7 +13342,6 @@ abstract class _ProfitSharingBudget implements ProfitSharingBudget { /// @nodoc mixin _$ProfitSharingPercentage { - double get purchase => throw _privateConstructorUsedError; double get owner => throw _privateConstructorUsedError; double get team => throw _privateConstructorUsedError; @@ -13360,7 +13359,7 @@ abstract class $ProfitSharingPercentageCopyWith<$Res> { $Res Function(ProfitSharingPercentage) then, ) = _$ProfitSharingPercentageCopyWithImpl<$Res, ProfitSharingPercentage>; @useResult - $Res call({double purchase, double owner, double team}); + $Res call({double owner, double team}); } /// @nodoc @@ -13380,17 +13379,9 @@ class _$ProfitSharingPercentageCopyWithImpl< /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({ - Object? purchase = null, - Object? owner = null, - Object? team = null, - }) { + $Res call({Object? owner = null, Object? team = null}) { return _then( _value.copyWith( - purchase: null == purchase - ? _value.purchase - : purchase // ignore: cast_nullable_to_non_nullable - as double, owner: null == owner ? _value.owner : owner // ignore: cast_nullable_to_non_nullable @@ -13414,7 +13405,7 @@ abstract class _$$ProfitSharingPercentageImplCopyWith<$Res> ) = __$$ProfitSharingPercentageImplCopyWithImpl<$Res>; @override @useResult - $Res call({double purchase, double owner, double team}); + $Res call({double owner, double team}); } /// @nodoc @@ -13434,17 +13425,9 @@ class __$$ProfitSharingPercentageImplCopyWithImpl<$Res> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({ - Object? purchase = null, - Object? owner = null, - Object? team = null, - }) { + $Res call({Object? owner = null, Object? team = null}) { return _then( _$ProfitSharingPercentageImpl( - purchase: null == purchase - ? _value.purchase - : purchase // ignore: cast_nullable_to_non_nullable - as double, owner: null == owner ? _value.owner : owner // ignore: cast_nullable_to_non_nullable @@ -13462,13 +13445,10 @@ class __$$ProfitSharingPercentageImplCopyWithImpl<$Res> class _$ProfitSharingPercentageImpl implements _ProfitSharingPercentage { const _$ProfitSharingPercentageImpl({ - required this.purchase, required this.owner, required this.team, }); - @override - final double purchase; @override final double owner; @override @@ -13476,7 +13456,7 @@ class _$ProfitSharingPercentageImpl implements _ProfitSharingPercentage { @override String toString() { - return 'ProfitSharingPercentage(purchase: $purchase, owner: $owner, team: $team)'; + return 'ProfitSharingPercentage(owner: $owner, team: $team)'; } @override @@ -13484,14 +13464,12 @@ class _$ProfitSharingPercentageImpl implements _ProfitSharingPercentage { return identical(this, other) || (other.runtimeType == runtimeType && other is _$ProfitSharingPercentageImpl && - (identical(other.purchase, purchase) || - other.purchase == purchase) && (identical(other.owner, owner) || other.owner == owner) && (identical(other.team, team) || other.team == team)); } @override - int get hashCode => Object.hash(runtimeType, purchase, owner, team); + int get hashCode => Object.hash(runtimeType, owner, team); /// Create a copy of ProfitSharingPercentage /// with the given fields replaced by the non-null parameter values. @@ -13507,13 +13485,10 @@ class _$ProfitSharingPercentageImpl implements _ProfitSharingPercentage { abstract class _ProfitSharingPercentage implements ProfitSharingPercentage { const factory _ProfitSharingPercentage({ - required final double purchase, required final double owner, required final double team, }) = _$ProfitSharingPercentageImpl; - @override - double get purchase; @override double get owner; @override @@ -13533,7 +13508,6 @@ mixin _$ProfitSharingPeriod { DateTime get periodEnd => throw _privateConstructorUsedError; int get revenue => throw _privateConstructorUsedError; int get orderCount => throw _privateConstructorUsedError; - int get limitPurchase => throw _privateConstructorUsedError; int get sdl => throw _privateConstructorUsedError; int get limitTeam => throw _privateConstructorUsedError; String get month => throw _privateConstructorUsedError; @@ -13558,7 +13532,6 @@ abstract class $ProfitSharingPeriodCopyWith<$Res> { DateTime periodEnd, int revenue, int orderCount, - int limitPurchase, int sdl, int limitTeam, String month, @@ -13585,7 +13558,6 @@ class _$ProfitSharingPeriodCopyWithImpl<$Res, $Val extends ProfitSharingPeriod> Object? periodEnd = null, Object? revenue = null, Object? orderCount = null, - Object? limitPurchase = null, Object? sdl = null, Object? limitTeam = null, Object? month = null, @@ -13609,10 +13581,6 @@ class _$ProfitSharingPeriodCopyWithImpl<$Res, $Val extends ProfitSharingPeriod> ? _value.orderCount : orderCount // ignore: cast_nullable_to_non_nullable as int, - limitPurchase: null == limitPurchase - ? _value.limitPurchase - : limitPurchase // ignore: cast_nullable_to_non_nullable - as int, sdl: null == sdl ? _value.sdl : sdl // ignore: cast_nullable_to_non_nullable @@ -13649,7 +13617,6 @@ abstract class _$$ProfitSharingPeriodImplCopyWith<$Res> DateTime periodEnd, int revenue, int orderCount, - int limitPurchase, int sdl, int limitTeam, String month, @@ -13675,7 +13642,6 @@ class __$$ProfitSharingPeriodImplCopyWithImpl<$Res> Object? periodEnd = null, Object? revenue = null, Object? orderCount = null, - Object? limitPurchase = null, Object? sdl = null, Object? limitTeam = null, Object? month = null, @@ -13699,10 +13665,6 @@ class __$$ProfitSharingPeriodImplCopyWithImpl<$Res> ? _value.orderCount : orderCount // ignore: cast_nullable_to_non_nullable as int, - limitPurchase: null == limitPurchase - ? _value.limitPurchase - : limitPurchase // ignore: cast_nullable_to_non_nullable - as int, sdl: null == sdl ? _value.sdl : sdl // ignore: cast_nullable_to_non_nullable @@ -13732,7 +13694,6 @@ class _$ProfitSharingPeriodImpl extends _ProfitSharingPeriod { required this.periodEnd, required this.revenue, required this.orderCount, - required this.limitPurchase, required this.sdl, required this.limitTeam, this.month = '', @@ -13748,8 +13709,6 @@ class _$ProfitSharingPeriodImpl extends _ProfitSharingPeriod { @override final int orderCount; @override - final int limitPurchase; - @override final int sdl; @override final int limitTeam; @@ -13762,7 +13721,7 @@ class _$ProfitSharingPeriodImpl extends _ProfitSharingPeriod { @override String toString() { - return 'ProfitSharingPeriod(periodStart: $periodStart, periodEnd: $periodEnd, revenue: $revenue, orderCount: $orderCount, limitPurchase: $limitPurchase, sdl: $sdl, limitTeam: $limitTeam, month: $month, weekCount: $weekCount)'; + return 'ProfitSharingPeriod(periodStart: $periodStart, periodEnd: $periodEnd, revenue: $revenue, orderCount: $orderCount, sdl: $sdl, limitTeam: $limitTeam, month: $month, weekCount: $weekCount)'; } @override @@ -13777,8 +13736,6 @@ class _$ProfitSharingPeriodImpl extends _ProfitSharingPeriod { (identical(other.revenue, revenue) || other.revenue == revenue) && (identical(other.orderCount, orderCount) || other.orderCount == orderCount) && - (identical(other.limitPurchase, limitPurchase) || - other.limitPurchase == limitPurchase) && (identical(other.sdl, sdl) || other.sdl == sdl) && (identical(other.limitTeam, limitTeam) || other.limitTeam == limitTeam) && @@ -13794,7 +13751,6 @@ class _$ProfitSharingPeriodImpl extends _ProfitSharingPeriod { periodEnd, revenue, orderCount, - limitPurchase, sdl, limitTeam, month, @@ -13819,7 +13775,6 @@ abstract class _ProfitSharingPeriod extends ProfitSharingPeriod { required final DateTime periodEnd, required final int revenue, required final int orderCount, - required final int limitPurchase, required final int sdl, required final int limitTeam, final String month, @@ -13836,8 +13791,6 @@ abstract class _ProfitSharingPeriod extends ProfitSharingPeriod { @override int get orderCount; @override - int get limitPurchase; - @override int get sdl; @override int get limitTeam; @@ -15050,6 +15003,7 @@ mixin _$AnalyticFailure { required TResult Function(ApiFailure failure) serverError, required TResult Function() unexpectedError, required TResult Function() empty, + required TResult Function() notTeam, required TResult Function(String erroMessage) dynamicErrorMessage, }) => throw _privateConstructorUsedError; @optionalTypeArgs @@ -15057,6 +15011,7 @@ mixin _$AnalyticFailure { TResult? Function(ApiFailure failure)? serverError, TResult? Function()? unexpectedError, TResult? Function()? empty, + TResult? Function()? notTeam, TResult? Function(String erroMessage)? dynamicErrorMessage, }) => throw _privateConstructorUsedError; @optionalTypeArgs @@ -15064,6 +15019,7 @@ mixin _$AnalyticFailure { TResult Function(ApiFailure failure)? serverError, TResult Function()? unexpectedError, TResult Function()? empty, + TResult Function()? notTeam, TResult Function(String erroMessage)? dynamicErrorMessage, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -15072,6 +15028,7 @@ mixin _$AnalyticFailure { required TResult Function(_ServerError value) serverError, required TResult Function(_UnexpectedError value) unexpectedError, required TResult Function(_Empty value) empty, + required TResult Function(_NotTeam value) notTeam, required TResult Function(_DynamicErrorMessage value) dynamicErrorMessage, }) => throw _privateConstructorUsedError; @optionalTypeArgs @@ -15079,6 +15036,7 @@ mixin _$AnalyticFailure { TResult? Function(_ServerError value)? serverError, TResult? Function(_UnexpectedError value)? unexpectedError, TResult? Function(_Empty value)? empty, + TResult? Function(_NotTeam value)? notTeam, TResult? Function(_DynamicErrorMessage value)? dynamicErrorMessage, }) => throw _privateConstructorUsedError; @optionalTypeArgs @@ -15086,6 +15044,7 @@ mixin _$AnalyticFailure { TResult Function(_ServerError value)? serverError, TResult Function(_UnexpectedError value)? unexpectedError, TResult Function(_Empty value)? empty, + TResult Function(_NotTeam value)? notTeam, TResult Function(_DynamicErrorMessage value)? dynamicErrorMessage, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -15198,6 +15157,7 @@ class _$ServerErrorImpl implements _ServerError { required TResult Function(ApiFailure failure) serverError, required TResult Function() unexpectedError, required TResult Function() empty, + required TResult Function() notTeam, required TResult Function(String erroMessage) dynamicErrorMessage, }) { return serverError(failure); @@ -15209,6 +15169,7 @@ class _$ServerErrorImpl implements _ServerError { TResult? Function(ApiFailure failure)? serverError, TResult? Function()? unexpectedError, TResult? Function()? empty, + TResult? Function()? notTeam, TResult? Function(String erroMessage)? dynamicErrorMessage, }) { return serverError?.call(failure); @@ -15220,6 +15181,7 @@ class _$ServerErrorImpl implements _ServerError { TResult Function(ApiFailure failure)? serverError, TResult Function()? unexpectedError, TResult Function()? empty, + TResult Function()? notTeam, TResult Function(String erroMessage)? dynamicErrorMessage, required TResult orElse(), }) { @@ -15235,6 +15197,7 @@ class _$ServerErrorImpl implements _ServerError { required TResult Function(_ServerError value) serverError, required TResult Function(_UnexpectedError value) unexpectedError, required TResult Function(_Empty value) empty, + required TResult Function(_NotTeam value) notTeam, required TResult Function(_DynamicErrorMessage value) dynamicErrorMessage, }) { return serverError(this); @@ -15246,6 +15209,7 @@ class _$ServerErrorImpl implements _ServerError { TResult? Function(_ServerError value)? serverError, TResult? Function(_UnexpectedError value)? unexpectedError, TResult? Function(_Empty value)? empty, + TResult? Function(_NotTeam value)? notTeam, TResult? Function(_DynamicErrorMessage value)? dynamicErrorMessage, }) { return serverError?.call(this); @@ -15257,6 +15221,7 @@ class _$ServerErrorImpl implements _ServerError { TResult Function(_ServerError value)? serverError, TResult Function(_UnexpectedError value)? unexpectedError, TResult Function(_Empty value)? empty, + TResult Function(_NotTeam value)? notTeam, TResult Function(_DynamicErrorMessage value)? dynamicErrorMessage, required TResult orElse(), }) { @@ -15325,6 +15290,7 @@ class _$UnexpectedErrorImpl implements _UnexpectedError { required TResult Function(ApiFailure failure) serverError, required TResult Function() unexpectedError, required TResult Function() empty, + required TResult Function() notTeam, required TResult Function(String erroMessage) dynamicErrorMessage, }) { return unexpectedError(); @@ -15336,6 +15302,7 @@ class _$UnexpectedErrorImpl implements _UnexpectedError { TResult? Function(ApiFailure failure)? serverError, TResult? Function()? unexpectedError, TResult? Function()? empty, + TResult? Function()? notTeam, TResult? Function(String erroMessage)? dynamicErrorMessage, }) { return unexpectedError?.call(); @@ -15347,6 +15314,7 @@ class _$UnexpectedErrorImpl implements _UnexpectedError { TResult Function(ApiFailure failure)? serverError, TResult Function()? unexpectedError, TResult Function()? empty, + TResult Function()? notTeam, TResult Function(String erroMessage)? dynamicErrorMessage, required TResult orElse(), }) { @@ -15362,6 +15330,7 @@ class _$UnexpectedErrorImpl implements _UnexpectedError { required TResult Function(_ServerError value) serverError, required TResult Function(_UnexpectedError value) unexpectedError, required TResult Function(_Empty value) empty, + required TResult Function(_NotTeam value) notTeam, required TResult Function(_DynamicErrorMessage value) dynamicErrorMessage, }) { return unexpectedError(this); @@ -15373,6 +15342,7 @@ class _$UnexpectedErrorImpl implements _UnexpectedError { TResult? Function(_ServerError value)? serverError, TResult? Function(_UnexpectedError value)? unexpectedError, TResult? Function(_Empty value)? empty, + TResult? Function(_NotTeam value)? notTeam, TResult? Function(_DynamicErrorMessage value)? dynamicErrorMessage, }) { return unexpectedError?.call(this); @@ -15384,6 +15354,7 @@ class _$UnexpectedErrorImpl implements _UnexpectedError { TResult Function(_ServerError value)? serverError, TResult Function(_UnexpectedError value)? unexpectedError, TResult Function(_Empty value)? empty, + TResult Function(_NotTeam value)? notTeam, TResult Function(_DynamicErrorMessage value)? dynamicErrorMessage, required TResult orElse(), }) { @@ -15444,6 +15415,7 @@ class _$EmptyImpl implements _Empty { required TResult Function(ApiFailure failure) serverError, required TResult Function() unexpectedError, required TResult Function() empty, + required TResult Function() notTeam, required TResult Function(String erroMessage) dynamicErrorMessage, }) { return empty(); @@ -15455,6 +15427,7 @@ class _$EmptyImpl implements _Empty { TResult? Function(ApiFailure failure)? serverError, TResult? Function()? unexpectedError, TResult? Function()? empty, + TResult? Function()? notTeam, TResult? Function(String erroMessage)? dynamicErrorMessage, }) { return empty?.call(); @@ -15466,6 +15439,7 @@ class _$EmptyImpl implements _Empty { TResult Function(ApiFailure failure)? serverError, TResult Function()? unexpectedError, TResult Function()? empty, + TResult Function()? notTeam, TResult Function(String erroMessage)? dynamicErrorMessage, required TResult orElse(), }) { @@ -15481,6 +15455,7 @@ class _$EmptyImpl implements _Empty { required TResult Function(_ServerError value) serverError, required TResult Function(_UnexpectedError value) unexpectedError, required TResult Function(_Empty value) empty, + required TResult Function(_NotTeam value) notTeam, required TResult Function(_DynamicErrorMessage value) dynamicErrorMessage, }) { return empty(this); @@ -15492,6 +15467,7 @@ class _$EmptyImpl implements _Empty { TResult? Function(_ServerError value)? serverError, TResult? Function(_UnexpectedError value)? unexpectedError, TResult? Function(_Empty value)? empty, + TResult? Function(_NotTeam value)? notTeam, TResult? Function(_DynamicErrorMessage value)? dynamicErrorMessage, }) { return empty?.call(this); @@ -15503,6 +15479,7 @@ class _$EmptyImpl implements _Empty { TResult Function(_ServerError value)? serverError, TResult Function(_UnexpectedError value)? unexpectedError, TResult Function(_Empty value)? empty, + TResult Function(_NotTeam value)? notTeam, TResult Function(_DynamicErrorMessage value)? dynamicErrorMessage, required TResult orElse(), }) { @@ -15517,6 +15494,131 @@ abstract class _Empty implements AnalyticFailure { const factory _Empty() = _$EmptyImpl; } +/// @nodoc +abstract class _$$NotTeamImplCopyWith<$Res> { + factory _$$NotTeamImplCopyWith( + _$NotTeamImpl value, + $Res Function(_$NotTeamImpl) then, + ) = __$$NotTeamImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$NotTeamImplCopyWithImpl<$Res> + extends _$AnalyticFailureCopyWithImpl<$Res, _$NotTeamImpl> + implements _$$NotTeamImplCopyWith<$Res> { + __$$NotTeamImplCopyWithImpl( + _$NotTeamImpl _value, + $Res Function(_$NotTeamImpl) _then, + ) : super(_value, _then); + + /// Create a copy of AnalyticFailure + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$NotTeamImpl implements _NotTeam { + const _$NotTeamImpl(); + + @override + String toString() { + return 'AnalyticFailure.notTeam()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$NotTeamImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(ApiFailure failure) serverError, + required TResult Function() unexpectedError, + required TResult Function() empty, + required TResult Function() notTeam, + required TResult Function(String erroMessage) dynamicErrorMessage, + }) { + return notTeam(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(ApiFailure failure)? serverError, + TResult? Function()? unexpectedError, + TResult? Function()? empty, + TResult? Function()? notTeam, + TResult? Function(String erroMessage)? dynamicErrorMessage, + }) { + return notTeam?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(ApiFailure failure)? serverError, + TResult Function()? unexpectedError, + TResult Function()? empty, + TResult Function()? notTeam, + TResult Function(String erroMessage)? dynamicErrorMessage, + required TResult orElse(), + }) { + if (notTeam != null) { + return notTeam(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_ServerError value) serverError, + required TResult Function(_UnexpectedError value) unexpectedError, + required TResult Function(_Empty value) empty, + required TResult Function(_NotTeam value) notTeam, + required TResult Function(_DynamicErrorMessage value) dynamicErrorMessage, + }) { + return notTeam(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_ServerError value)? serverError, + TResult? Function(_UnexpectedError value)? unexpectedError, + TResult? Function(_Empty value)? empty, + TResult? Function(_NotTeam value)? notTeam, + TResult? Function(_DynamicErrorMessage value)? dynamicErrorMessage, + }) { + return notTeam?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_ServerError value)? serverError, + TResult Function(_UnexpectedError value)? unexpectedError, + TResult Function(_Empty value)? empty, + TResult Function(_NotTeam value)? notTeam, + TResult Function(_DynamicErrorMessage value)? dynamicErrorMessage, + required TResult orElse(), + }) { + if (notTeam != null) { + return notTeam(this); + } + return orElse(); + } +} + +abstract class _NotTeam implements AnalyticFailure { + const factory _NotTeam() = _$NotTeamImpl; +} + /// @nodoc abstract class _$$DynamicErrorMessageImplCopyWith<$Res> { factory _$$DynamicErrorMessageImplCopyWith( @@ -15594,6 +15696,7 @@ class _$DynamicErrorMessageImpl implements _DynamicErrorMessage { required TResult Function(ApiFailure failure) serverError, required TResult Function() unexpectedError, required TResult Function() empty, + required TResult Function() notTeam, required TResult Function(String erroMessage) dynamicErrorMessage, }) { return dynamicErrorMessage(erroMessage); @@ -15605,6 +15708,7 @@ class _$DynamicErrorMessageImpl implements _DynamicErrorMessage { TResult? Function(ApiFailure failure)? serverError, TResult? Function()? unexpectedError, TResult? Function()? empty, + TResult? Function()? notTeam, TResult? Function(String erroMessage)? dynamicErrorMessage, }) { return dynamicErrorMessage?.call(erroMessage); @@ -15616,6 +15720,7 @@ class _$DynamicErrorMessageImpl implements _DynamicErrorMessage { TResult Function(ApiFailure failure)? serverError, TResult Function()? unexpectedError, TResult Function()? empty, + TResult Function()? notTeam, TResult Function(String erroMessage)? dynamicErrorMessage, required TResult orElse(), }) { @@ -15631,6 +15736,7 @@ class _$DynamicErrorMessageImpl implements _DynamicErrorMessage { required TResult Function(_ServerError value) serverError, required TResult Function(_UnexpectedError value) unexpectedError, required TResult Function(_Empty value) empty, + required TResult Function(_NotTeam value) notTeam, required TResult Function(_DynamicErrorMessage value) dynamicErrorMessage, }) { return dynamicErrorMessage(this); @@ -15642,6 +15748,7 @@ class _$DynamicErrorMessageImpl implements _DynamicErrorMessage { TResult? Function(_ServerError value)? serverError, TResult? Function(_UnexpectedError value)? unexpectedError, TResult? Function(_Empty value)? empty, + TResult? Function(_NotTeam value)? notTeam, TResult? Function(_DynamicErrorMessage value)? dynamicErrorMessage, }) { return dynamicErrorMessage?.call(this); @@ -15653,6 +15760,7 @@ class _$DynamicErrorMessageImpl implements _DynamicErrorMessage { TResult Function(_ServerError value)? serverError, TResult Function(_UnexpectedError value)? unexpectedError, TResult Function(_Empty value)? empty, + TResult Function(_NotTeam value)? notTeam, TResult Function(_DynamicErrorMessage value)? dynamicErrorMessage, required TResult orElse(), }) { diff --git a/lib/domain/analytic/entities/profit_sharing_entity.dart b/lib/domain/analytic/entities/profit_sharing_entity.dart index 8026a4c..2929efc 100644 --- a/lib/domain/analytic/entities/profit_sharing_entity.dart +++ b/lib/domain/analytic/entities/profit_sharing_entity.dart @@ -59,6 +59,13 @@ class ProfitSharingCategory with _$ProfitSharingCategory { bool get hasTopProduct => topProduct.productId.isNotEmpty; + /// Porsi tim = omzet − SDL. Response tidak membawa porsi tim per kategori, + /// jadi dihitung dari nominalnya (docs/migrasi-profit-sharing.md). + int get teamShare => totalRevenue - sdl; + + /// % porsi tim terhadap omzet, yaitu 100 − fee owner. + double get teamPercent => 100 - ownerFeePercent; + /// Laba kotor memakai HPP standar (acuan yang dipakai di laporan laba rugi). int get grossProfit => totalRevenue - totalStandardHpp; @@ -106,16 +113,16 @@ class ProfitSharingBudget with _$ProfitSharingBudget { ); } +/// Porsi bawaan bagi hasil: owner (SDL) dan tim, dengan tim = 100 − owner. @freezed class ProfitSharingPercentage with _$ProfitSharingPercentage { const factory ProfitSharingPercentage({ - required double purchase, required double owner, required double team, }) = _ProfitSharingPercentage; factory ProfitSharingPercentage.empty() => - const ProfitSharingPercentage(purchase: 0, owner: 0, team: 0); + const ProfitSharingPercentage(owner: 0, team: 0); } /// Satu baris periode bagi hasil. Dipakai untuk total, mingguan, dan bulanan. @@ -129,7 +136,6 @@ class ProfitSharingPeriod with _$ProfitSharingPeriod { required DateTime periodEnd, required int revenue, required int orderCount, - required int limitPurchase, required int sdl, required int limitTeam, @Default('') String month, @@ -141,7 +147,6 @@ class ProfitSharingPeriod with _$ProfitSharingPeriod { periodEnd: DateTime.fromMillisecondsSinceEpoch(0), revenue: 0, orderCount: 0, - limitPurchase: 0, sdl: 0, limitTeam: 0, ); @@ -152,6 +157,9 @@ class ProfitSharingPeriod with _$ProfitSharingPeriod { /// induk, jadi porsinya dihitung dari nominal, bukan dari persentase tetap. double get sdlPercentage => revenue == 0 ? 0 : (sdl / revenue) * 100; + /// % porsi tim terhadap omzet, dihitung dari nominal dengan alasan yang sama. + double get teamPercentage => revenue == 0 ? 0 : (limitTeam / revenue) * 100; + int get averageOrderValue => orderCount == 0 ? 0 : revenue ~/ orderCount; } diff --git a/lib/domain/analytic/failures/analytic_failure.dart b/lib/domain/analytic/failures/analytic_failure.dart index d0b1a54..81da2d5 100644 --- a/lib/domain/analytic/failures/analytic_failure.dart +++ b/lib/domain/analytic/failures/analytic_failure.dart @@ -5,6 +5,9 @@ sealed class AnalyticFailure with _$AnalyticFailure { const factory AnalyticFailure.serverError(ApiFailure failure) = _ServerError; const factory AnalyticFailure.unexpectedError() = _UnexpectedError; const factory AnalyticFailure.empty() = _Empty; + + /// Detail profit sharing ditolak karena kategori induknya sudah non-team. + const factory AnalyticFailure.notTeam() = _NotTeam; const factory AnalyticFailure.dynamicErrorMessage(String erroMessage) = _DynamicErrorMessage; } diff --git a/lib/infrastructure/analytic/analytic_dtos.freezed.dart b/lib/infrastructure/analytic/analytic_dtos.freezed.dart index 2d56cfc..b747433 100644 --- a/lib/infrastructure/analytic/analytic_dtos.freezed.dart +++ b/lib/infrastructure/analytic/analytic_dtos.freezed.dart @@ -15656,8 +15656,6 @@ ProfitSharingPercentageDto _$ProfitSharingPercentageDtoFromJson( /// @nodoc mixin _$ProfitSharingPercentageDto { - @JsonKey(name: 'purchase') - num? get purchase => throw _privateConstructorUsedError; @JsonKey(name: 'owner') num? get owner => throw _privateConstructorUsedError; @JsonKey(name: 'team') @@ -15685,7 +15683,6 @@ abstract class $ProfitSharingPercentageDtoCopyWith<$Res> { >; @useResult $Res call({ - @JsonKey(name: 'purchase') num? purchase, @JsonKey(name: 'owner') num? owner, @JsonKey(name: 'team') num? team, }); @@ -15708,17 +15705,9 @@ class _$ProfitSharingPercentageDtoCopyWithImpl< /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({ - Object? purchase = freezed, - Object? owner = freezed, - Object? team = freezed, - }) { + $Res call({Object? owner = freezed, Object? team = freezed}) { return _then( _value.copyWith( - purchase: freezed == purchase - ? _value.purchase - : purchase // ignore: cast_nullable_to_non_nullable - as num?, owner: freezed == owner ? _value.owner : owner // ignore: cast_nullable_to_non_nullable @@ -15743,7 +15732,6 @@ abstract class _$$ProfitSharingPercentageDtoImplCopyWith<$Res> @override @useResult $Res call({ - @JsonKey(name: 'purchase') num? purchase, @JsonKey(name: 'owner') num? owner, @JsonKey(name: 'team') num? team, }); @@ -15766,17 +15754,9 @@ class __$$ProfitSharingPercentageDtoImplCopyWithImpl<$Res> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override - $Res call({ - Object? purchase = freezed, - Object? owner = freezed, - Object? team = freezed, - }) { + $Res call({Object? owner = freezed, Object? team = freezed}) { return _then( _$ProfitSharingPercentageDtoImpl( - purchase: freezed == purchase - ? _value.purchase - : purchase // ignore: cast_nullable_to_non_nullable - as num?, owner: freezed == owner ? _value.owner : owner // ignore: cast_nullable_to_non_nullable @@ -15794,7 +15774,6 @@ class __$$ProfitSharingPercentageDtoImplCopyWithImpl<$Res> @JsonSerializable() class _$ProfitSharingPercentageDtoImpl extends _ProfitSharingPercentageDto { const _$ProfitSharingPercentageDtoImpl({ - @JsonKey(name: 'purchase') this.purchase, @JsonKey(name: 'owner') this.owner, @JsonKey(name: 'team') this.team, }) : super._(); @@ -15803,9 +15782,6 @@ class _$ProfitSharingPercentageDtoImpl extends _ProfitSharingPercentageDto { Map json, ) => _$$ProfitSharingPercentageDtoImplFromJson(json); - @override - @JsonKey(name: 'purchase') - final num? purchase; @override @JsonKey(name: 'owner') final num? owner; @@ -15815,7 +15791,7 @@ class _$ProfitSharingPercentageDtoImpl extends _ProfitSharingPercentageDto { @override String toString() { - return 'ProfitSharingPercentageDto(purchase: $purchase, owner: $owner, team: $team)'; + return 'ProfitSharingPercentageDto(owner: $owner, team: $team)'; } @override @@ -15823,15 +15799,13 @@ class _$ProfitSharingPercentageDtoImpl extends _ProfitSharingPercentageDto { return identical(this, other) || (other.runtimeType == runtimeType && other is _$ProfitSharingPercentageDtoImpl && - (identical(other.purchase, purchase) || - other.purchase == purchase) && (identical(other.owner, owner) || other.owner == owner) && (identical(other.team, team) || other.team == team)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, purchase, owner, team); + int get hashCode => Object.hash(runtimeType, owner, team); /// Create a copy of ProfitSharingPercentageDto /// with the given fields replaced by the non-null parameter values. @@ -15852,7 +15826,6 @@ class _$ProfitSharingPercentageDtoImpl extends _ProfitSharingPercentageDto { abstract class _ProfitSharingPercentageDto extends ProfitSharingPercentageDto { const factory _ProfitSharingPercentageDto({ - @JsonKey(name: 'purchase') final num? purchase, @JsonKey(name: 'owner') final num? owner, @JsonKey(name: 'team') final num? team, }) = _$ProfitSharingPercentageDtoImpl; @@ -15861,9 +15834,6 @@ abstract class _ProfitSharingPercentageDto extends ProfitSharingPercentageDto { factory _ProfitSharingPercentageDto.fromJson(Map json) = _$ProfitSharingPercentageDtoImpl.fromJson; - @override - @JsonKey(name: 'purchase') - num? get purchase; @override @JsonKey(name: 'owner') num? get owner; @@ -15895,8 +15865,6 @@ mixin _$ProfitSharingPeriodDto { num? get revenue => throw _privateConstructorUsedError; @JsonKey(name: 'order_count') num? get orderCount => throw _privateConstructorUsedError; - @JsonKey(name: 'limit_purchase') - num? get limitPurchase => throw _privateConstructorUsedError; @JsonKey(name: 'sdl') num? get sdl => throw _privateConstructorUsedError; @JsonKey(name: 'limit_team') @@ -15928,7 +15896,6 @@ abstract class $ProfitSharingPeriodDtoCopyWith<$Res> { @JsonKey(name: 'period_end') DateTime? periodEnd, @JsonKey(name: 'revenue') num? revenue, @JsonKey(name: 'order_count') num? orderCount, - @JsonKey(name: 'limit_purchase') num? limitPurchase, @JsonKey(name: 'sdl') num? sdl, @JsonKey(name: 'limit_team') num? limitTeam, @JsonKey(name: 'month') String? month, @@ -15958,7 +15925,6 @@ class _$ProfitSharingPeriodDtoCopyWithImpl< Object? periodEnd = freezed, Object? revenue = freezed, Object? orderCount = freezed, - Object? limitPurchase = freezed, Object? sdl = freezed, Object? limitTeam = freezed, Object? month = freezed, @@ -15982,10 +15948,6 @@ class _$ProfitSharingPeriodDtoCopyWithImpl< ? _value.orderCount : orderCount // ignore: cast_nullable_to_non_nullable as num?, - limitPurchase: freezed == limitPurchase - ? _value.limitPurchase - : limitPurchase // ignore: cast_nullable_to_non_nullable - as num?, sdl: freezed == sdl ? _value.sdl : sdl // ignore: cast_nullable_to_non_nullable @@ -16022,7 +15984,6 @@ abstract class _$$ProfitSharingPeriodDtoImplCopyWith<$Res> @JsonKey(name: 'period_end') DateTime? periodEnd, @JsonKey(name: 'revenue') num? revenue, @JsonKey(name: 'order_count') num? orderCount, - @JsonKey(name: 'limit_purchase') num? limitPurchase, @JsonKey(name: 'sdl') num? sdl, @JsonKey(name: 'limit_team') num? limitTeam, @JsonKey(name: 'month') String? month, @@ -16049,7 +16010,6 @@ class __$$ProfitSharingPeriodDtoImplCopyWithImpl<$Res> Object? periodEnd = freezed, Object? revenue = freezed, Object? orderCount = freezed, - Object? limitPurchase = freezed, Object? sdl = freezed, Object? limitTeam = freezed, Object? month = freezed, @@ -16073,10 +16033,6 @@ class __$$ProfitSharingPeriodDtoImplCopyWithImpl<$Res> ? _value.orderCount : orderCount // ignore: cast_nullable_to_non_nullable as num?, - limitPurchase: freezed == limitPurchase - ? _value.limitPurchase - : limitPurchase // ignore: cast_nullable_to_non_nullable - as num?, sdl: freezed == sdl ? _value.sdl : sdl // ignore: cast_nullable_to_non_nullable @@ -16106,7 +16062,6 @@ class _$ProfitSharingPeriodDtoImpl extends _ProfitSharingPeriodDto { @JsonKey(name: 'period_end') this.periodEnd, @JsonKey(name: 'revenue') this.revenue, @JsonKey(name: 'order_count') this.orderCount, - @JsonKey(name: 'limit_purchase') this.limitPurchase, @JsonKey(name: 'sdl') this.sdl, @JsonKey(name: 'limit_team') this.limitTeam, @JsonKey(name: 'month') this.month, @@ -16129,9 +16084,6 @@ class _$ProfitSharingPeriodDtoImpl extends _ProfitSharingPeriodDto { @JsonKey(name: 'order_count') final num? orderCount; @override - @JsonKey(name: 'limit_purchase') - final num? limitPurchase; - @override @JsonKey(name: 'sdl') final num? sdl; @override @@ -16146,7 +16098,7 @@ class _$ProfitSharingPeriodDtoImpl extends _ProfitSharingPeriodDto { @override String toString() { - return 'ProfitSharingPeriodDto(periodStart: $periodStart, periodEnd: $periodEnd, revenue: $revenue, orderCount: $orderCount, limitPurchase: $limitPurchase, sdl: $sdl, limitTeam: $limitTeam, month: $month, weekCount: $weekCount)'; + return 'ProfitSharingPeriodDto(periodStart: $periodStart, periodEnd: $periodEnd, revenue: $revenue, orderCount: $orderCount, sdl: $sdl, limitTeam: $limitTeam, month: $month, weekCount: $weekCount)'; } @override @@ -16161,8 +16113,6 @@ class _$ProfitSharingPeriodDtoImpl extends _ProfitSharingPeriodDto { (identical(other.revenue, revenue) || other.revenue == revenue) && (identical(other.orderCount, orderCount) || other.orderCount == orderCount) && - (identical(other.limitPurchase, limitPurchase) || - other.limitPurchase == limitPurchase) && (identical(other.sdl, sdl) || other.sdl == sdl) && (identical(other.limitTeam, limitTeam) || other.limitTeam == limitTeam) && @@ -16179,7 +16129,6 @@ class _$ProfitSharingPeriodDtoImpl extends _ProfitSharingPeriodDto { periodEnd, revenue, orderCount, - limitPurchase, sdl, limitTeam, month, @@ -16210,7 +16159,6 @@ abstract class _ProfitSharingPeriodDto extends ProfitSharingPeriodDto { @JsonKey(name: 'period_end') final DateTime? periodEnd, @JsonKey(name: 'revenue') final num? revenue, @JsonKey(name: 'order_count') final num? orderCount, - @JsonKey(name: 'limit_purchase') final num? limitPurchase, @JsonKey(name: 'sdl') final num? sdl, @JsonKey(name: 'limit_team') final num? limitTeam, @JsonKey(name: 'month') final String? month, @@ -16234,9 +16182,6 @@ abstract class _ProfitSharingPeriodDto extends ProfitSharingPeriodDto { @JsonKey(name: 'order_count') num? get orderCount; @override - @JsonKey(name: 'limit_purchase') - num? get limitPurchase; - @override @JsonKey(name: 'sdl') num? get sdl; @override diff --git a/lib/infrastructure/analytic/analytic_dtos.g.dart b/lib/infrastructure/analytic/analytic_dtos.g.dart index c061949..7c8af7f 100644 --- a/lib/infrastructure/analytic/analytic_dtos.g.dart +++ b/lib/infrastructure/analytic/analytic_dtos.g.dart @@ -1251,18 +1251,13 @@ Map _$$ProfitSharingBudgetDtoImplToJson( _$ProfitSharingPercentageDtoImpl _$$ProfitSharingPercentageDtoImplFromJson( Map json, ) => _$ProfitSharingPercentageDtoImpl( - purchase: json['purchase'] as num?, owner: json['owner'] as num?, team: json['team'] as num?, ); Map _$$ProfitSharingPercentageDtoImplToJson( _$ProfitSharingPercentageDtoImpl instance, -) => { - 'purchase': instance.purchase, - 'owner': instance.owner, - 'team': instance.team, -}; +) => {'owner': instance.owner, 'team': instance.team}; _$ProfitSharingPeriodDtoImpl _$$ProfitSharingPeriodDtoImplFromJson( Map json, @@ -1275,7 +1270,6 @@ _$ProfitSharingPeriodDtoImpl _$$ProfitSharingPeriodDtoImplFromJson( : DateTime.parse(json['period_end'] as String), revenue: json['revenue'] as num?, orderCount: json['order_count'] as num?, - limitPurchase: json['limit_purchase'] as num?, sdl: json['sdl'] as num?, limitTeam: json['limit_team'] as num?, month: json['month'] as String?, @@ -1289,7 +1283,6 @@ Map _$$ProfitSharingPeriodDtoImplToJson( 'period_end': instance.periodEnd?.toIso8601String(), 'revenue': instance.revenue, 'order_count': instance.orderCount, - 'limit_purchase': instance.limitPurchase, 'sdl': instance.sdl, 'limit_team': instance.limitTeam, 'month': instance.month, diff --git a/lib/infrastructure/analytic/datasource/remote_data_provider.dart b/lib/infrastructure/analytic/datasource/remote_data_provider.dart index 523e2dc..09fdf39 100644 --- a/lib/infrastructure/analytic/datasource/remote_data_provider.dart +++ b/lib/infrastructure/analytic/datasource/remote_data_provider.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:data_channel/data_channel.dart'; +import 'package:dio/dio.dart' show Response; import 'package:injectable/injectable.dart'; import '../../../common/api/api_client.dart'; @@ -291,7 +292,12 @@ class AnalyticRemoteDataProvider { return DC.data(dto); } on ApiFailure catch (e, s) { - log('fetchExclusiveSummaryError', name: _logName, error: e, stackTrace: s); + log( + 'fetchExclusiveSummaryError', + name: _logName, + error: e, + stackTrace: s, + ); return DC.error(AnalyticFailure.serverError(e)); } } @@ -310,17 +316,18 @@ class AnalyticRemoteDataProvider { }; if (outletId != null) params['outlet_id'] = outletId; - final response = await _apiClient.get( - ApiPath.parentCategoryAnalytic, - params: params, - headers: getAuthorizationHeader(), - ); + final response = await _getProfitSharing('', params); - if (response.data['data'] == null) { + final failure = _profitSharingFailure(response); + if (failure != null) return DC.error(failure); + + final body = response.data; + final data = body is Map ? body['data'] : null; + if (data == null) { return DC.error(AnalyticFailure.empty()); } - final dto = ProfitSharingDto.fromJson(response.data['data']); + final dto = ProfitSharingDto.fromJson(data); return DC.data(dto); } on ApiFailure catch (e, s) { @@ -342,17 +349,18 @@ class AnalyticRemoteDataProvider { }; if (outletId != null) params['outlet_id'] = outletId; - final response = await _apiClient.get( - '${ApiPath.parentCategoryAnalytic}/$parentCategoryId', - params: params, - headers: getAuthorizationHeader(), - ); + final response = await _getProfitSharing('/$parentCategoryId', params); - if (response.data['data'] == null) { + final failure = _profitSharingFailure(response); + if (failure != null) return DC.error(failure); + + final body = response.data; + final data = body is Map ? body['data'] : null; + if (data == null) { return DC.error(AnalyticFailure.empty()); } - final dto = ProfitSharingDetailDto.fromJson(response.data['data']); + final dto = ProfitSharingDetailDto.fromJson(data); return DC.data(dto); } on ApiFailure catch (e, s) { @@ -365,4 +373,43 @@ class AnalyticRemoteDataProvider { return DC.error(AnalyticFailure.serverError(e)); } } + + /// GET ke endpoint profit sharing. Selama backend lama masih jalan path baru + /// mengembalikan 404, jadi dicoba sekali lagi ke path lama. + Future _getProfitSharing( + String subPath, + Map params, + ) async { + Future request(String basePath) => _apiClient.get( + '$basePath$subPath', + params: params, + headers: getAuthorizationHeader(), + // Status 5xx tidak dilempar supaya `cause` di body masih bisa dibaca. + validateStatus: (status) => status != null && status < 600, + ); + + final response = await request(ApiPath.profitSharingAnalytic); + if (response.statusCode != 404) return response; + return request(ApiPath.profitSharingAnalyticLegacy); + } + + /// Failure dari status error endpoint profit sharing, null kalau berhasil. + /// Detail kategori non-team ditolak 500 dengan `cause` "... is not a team". + AnalyticFailure? _profitSharingFailure(Response response) { + final status = response.statusCode ?? 0; + if (status == 404) { + return const AnalyticFailure.serverError(ApiFailure.notFound(null)); + } + if (status < 500) return null; + + final body = response.data; + final errors = body is Map ? body['errors'] : null; + final isNotTeam = + errors is List && + errors.any( + (e) => e is Map && '${e['cause']}'.contains('is not a team'), + ); + if (isNotTeam) return const AnalyticFailure.notTeam(); + return const AnalyticFailure.serverError(ApiFailure.internalServerError()); + } } diff --git a/lib/infrastructure/analytic/dto/profit_sharing_dto.dart b/lib/infrastructure/analytic/dto/profit_sharing_dto.dart index 2a98eb7..c53c836 100644 --- a/lib/infrastructure/analytic/dto/profit_sharing_dto.dart +++ b/lib/infrastructure/analytic/dto/profit_sharing_dto.dart @@ -121,7 +121,6 @@ class ProfitSharingPercentageDto with _$ProfitSharingPercentageDto { const ProfitSharingPercentageDto._(); const factory ProfitSharingPercentageDto({ - @JsonKey(name: 'purchase') num? purchase, @JsonKey(name: 'owner') num? owner, @JsonKey(name: 'team') num? team, }) = _ProfitSharingPercentageDto; @@ -130,7 +129,6 @@ class ProfitSharingPercentageDto with _$ProfitSharingPercentageDto { _$ProfitSharingPercentageDtoFromJson(json); ProfitSharingPercentage toDomain() => ProfitSharingPercentage( - purchase: purchase?.toDouble() ?? 0, owner: owner?.toDouble() ?? 0, team: team?.toDouble() ?? 0, ); @@ -145,7 +143,6 @@ class ProfitSharingPeriodDto with _$ProfitSharingPeriodDto { @JsonKey(name: 'period_end') DateTime? periodEnd, @JsonKey(name: 'revenue') num? revenue, @JsonKey(name: 'order_count') num? orderCount, - @JsonKey(name: 'limit_purchase') num? limitPurchase, @JsonKey(name: 'sdl') num? sdl, @JsonKey(name: 'limit_team') num? limitTeam, @JsonKey(name: 'month') String? month, @@ -161,7 +158,6 @@ class ProfitSharingPeriodDto with _$ProfitSharingPeriodDto { periodEnd: periodEnd?.toLocal() ?? DateTime.fromMillisecondsSinceEpoch(0), revenue: revenue?.toInt() ?? 0, orderCount: orderCount?.toInt() ?? 0, - limitPurchase: limitPurchase?.toInt() ?? 0, sdl: sdl?.toInt() ?? 0, limitTeam: limitTeam?.toInt() ?? 0, month: month ?? '', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 320e57a..ac5fb86 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -541,18 +541,35 @@ "@total_inventory_value": {}, "profit_sharing": "Profit Sharing", "@profit_sharing": {}, - "profit_sharing_desc": "Revenue allocation for purchasing, owner, and team", + "profit_sharing_desc": "Revenue allocation for owner (SDL) and team", "@profit_sharing_desc": {}, "profit_sharing_allocation": "Profit Sharing Allocation", "@profit_sharing_allocation": {}, - "share_purchase": "Purchasing", - "@share_purchase": {}, + "profit_sharing_not_team": "This category is no longer part of profit sharing", + "@profit_sharing_not_team": {}, "share_owner": "Owner", "@share_owner": {}, "share_team": "Team", "@share_team": {}, "sdl": "SDL", "@sdl": {}, + "sdl_summary": "SDL Summary", + "@sdl_summary": {}, + "sdl_desc": "Owner's share of revenue", + "@sdl_desc": {}, + "sdl_per_parent_category": "SDL per Parent Category", + "@sdl_per_parent_category": {}, + "sdl_per_week": "SDL per Week", + "@sdl_per_week": {}, + "from_revenue": "from {revenue} revenue", + "@from_revenue": { + "placeholders": { + "revenue": { + "type": "String", + "example": "Rp 52,500,000" + } + } + }, "top_product": "Top Product", "@top_product": {}, "weekly": "Weekly", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 9f0bca9..2059651 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -541,18 +541,35 @@ "@total_inventory_value": {}, "profit_sharing": "Bagi Hasil", "@profit_sharing": {}, - "profit_sharing_desc": "Alokasi omzet untuk belanja, owner, dan tim", + "profit_sharing_desc": "Alokasi omzet untuk owner (SDL) dan tim", "@profit_sharing_desc": {}, "profit_sharing_allocation": "Alokasi Bagi Hasil", "@profit_sharing_allocation": {}, - "share_purchase": "Belanja", - "@share_purchase": {}, + "profit_sharing_not_team": "Kategori ini sudah tidak ikut bagi hasil", + "@profit_sharing_not_team": {}, "share_owner": "Owner", "@share_owner": {}, "share_team": "Tim", "@share_team": {}, "sdl": "SDL", "@sdl": {}, + "sdl_summary": "Ringkasan SDL", + "@sdl_summary": {}, + "sdl_desc": "Bagian owner dari omzet", + "@sdl_desc": {}, + "sdl_per_parent_category": "SDL per Kategori Induk", + "@sdl_per_parent_category": {}, + "sdl_per_week": "SDL per Minggu", + "@sdl_per_week": {}, + "from_revenue": "dari omzet {revenue}", + "@from_revenue": { + "placeholders": { + "revenue": { + "type": "String", + "example": "Rp 52.500.000" + } + } + }, "top_product": "Produk Terlaris", "@top_product": {}, "weekly": "Mingguan", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 27cd9cd..e57b8c2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1574,7 +1574,7 @@ abstract class AppLocalizations { /// No description provided for @profit_sharing_desc. /// /// In en, this message translates to: - /// **'Revenue allocation for purchasing, owner, and team'** + /// **'Revenue allocation for owner (SDL) and team'** String get profit_sharing_desc; /// No description provided for @profit_sharing_allocation. @@ -1583,11 +1583,11 @@ abstract class AppLocalizations { /// **'Profit Sharing Allocation'** String get profit_sharing_allocation; - /// No description provided for @share_purchase. + /// No description provided for @profit_sharing_not_team. /// /// In en, this message translates to: - /// **'Purchasing'** - String get share_purchase; + /// **'This category is no longer part of profit sharing'** + String get profit_sharing_not_team; /// No description provided for @share_owner. /// @@ -1607,6 +1607,36 @@ abstract class AppLocalizations { /// **'SDL'** String get sdl; + /// No description provided for @sdl_summary. + /// + /// In en, this message translates to: + /// **'SDL Summary'** + String get sdl_summary; + + /// No description provided for @sdl_desc. + /// + /// In en, this message translates to: + /// **'Owner\'s share of revenue'** + String get sdl_desc; + + /// No description provided for @sdl_per_parent_category. + /// + /// In en, this message translates to: + /// **'SDL per Parent Category'** + String get sdl_per_parent_category; + + /// No description provided for @sdl_per_week. + /// + /// In en, this message translates to: + /// **'SDL per Week'** + String get sdl_per_week; + + /// No description provided for @from_revenue. + /// + /// In en, this message translates to: + /// **'from {revenue} revenue'** + String from_revenue(String revenue); + /// No description provided for @top_product. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6fdd488..477055e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -761,13 +761,13 @@ class AppLocalizationsEn extends AppLocalizations { String get profit_sharing => 'Profit Sharing'; @override - String get profit_sharing_desc => 'Revenue allocation for purchasing, owner, and team'; + String get profit_sharing_desc => 'Revenue allocation for owner (SDL) and team'; @override String get profit_sharing_allocation => 'Profit Sharing Allocation'; @override - String get share_purchase => 'Purchasing'; + String get profit_sharing_not_team => 'This category is no longer part of profit sharing'; @override String get share_owner => 'Owner'; @@ -778,6 +778,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sdl => 'SDL'; + @override + String get sdl_summary => 'SDL Summary'; + + @override + String get sdl_desc => 'Owner\'s share of revenue'; + + @override + String get sdl_per_parent_category => 'SDL per Parent Category'; + + @override + String get sdl_per_week => 'SDL per Week'; + + @override + String from_revenue(String revenue) { + return 'from $revenue revenue'; + } + @override String get top_product => 'Top Product'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index ae07cd7..393a0bc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -761,13 +761,13 @@ class AppLocalizationsId extends AppLocalizations { String get profit_sharing => 'Bagi Hasil'; @override - String get profit_sharing_desc => 'Alokasi omzet untuk belanja, owner, dan tim'; + String get profit_sharing_desc => 'Alokasi omzet untuk owner (SDL) dan tim'; @override String get profit_sharing_allocation => 'Alokasi Bagi Hasil'; @override - String get share_purchase => 'Belanja'; + String get profit_sharing_not_team => 'Kategori ini sudah tidak ikut bagi hasil'; @override String get share_owner => 'Owner'; @@ -778,6 +778,23 @@ class AppLocalizationsId extends AppLocalizations { @override String get sdl => 'SDL'; + @override + String get sdl_summary => 'Ringkasan SDL'; + + @override + String get sdl_desc => 'Bagian owner dari omzet'; + + @override + String get sdl_per_parent_category => 'SDL per Kategori Induk'; + + @override + String get sdl_per_week => 'SDL per Minggu'; + + @override + String from_revenue(String revenue) { + return 'dari omzet $revenue'; + } + @override String get top_product => 'Produk Terlaris'; diff --git a/lib/presentation/pages/profit_sharing/profit_sharing_detail_page.dart b/lib/presentation/pages/profit_sharing/profit_sharing_detail_page.dart index 473d435..d1bfabc 100644 --- a/lib/presentation/pages/profit_sharing/profit_sharing_detail_page.dart +++ b/lib/presentation/pages/profit_sharing/profit_sharing_detail_page.dart @@ -9,8 +9,10 @@ import '../../../common/theme/theme.dart'; import '../../../domain/analytic/analytic.dart'; import '../../../injection.dart'; import '../../components/spacer/spacer.dart'; +import 'widgets/period_label.dart'; import 'widgets/profit_sharing_allocation.dart'; import 'widgets/profit_sharing_detail_header.dart'; +import 'widgets/profit_sharing_sdl.dart'; import 'widgets/profit_sharing_status.dart'; import 'widgets/profit_sharing_subcategories.dart'; @@ -48,75 +50,116 @@ class ProfitSharingDetailPage extends StatelessWidget return Scaffold( backgroundColor: AppColor.background, body: - BlocBuilder< + BlocListener< ProfitSharingDetailLoaderBloc, ProfitSharingDetailLoaderState >( - builder: (context, state) { - return RefreshIndicator( - backgroundColor: AppColor.white, - color: AppColor.primary, - onRefresh: () async { - context.read().add( - _fetchEvent(), - ); - await context - .read() - .stream - .firstWhere((s) => !s.isFetching); - }, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: ProfitSharingDetailHeader( - state: state, - fallbackTitle: parentCategoryName, - ), - ), - - const SliverToBoxAdapter(child: SpaceHeight(16)), - - if (state.isFetching) - SliverToBoxAdapter(child: _buildShimmer()) - else ...[ - SliverToBoxAdapter( - child: _SummaryCard(summary: state.detail.summary), - ), - - const SliverToBoxAdapter(child: SpaceHeight(16)), - - SliverToBoxAdapter( - child: ProfitSharingAllocation( - budget: state.detail.budget, - ), - ), - - const SliverToBoxAdapter(child: SpaceHeight(16)), - - SliverToBoxAdapter( - child: ProfitSharingSubCategories( - categories: state.detail.categories, - expandedCategoryId: state.expandedCategoryId, - onToggle: (categoryId) => - context.read().add( - ProfitSharingDetailLoaderEvent.expandedCategoryChanged( - categoryId, - ), - ), - ), - ), - ], - - const SliverToBoxAdapter(child: SizedBox(height: 100)), - ], - ), + listenWhen: (previous, current) => + previous.failureOption != current.failureOption, + listener: (context, state) { + final isNotTeam = state.failureOption.fold( + () => false, + (f) => f.maybeMap(notTeam: (_) => true, orElse: () => false), ); + // Kategori induk dimatikan dari team setelah list dimuat: + // kembali ke list dengan hasil `true` supaya list memuat ulang. + if (isNotTeam) context.router.maybePop(true); }, + child: _buildContent(), ), ); } + Widget _buildContent() { + return BlocBuilder< + ProfitSharingDetailLoaderBloc, + ProfitSharingDetailLoaderState + >( + builder: (context, state) { + return RefreshIndicator( + backgroundColor: AppColor.white, + color: AppColor.primary, + onRefresh: () async { + context.read().add(_fetchEvent()); + await context + .read() + .stream + .firstWhere((s) => !s.isFetching); + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: ProfitSharingDetailHeader( + state: state, + fallbackTitle: parentCategoryName, + ), + ), + + const SliverToBoxAdapter(child: SpaceHeight(16)), + + if (state.isFetching) + SliverToBoxAdapter(child: _buildShimmer()) + else ...[ + SliverToBoxAdapter( + child: _SummaryCard(summary: state.detail.summary), + ), + + const SliverToBoxAdapter(child: SpaceHeight(16)), + + SliverToBoxAdapter( + child: _buildSdlSection(context, state.detail), + ), + + const SliverToBoxAdapter(child: SpaceHeight(16)), + + SliverToBoxAdapter( + child: ProfitSharingAllocation(budget: state.detail.budget), + ), + + const SliverToBoxAdapter(child: SpaceHeight(16)), + + SliverToBoxAdapter( + child: ProfitSharingSubCategories( + categories: state.detail.categories, + expandedCategoryId: state.expandedCategoryId, + onToggle: (categoryId) => + context.read().add( + ProfitSharingDetailLoaderEvent.expandedCategoryChanged( + categoryId, + ), + ), + ), + ), + ], + + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ), + ); + }, + ); + } + + Widget _buildSdlSection(BuildContext context, ProfitSharingDetail detail) { + final summary = detail.summary; + + return ProfitSharingSdlSection( + sdl: summary.sdl, + revenue: summary.totalRevenue, + percent: summary.ownerFeePercent, + breakdownTitle: context.lang.sdl_per_week, + items: [ + for (final period in detail.budget.weekly.where((e) => e.hasRevenue)) + ProfitSharingSdlItem( + label: formatPeriodLabel(period.periodStart, period.periodEnd), + sdl: period.sdl, + revenue: period.revenue, + ), + ], + ); + } + Widget _buildShimmer() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -187,6 +230,7 @@ class _SummaryCard extends StatelessWidget { icon: Icons.category_rounded, label: context.lang.sub_category, value: '${summary.categoryCount}', + color: ProfitSharingColor.subCategory, ), ), const SpaceWidth(10), @@ -195,7 +239,7 @@ class _SummaryCard extends StatelessWidget { icon: Icons.inventory_2_rounded, label: context.lang.products, value: '${summary.productCount}', - color: AppColor.info, + color: ProfitSharingColor.product, ), ), const SpaceWidth(10), @@ -204,20 +248,13 @@ class _SummaryCard extends StatelessWidget { icon: Icons.receipt_long_rounded, label: context.lang.orders, value: summary.orderCount.thousandFormat, - color: AppColor.warning, + color: ProfitSharingColor.order, ), ), ], ), - const SpaceHeight(12), - - ProfitSharingSdlStrip( - amount: summary.sdl, - feePercent: summary.ownerFeePercent, - ), - - const SpaceHeight(12), + const SpaceHeight(16), Row( children: [ diff --git a/lib/presentation/pages/profit_sharing/profit_sharing_page.dart b/lib/presentation/pages/profit_sharing/profit_sharing_page.dart index 6f860a8..8b7deb5 100644 --- a/lib/presentation/pages/profit_sharing/profit_sharing_page.dart +++ b/lib/presentation/pages/profit_sharing/profit_sharing_page.dart @@ -4,15 +4,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shimmer/shimmer.dart'; import '../../../application/analytic/profit_sharing_loader/profit_sharing_loader_bloc.dart'; +import '../../../common/extension/extension.dart'; import '../../../common/theme/theme.dart'; +import '../../../domain/analytic/analytic.dart'; import '../../../injection.dart'; import '../../components/field/date_range_picker_field.dart'; import '../../components/spacer/spacer.dart'; +import '../../components/toast/flushbar.dart'; import '../../router/app_router.gr.dart'; import 'widgets/profit_sharing_allocation.dart'; import 'widgets/profit_sharing_categories.dart'; import 'widgets/profit_sharing_header.dart'; import 'widgets/profit_sharing_periods.dart'; +import 'widgets/profit_sharing_sdl.dart'; @RoutePage() class ProfitSharingPage extends StatefulWidget implements AutoRouteWrapper { @@ -124,20 +128,23 @@ class _ProfitSharingPageState extends State opacity: _fadeAnimation, child: ProfitSharingCategories( categories: state.profitSharing.categories, - onCategoryTap: (category) => context.router.push( - ProfitSharingDetailRoute( - parentCategoryId: category.parentCategoryId, - parentCategoryName: category.parentCategoryName, - dateFrom: state.dateFrom, - dateTo: state.dateTo, - ), - ), + onCategoryTap: (category) => + _openDetail(context, state, category), ), ), ), const SliverToBoxAdapter(child: SpaceHeight(16)), + SliverToBoxAdapter( + child: FadeTransition( + opacity: _fadeAnimation, + child: _buildSdlSection(context, state), + ), + ), + + const SliverToBoxAdapter(child: SpaceHeight(16)), + SliverToBoxAdapter( child: FadeTransition( opacity: _fadeAnimation, @@ -174,6 +181,58 @@ class _ProfitSharingPageState extends State ); } + /// Detail kembali dengan `true` kalau kategori induknya sudah non-team; + /// list dimuat ulang supaya kategori itu hilang. + Future _openDetail( + BuildContext context, + ProfitSharingLoaderState state, + ProfitSharingCategory category, + ) async { + final isNotTeam = await context.router.push( + ProfitSharingDetailRoute( + parentCategoryId: category.parentCategoryId, + parentCategoryName: category.parentCategoryName, + dateFrom: state.dateFrom, + dateTo: state.dateTo, + ), + ); + if (isNotTeam != true || !context.mounted) return; + + AppFlushbar.showError(context, context.lang.profit_sharing_not_team); + context.read().add( + const ProfitSharingLoaderEvent.fetched(), + ); + } + + Widget _buildSdlSection( + BuildContext context, + ProfitSharingLoaderState state, + ) { + final total = state.profitSharing.budget.total; + final categories = [...state.profitSharing.categories] + ..sort((a, b) => b.sdl.compareTo(a.sdl)); + + return ProfitSharingSdlSection( + sdl: total.sdl, + revenue: total.revenue, + // Fee owner beda tiap kategori induk, jadi yang ditampilkan persen + // efektifnya; persentase bawaan hanya dipakai saat belum ada omzet. + percent: total.hasRevenue + ? total.sdlPercentage + : state.profitSharing.budget.percentages.owner, + breakdownTitle: context.lang.sdl_per_parent_category, + items: [ + for (final category in categories) + ProfitSharingSdlItem( + label: category.parentCategoryName, + sdl: category.sdl, + revenue: category.totalRevenue, + feePercent: category.ownerFeePercent, + ), + ], + ); + } + Widget _buildShimmer() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_allocation.dart b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_allocation.dart index 58376e8..ea6bc5d 100644 --- a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_allocation.dart +++ b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_allocation.dart @@ -6,10 +6,20 @@ import '../../../../domain/analytic/analytic.dart'; import '../../../components/spacer/spacer.dart'; /// Warna tetap untuk tiap pos bagi hasil supaya konsisten di semua bagian. +/// Sengaja pakai warna jenuh, bukan default Material yang pucat. class ProfitSharingColor { - static const Color purchase = Color(0xFF2196F3); - static const Color owner = Color(0xFF4CAF50); - static const Color team = Color(0xFFFF9800); + static const Color owner = Color(0xFF00B341); + static const Color team = Color(0xFFFF6A00); + + // Status margin (Sehat / Waspada / Tidak Sehat). + static const Color healthy = Color(0xFF00B341); + static const Color watch = Color(0xFFFF9500); + static const Color critical = Color(0xFFFF1E3C); + + // Tile ringkasan. + static const Color subCategory = AppColor.primary; + static const Color product = Color(0xFF7B2FF7); + static const Color order = Color(0xFF00A3D9); } class ProfitSharingAllocation extends StatelessWidget { @@ -21,11 +31,14 @@ class ProfitSharingAllocation extends StatelessWidget { Widget build(BuildContext context) { final total = budget.total; final percentages = budget.percentages; - // Fee owner berbeda tiap kategori induk, jadi porsi SDL dihitung dari + // Fee owner berbeda tiap kategori induk, jadi porsi dihitung dari // nominalnya; persentase bawaan hanya dipakai saat belum ada omzet. final sdlPercentage = total.hasRevenue ? total.sdlPercentage : percentages.owner; + final teamPercentage = total.hasRevenue + ? total.teamPercentage + : percentages.team; return Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -55,22 +68,10 @@ class ProfitSharingAllocation extends StatelessWidget { const SpaceHeight(16), - ProfitSharingBar( - purchase: percentages.purchase, - owner: sdlPercentage, - team: percentages.team, - ), + ProfitSharingBar(owner: sdlPercentage, team: teamPercentage), const SpaceHeight(20), - _row( - context: context, - color: ProfitSharingColor.purchase, - label: context.lang.share_purchase, - percentage: percentages.purchase, - amount: total.limitPurchase, - ), - const Divider(height: 24, color: AppColor.borderLight), _row( context: context, color: ProfitSharingColor.owner, @@ -83,7 +84,7 @@ class ProfitSharingAllocation extends StatelessWidget { context: context, color: ProfitSharingColor.team, label: context.lang.share_team, - percentage: percentages.team, + percentage: teamPercentage, amount: total.limitTeam, ), ], @@ -117,14 +118,14 @@ class ProfitSharingAllocation extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: color.withOpacity(0.12), + color: color, borderRadius: BorderRadius.circular(6), ), child: Text( '${percentage.toStringAsFixed(percentage % 1 == 0 ? 0 : 1)}%', style: AppStyle.xs.copyWith( - color: color, - fontWeight: FontWeight.w700, + color: AppColor.textWhite, + fontWeight: FontWeight.w800, ), ), ), @@ -145,16 +146,15 @@ class ProfitSharingAllocation extends StatelessWidget { } } -/// Bar proporsi belanja / SDL (owner) / tim. +/// Bar proporsi SDL (owner) / tim dalam persen omzet. Sisa yang belum terbagi +/// (backend lama masih menyisihkan porsi belanja) tampil sebagai track kosong. class ProfitSharingBar extends StatelessWidget { - final double purchase; final double owner; final double team; final double height; const ProfitSharingBar({ super.key, - required this.purchase, required this.owner, required this.team, this.height = 12, @@ -162,7 +162,7 @@ class ProfitSharingBar extends StatelessWidget { @override Widget build(BuildContext context) { - final total = purchase + owner + team; + final total = owner + team; if (total <= 0) { return Container( height: height, @@ -179,9 +179,9 @@ class ProfitSharingBar extends StatelessWidget { height: height, child: Row( children: [ - _segment(purchase, ProfitSharingColor.purchase), _segment(owner, ProfitSharingColor.owner), _segment(team, ProfitSharingColor.team), + _segment(100 - total, AppColor.borderLight), ].whereType().toList(), ), ), diff --git a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_categories.dart b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_categories.dart index a5f36a8..fd834ab 100644 --- a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_categories.dart +++ b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_categories.dart @@ -4,9 +4,10 @@ import '../../../../common/extension/extension.dart'; import '../../../../common/theme/theme.dart'; import '../../../../domain/analytic/analytic.dart'; import '../../../components/spacer/spacer.dart'; +import 'profit_sharing_allocation.dart'; import 'profit_sharing_status.dart'; -/// Ringkasan omzet & SDL per kategori induk (parent category). +/// Ringkasan porsi tim (omzet − SDL) per kategori induk (parent category). /// Tiap kartu bisa diketuk untuk melihat rincian sub kategori & produknya. class ProfitSharingCategories extends StatelessWidget { final List categories; @@ -21,9 +22,9 @@ class ProfitSharingCategories extends StatelessWidget { @override Widget build(BuildContext context) { final sorted = [...categories] - ..sort((a, b) => b.totalRevenue.compareTo(a.totalRevenue)); + ..sort((a, b) => b.teamShare.compareTo(a.teamShare)); - final totalRevenue = sorted.fold(0, (sum, e) => sum + e.totalRevenue); + final totalTeam = sorted.fold(0, (sum, e) => sum + e.teamShare); return Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -75,9 +76,7 @@ class ProfitSharingCategories extends StatelessWidget { child: _CategoryCard( rank: i + 1, category: sorted[i], - share: totalRevenue == 0 - ? 0 - : sorted[i].totalRevenue / totalRevenue, + share: totalTeam == 0 ? 0 : sorted[i].teamShare / totalTeam, onTap: () => onCategoryTap(sorted[i]), ), ), @@ -106,18 +105,11 @@ class _CategoryCard extends StatelessWidget { Widget build(BuildContext context) { final isTop = rank == 1; - return Material( - color: AppColor.background, - borderRadius: BorderRadius.circular(14), + return ProfitSharingItemCard( child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Container( + child: Padding( padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColor.borderLight), - ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -188,7 +180,7 @@ class _CategoryCard extends StatelessWidget { children: [ Expanded( child: Text( - category.totalRevenue.currencyFormatRp, + category.teamShare.currencyFormatRp, style: AppStyle.xl.copyWith( color: AppColor.textPrimary, fontWeight: FontWeight.w800, @@ -204,21 +196,53 @@ class _CategoryCard extends StatelessWidget { vertical: 3, ), decoration: BoxDecoration( - color: AppColor.primary.withOpacity(0.08), + color: AppColor.primary, borderRadius: BorderRadius.circular(20), ), child: Text( formatPercent(share * 100), style: AppStyle.xs.copyWith( - color: AppColor.primary, - fontWeight: FontWeight.w700, + color: AppColor.textWhite, + fontWeight: FontWeight.w800, ), ), ), ], ), - const SpaceHeight(8), + const SpaceHeight(4), + + Row( + children: [ + Text( + context.lang.share_team, + style: AppStyle.xs.copyWith( + color: ProfitSharingColor.team, + fontWeight: FontWeight.w700, + ), + ), + const SpaceWidth(4), + ProfitSharingFeeBadge( + feePercent: category.teamPercent, + color: ProfitSharingColor.team, + ), + const SpaceWidth(6), + Expanded( + child: Text( + context.lang.from_revenue( + category.totalRevenue.currencyFormatRp, + ), + style: AppStyle.xs.copyWith( + color: AppColor.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + const SpaceHeight(10), ClipRRect( borderRadius: BorderRadius.circular(6), @@ -232,15 +256,8 @@ class _CategoryCard extends StatelessWidget { ), ), - const SpaceHeight(12), - - ProfitSharingSdlStrip( - amount: category.sdl, - feePercent: category.ownerFeePercent, - ), - if (category.hasTopProduct) ...[ - const SpaceHeight(10), + const SpaceHeight(12), _TopProduct(product: category.topProduct), ], ], @@ -333,7 +350,7 @@ class _TotalCard extends StatelessWidget { 0, (sum, e) => sum + e.totalRevenue, ); - final totalSdl = categories.fold(0, (sum, e) => sum + e.sdl); + final totalTeam = categories.fold(0, (sum, e) => sum + e.teamShare); return Container( padding: const EdgeInsets.all(16), @@ -349,7 +366,8 @@ class _TotalCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.lang.grand_total, + '${context.lang.grand_total} ' + '${context.lang.share_team.toUpperCase()}', style: AppStyle.xs.copyWith( color: AppColor.textWhite.withOpacity(0.8), fontWeight: FontWeight.w700, @@ -358,7 +376,7 @@ class _TotalCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - totalRevenue.currencyFormatRp, + totalTeam.currencyFormatRp, style: AppStyle.xl.copyWith( color: AppColor.textWhite, fontWeight: FontWeight.w900, @@ -369,8 +387,8 @@ class _TotalCard extends StatelessWidget { ProfitSharingHeaderStats( stats: [ + (context.lang.revenue, totalRevenue.currencyFormatRp), (context.lang.qty, totalQuantity.thousandFormat), - (context.lang.sdl, totalSdl.currencyFormatRp), ], ), ], diff --git a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_periods.dart b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_periods.dart index 04b00a2..94a6651 100644 --- a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_periods.dart +++ b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_periods.dart @@ -7,6 +7,7 @@ import '../../../../domain/analytic/analytic.dart'; import '../../../components/spacer/spacer.dart'; import 'period_label.dart'; import 'profit_sharing_allocation.dart'; +import 'profit_sharing_status.dart'; class ProfitSharingPeriods extends StatelessWidget { final ProfitSharingLoaderState state; @@ -151,78 +152,73 @@ class _PeriodCard extends StatelessWidget { ? '${formatPeriodLabel(period.periodStart, period.periodEnd)} - ${context.lang.week_count(period.weekCount)}' : context.lang.order_count_label(period.orderCount); - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColor.background, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColor.borderLight), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: AppStyle.md.copyWith( - fontWeight: FontWeight.w700, - color: AppColor.textPrimary, + return ProfitSharingItemCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w700, + color: AppColor.textPrimary, + ), ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: AppStyle.xs.copyWith( - color: AppColor.textSecondary, + const SizedBox(height: 2), + Text( + subtitle, + style: AppStyle.xs.copyWith( + color: AppColor.textSecondary, + ), ), - ), - ], + ], + ), ), - ), - const SpaceWidth(8), - Text( - period.revenue.currencyFormatRp, - style: AppStyle.md.copyWith( - fontWeight: FontWeight.w800, - color: AppColor.primary, + const SpaceWidth(8), + Text( + period.revenue.currencyFormatRp, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w800, + color: AppColor.primary, + ), ), - ), - ], - ), + ], + ), - const SpaceHeight(12), + const SpaceHeight(12), - ProfitSharingBar( - purchase: percentages.purchase, - owner: period.hasRevenue ? period.sdlPercentage : percentages.owner, - team: percentages.team, - height: 6, - ), + ProfitSharingBar( + owner: period.hasRevenue + ? period.sdlPercentage + : percentages.owner, + team: period.hasRevenue + ? period.teamPercentage + : percentages.team, + height: 6, + ), - const SpaceHeight(12), + const SpaceHeight(12), - Row( - children: [ - _share( - context.lang.share_purchase, - period.limitPurchase, - ProfitSharingColor.purchase, - ), - _share(context.lang.sdl, period.sdl, ProfitSharingColor.owner), - _share( - context.lang.share_team, - period.limitTeam, - ProfitSharingColor.team, - ), - ], - ), - ], + Row( + children: [ + _share(context.lang.sdl, period.sdl, ProfitSharingColor.owner), + _share( + context.lang.share_team, + period.limitTeam, + ProfitSharingColor.team, + ), + ], + ), + ], + ), ), ); } diff --git a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_sdl.dart b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_sdl.dart new file mode 100644 index 0000000..962719e --- /dev/null +++ b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_sdl.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/extension/extension.dart'; +import '../../../../common/theme/theme.dart'; +import '../../../components/spacer/spacer.dart'; +import 'profit_sharing_allocation.dart'; +import 'profit_sharing_status.dart'; + +/// Satu baris rincian SDL, bisa per kategori induk atau per periode. +class ProfitSharingSdlItem { + final String label; + final int sdl; + final int revenue; + final double? feePercent; + + const ProfitSharingSdlItem({ + required this.label, + required this.sdl, + required this.revenue, + this.feePercent, + }); +} + +/// Section khusus SDL (bagian owner): total di blok hijau, lalu rinciannya. +/// Urutan [items] ditampilkan apa adanya. +class ProfitSharingSdlSection extends StatelessWidget { + final int sdl; + final int revenue; + + /// Persen di blok total: fee owner kalau satu kategori, atau persen + /// efektif terhadap omzet kalau gabungan beberapa kategori. + final double percent; + final String breakdownTitle; + final List items; + + const ProfitSharingSdlSection({ + super.key, + required this.sdl, + required this.revenue, + required this.percent, + required this.breakdownTitle, + this.items = const [], + }); + + @override + Widget build(BuildContext context) { + final totalSdl = items.fold(0, (sum, e) => sum + e.sdl); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColor.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColor.textLight.withOpacity(0.08), + spreadRadius: 1, + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.lang.sdl_summary, + style: AppStyle.lg.copyWith( + fontWeight: FontWeight.w700, + color: AppColor.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + context.lang.sdl_desc, + style: AppStyle.xs.copyWith(color: AppColor.textSecondary), + ), + + const SpaceHeight(16), + + _TotalBlock(sdl: sdl, revenue: revenue, percent: percent), + + if (items.isNotEmpty) ...[ + const SpaceHeight(20), + Text( + breakdownTitle, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w700, + color: AppColor.textPrimary, + ), + ), + const SpaceHeight(12), + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) const Divider(height: 24, color: AppColor.borderLight), + _SdlRow( + item: items[i], + share: totalSdl == 0 ? 0 : items[i].sdl / totalSdl, + ), + ], + ], + ], + ), + ); + } +} + +class _TotalBlock extends StatelessWidget { + final int sdl; + final int revenue; + final double percent; + + const _TotalBlock({ + required this.sdl, + required this.revenue, + required this.percent, + }); + + @override + Widget build(BuildContext context) { + const color = ProfitSharingColor.owner; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color, darken(color)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColor.textWhite.withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.account_balance_wallet_rounded, + color: AppColor.textWhite, + size: 20, + ), + ), + const SpaceWidth(10), + Expanded( + child: Text( + context.lang.sdl, + style: AppStyle.md.copyWith( + color: AppColor.textWhite, + fontWeight: FontWeight.w700, + ), + ), + ), + ProfitSharingFeeBadge( + feePercent: percent, + onDarkBackground: true, + ), + ], + ), + const SpaceHeight(14), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + sdl.currencyFormatRp, + style: AppStyle.h3.copyWith( + color: AppColor.textWhite, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(height: 4), + Text( + context.lang.from_revenue(revenue.currencyFormatRp), + style: AppStyle.sm.copyWith( + color: AppColor.textWhite.withOpacity(0.85), + ), + ), + ], + ), + ); + } +} + +class _SdlRow extends StatelessWidget { + final ProfitSharingSdlItem item; + + /// Porsi baris ini terhadap total SDL di daftar (0..1). + final double share; + + const _SdlRow({required this.item, required this.share}); + + @override + Widget build(BuildContext context) { + final amountColor = darken(ProfitSharingColor.owner); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + item.label, + style: AppStyle.md.copyWith( + color: AppColor.textPrimary, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (item.feePercent != null) ...[ + const SpaceWidth(6), + ProfitSharingFeeBadge(feePercent: item.feePercent!), + ], + ], + ), + ), + const SpaceWidth(8), + Text( + item.sdl.currencyFormatRp, + style: AppStyle.md.copyWith( + color: amountColor, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + const SpaceHeight(8), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: share.clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: AppColor.border, + valueColor: const AlwaysStoppedAnimation( + ProfitSharingColor.owner, + ), + ), + ), + const SpaceHeight(6), + Row( + children: [ + Expanded( + child: Text( + context.lang.from_revenue(item.revenue.currencyFormatRp), + style: AppStyle.xs.copyWith(color: AppColor.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + formatPercent(share * 100), + style: AppStyle.xs.copyWith( + color: amountColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_status.dart b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_status.dart index 6a74fae..466180b 100644 --- a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_status.dart +++ b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_status.dart @@ -18,10 +18,16 @@ String formatPercent(double value) => '${value.toStringAsFixed(1)}%'; String formatFeePercent(double value) => '${value.toStringAsFixed(value % 1 == 0 ? 0 : 1)}%'; +/// Versi lebih gelap dari [color], dipakai sebagai ujung gradien. +Color darken(Color color, [double amount = 0.2]) => + Color.lerp(color, AppColor.black, amount)!; + Color statusColor(double realHppPercentage) { - if (realHppPercentage <= HppThreshold.healthy) return AppColor.success; - if (realHppPercentage <= HppThreshold.watch) return AppColor.warning; - return AppColor.error; + if (realHppPercentage <= HppThreshold.healthy) { + return ProfitSharingColor.healthy; + } + if (realHppPercentage <= HppThreshold.watch) return ProfitSharingColor.watch; + return ProfitSharingColor.critical; } String statusLabel(BuildContext context, double realHppPercentage) { @@ -47,97 +53,95 @@ class ProfitSharingStatusBadge extends StatelessWidget { Widget build(BuildContext context) { final color = statusColor(realHppPercentage); + // Di atas header merah, pill putih supaya status merah tidak tenggelam. return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: onDarkBackground - ? AppColor.textWhite.withOpacity(0.18) - : color.withOpacity(0.12), + color: onDarkBackground ? AppColor.white : color, borderRadius: BorderRadius.circular(20), - border: onDarkBackground - ? Border.all(color: AppColor.textWhite.withOpacity(0.3)) - : null, ), child: Text( statusLabel(context, realHppPercentage), style: AppStyle.xs.copyWith( - color: onDarkBackground ? AppColor.textWhite : color, - fontWeight: FontWeight.w700, + color: onDarkBackground ? color : AppColor.textWhite, + fontWeight: FontWeight.w800, ), ), ); } } -/// Strip sorotan SDL: label + persen fee owner di kiri, nominal di kanan. -class ProfitSharingSdlStrip extends StatelessWidget { - final int amount; - final double? feePercent; +/// Kartu item di dalam section: latar putih, garis aksen berwarna di kiri. +/// Child boleh berisi InkWell; ripple-nya ikut terpotong sudut kartu. +class ProfitSharingItemCard extends StatelessWidget { + final Widget child; + final Color accentColor; - const ProfitSharingSdlStrip({ + const ProfitSharingItemCard({ super.key, - required this.amount, - this.feePercent, + required this.child, + this.accentColor = AppColor.primary, }); @override Widget build(BuildContext context) { - const color = ProfitSharingColor.owner; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: color.withOpacity(0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color.withOpacity(0.2)), - ), - child: Row( - children: [ - const Icon( - Icons.account_balance_wallet_rounded, - color: color, - size: 18, - ), - const SpaceWidth(8), - Text( - context.lang.sdl, - style: AppStyle.sm.copyWith( - color: AppColor.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - if (feePercent != null) ...[ - const SpaceWidth(6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formatFeePercent(feePercent!), - style: AppStyle.xs.copyWith( - color: AppColor.textWhite, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - const SpaceWidth(8), - Expanded( - child: Text( - amount.currencyFormatRp, - textAlign: TextAlign.right, - style: AppStyle.md.copyWith( - color: AppColor.secondaryDark, - fontWeight: FontWeight.w800, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + color: AppColor.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.border), + boxShadow: [ + BoxShadow( + color: AppColor.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), + child: Stack( + children: [ + Material(type: MaterialType.transparency, child: child), + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container(width: 4, color: accentColor), + ), + ], + ), + ); + } +} + +/// Pill persen fee owner. Di atas latar berwarna jadi putih supaya terbaca. +class ProfitSharingFeeBadge extends StatelessWidget { + final double feePercent; + final Color color; + final bool onDarkBackground; + + const ProfitSharingFeeBadge({ + super.key, + required this.feePercent, + this.color = ProfitSharingColor.owner, + this.onDarkBackground = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: onDarkBackground ? AppColor.white : color, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + formatFeePercent(feePercent), + style: AppStyle.xs.copyWith( + color: onDarkBackground ? color : AppColor.textWhite, + fontWeight: FontWeight.w800, + ), + ), ); } } @@ -205,7 +209,7 @@ class ProfitSharingHeaderStats extends StatelessWidget { } } -/// Kotak statistik kecil: ikon, nilai, lalu label. +/// Kartu statistik kecil berwarna solid: ikon, nilai, lalu label. class ProfitSharingStatTile extends StatelessWidget { final IconData icon; final String label; @@ -225,9 +229,19 @@ class ProfitSharingStatTile extends StatelessWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColor.background, + gradient: LinearGradient( + colors: [color, darken(color)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColor.borderLight), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -236,25 +250,30 @@ class ProfitSharingStatTile extends StatelessWidget { width: 30, height: 30, decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: AppColor.textWhite.withOpacity(0.2), borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, color: color, size: 16), + child: Icon(icon, color: AppColor.textWhite, size: 16), ), const SpaceHeight(10), - Text( - value, - style: AppStyle.lg.copyWith( - color: AppColor.textPrimary, - fontWeight: FontWeight.w800, + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + style: AppStyle.lg.copyWith( + color: AppColor.textWhite, + fontWeight: FontWeight.w800, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( label, - style: AppStyle.xs.copyWith(color: AppColor.textSecondary), + style: AppStyle.xs.copyWith( + color: AppColor.textWhite.withOpacity(0.85), + fontWeight: FontWeight.w600, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_subcategories.dart b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_subcategories.dart index f894a4d..6adce2f 100644 --- a/lib/presentation/pages/profit_sharing/widgets/profit_sharing_subcategories.dart +++ b/lib/presentation/pages/profit_sharing/widgets/profit_sharing_subcategories.dart @@ -99,17 +99,11 @@ class _SubCategoryCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColor.background, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColor.borderLight), - ), + return ProfitSharingItemCard( child: Column( children: [ InkWell( onTap: onToggle, - borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(14), child: Column( @@ -155,7 +149,7 @@ class _SubCategoryCard extends StatelessWidget { child: LinearProgressIndicator( value: share.clamp(0.0, 1.0), minHeight: 5, - backgroundColor: AppColor.borderLight, + backgroundColor: AppColor.border, valueColor: const AlwaysStoppedAnimation( AppColor.primary, ), diff --git a/pubspec.yaml b/pubspec.yaml index 6b0b21a..81265d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "A new Flutter project." publish_to: "none" -version: 1.0.4+13 +version: 1.0.4+14 environment: sdk: ^3.8.1