feat: update profit loss ui
Some checks are pending
Build & Deploy iOS to TestFlight / build-and-deploy (push) Waiting to run
Some checks are pending
Build & Deploy iOS to TestFlight / build-and-deploy (push) Waiting to run
This commit is contained in:
parent
b07af60778
commit
0917c5132b
File diff suppressed because it is too large
Load Diff
@ -4,22 +4,34 @@ part of '../analytic.dart';
|
||||
class ProfitLossAnalytic with _$ProfitLossAnalytic {
|
||||
const factory ProfitLossAnalytic({
|
||||
required String organizationId,
|
||||
required String outletId,
|
||||
required String outletName,
|
||||
required String dateFrom,
|
||||
required String dateTo,
|
||||
required String groupBy,
|
||||
required ProfitLossSummary summary,
|
||||
required List<ProfitLossDailyData> data,
|
||||
required List<ProfitLossProductData> productData,
|
||||
required List<ProfitLossMainSummaryItem> mainSummary,
|
||||
required ProfitLossPurchasing purchasing,
|
||||
required List<ProfitLossOperationalExpense> operationalExpenses,
|
||||
required int operationalExpensesTotal,
|
||||
}) = _ProfitLossAnalytic;
|
||||
|
||||
factory ProfitLossAnalytic.empty() => ProfitLossAnalytic(
|
||||
organizationId: '',
|
||||
outletId: '',
|
||||
outletName: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
groupBy: '',
|
||||
summary: ProfitLossSummary.empty(),
|
||||
data: [],
|
||||
productData: [],
|
||||
mainSummary: [],
|
||||
purchasing: ProfitLossPurchasing.empty(),
|
||||
operationalExpenses: [],
|
||||
operationalExpensesTotal: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@ -115,3 +127,79 @@ class ProfitLossProductData with _$ProfitLossProductData {
|
||||
profitPerUnit: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossMainSummaryItem with _$ProfitLossMainSummaryItem {
|
||||
const factory ProfitLossMainSummaryItem({
|
||||
required String id,
|
||||
required String label,
|
||||
required bool isBold,
|
||||
required int todayNominal,
|
||||
required double todayPct,
|
||||
required int mtdNominal,
|
||||
required double mtdPct,
|
||||
@Default([]) List<ProfitLossMainSummaryItem> subItems,
|
||||
}) = _ProfitLossMainSummaryItem;
|
||||
|
||||
factory ProfitLossMainSummaryItem.empty() => const ProfitLossMainSummaryItem(
|
||||
id: '',
|
||||
label: '',
|
||||
isBold: false,
|
||||
todayNominal: 0,
|
||||
todayPct: 0,
|
||||
mtdNominal: 0,
|
||||
mtdPct: 0,
|
||||
subItems: [],
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossPurchasing with _$ProfitLossPurchasing {
|
||||
const factory ProfitLossPurchasing({
|
||||
required int todayTotal,
|
||||
required int mtdTotal,
|
||||
required int todayRawMaterial,
|
||||
required int mtdRawMaterial,
|
||||
required int todayExpense,
|
||||
required int mtdExpense,
|
||||
@Default([]) List<ProfitLossPurchasingItem> items,
|
||||
}) = _ProfitLossPurchasing;
|
||||
|
||||
factory ProfitLossPurchasing.empty() => const ProfitLossPurchasing(
|
||||
todayTotal: 0,
|
||||
mtdTotal: 0,
|
||||
todayRawMaterial: 0,
|
||||
mtdRawMaterial: 0,
|
||||
todayExpense: 0,
|
||||
mtdExpense: 0,
|
||||
items: [],
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossPurchasingItem with _$ProfitLossPurchasingItem {
|
||||
const factory ProfitLossPurchasingItem({
|
||||
required String date,
|
||||
required String item,
|
||||
required int quantity,
|
||||
required int nominal,
|
||||
}) = _ProfitLossPurchasingItem;
|
||||
|
||||
factory ProfitLossPurchasingItem.empty() => const ProfitLossPurchasingItem(
|
||||
date: '',
|
||||
item: '',
|
||||
quantity: 0,
|
||||
nominal: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossOperationalExpense with _$ProfitLossOperationalExpense {
|
||||
const factory ProfitLossOperationalExpense({
|
||||
required String item,
|
||||
required int nominal,
|
||||
}) = _ProfitLossOperationalExpense;
|
||||
|
||||
factory ProfitLossOperationalExpense.empty() =>
|
||||
const ProfitLossOperationalExpense(item: '', nominal: 0);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -94,6 +94,8 @@ _$ProfitLossAnalyticDtoImpl _$$ProfitLossAnalyticDtoImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$ProfitLossAnalyticDtoImpl(
|
||||
organizationId: json['organization_id'] as String?,
|
||||
outletId: json['outlet_id'] as String?,
|
||||
outletName: json['outlet_name'] as String?,
|
||||
dateFrom: json['date_from'] as String?,
|
||||
dateTo: json['date_to'] as String?,
|
||||
groupBy: json['group_by'] as String?,
|
||||
@ -106,18 +108,42 @@ _$ProfitLossAnalyticDtoImpl _$$ProfitLossAnalyticDtoImplFromJson(
|
||||
productData: (json['product_data'] as List<dynamic>?)
|
||||
?.map((e) => ProfitLossProductDataDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
mainSummary: (json['main_summary'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => ProfitLossMainSummaryItemDto.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList(),
|
||||
purchasing: json['purchasing'] == null
|
||||
? null
|
||||
: ProfitLossPurchasingDto.fromJson(
|
||||
json['purchasing'] as Map<String, dynamic>,
|
||||
),
|
||||
operationalExpenses: (json['operational_expenses'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) =>
|
||||
ProfitLossOperationalExpenseDto.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList(),
|
||||
operationalExpensesTotal: (json['operational_expenses_total'] as num?)
|
||||
?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ProfitLossAnalyticDtoImplToJson(
|
||||
_$ProfitLossAnalyticDtoImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'organization_id': instance.organizationId,
|
||||
'outlet_id': instance.outletId,
|
||||
'outlet_name': instance.outletName,
|
||||
'date_from': instance.dateFrom,
|
||||
'date_to': instance.dateTo,
|
||||
'group_by': instance.groupBy,
|
||||
'summary': instance.summary,
|
||||
'data': instance.data,
|
||||
'product_data': instance.productData,
|
||||
'main_summary': instance.mainSummary,
|
||||
'purchasing': instance.purchasing,
|
||||
'operational_expenses': instance.operationalExpenses,
|
||||
'operational_expenses_total': instance.operationalExpensesTotal,
|
||||
};
|
||||
|
||||
_$ProfitLossSummaryDtoImpl _$$ProfitLossSummaryDtoImplFromJson(
|
||||
@ -216,6 +242,93 @@ Map<String, dynamic> _$$ProfitLossProductDataDtoImplToJson(
|
||||
'profit_per_unit': instance.profitPerUnit,
|
||||
};
|
||||
|
||||
_$ProfitLossMainSummaryItemDtoImpl _$$ProfitLossMainSummaryItemDtoImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$ProfitLossMainSummaryItemDtoImpl(
|
||||
id: json['id'] as String?,
|
||||
label: json['label'] as String?,
|
||||
isBold: json['is_bold'] as bool?,
|
||||
todayNominal: (json['today_nominal'] as num?)?.toInt(),
|
||||
todayPct: (json['today_pct'] as num?)?.toDouble(),
|
||||
mtdNominal: (json['mtd_nominal'] as num?)?.toInt(),
|
||||
mtdPct: (json['mtd_pct'] as num?)?.toDouble(),
|
||||
subItems: (json['sub_items'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => ProfitLossMainSummaryItemDto.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ProfitLossMainSummaryItemDtoImplToJson(
|
||||
_$ProfitLossMainSummaryItemDtoImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'label': instance.label,
|
||||
'is_bold': instance.isBold,
|
||||
'today_nominal': instance.todayNominal,
|
||||
'today_pct': instance.todayPct,
|
||||
'mtd_nominal': instance.mtdNominal,
|
||||
'mtd_pct': instance.mtdPct,
|
||||
'sub_items': instance.subItems,
|
||||
};
|
||||
|
||||
_$ProfitLossPurchasingDtoImpl _$$ProfitLossPurchasingDtoImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$ProfitLossPurchasingDtoImpl(
|
||||
todayTotal: (json['today_total'] as num?)?.toInt(),
|
||||
mtdTotal: (json['mtd_total'] as num?)?.toInt(),
|
||||
todayRawMaterial: (json['today_raw_material'] as num?)?.toInt(),
|
||||
mtdRawMaterial: (json['mtd_raw_material'] as num?)?.toInt(),
|
||||
todayExpense: (json['today_expense'] as num?)?.toInt(),
|
||||
mtdExpense: (json['mtd_expense'] as num?)?.toInt(),
|
||||
items: (json['items'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => ProfitLossPurchasingItemDto.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ProfitLossPurchasingDtoImplToJson(
|
||||
_$ProfitLossPurchasingDtoImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'today_total': instance.todayTotal,
|
||||
'mtd_total': instance.mtdTotal,
|
||||
'today_raw_material': instance.todayRawMaterial,
|
||||
'mtd_raw_material': instance.mtdRawMaterial,
|
||||
'today_expense': instance.todayExpense,
|
||||
'mtd_expense': instance.mtdExpense,
|
||||
'items': instance.items,
|
||||
};
|
||||
|
||||
_$ProfitLossPurchasingItemDtoImpl _$$ProfitLossPurchasingItemDtoImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$ProfitLossPurchasingItemDtoImpl(
|
||||
date: json['date'] as String?,
|
||||
item: json['item'] as String?,
|
||||
quantity: (json['quantity'] as num?)?.toInt(),
|
||||
nominal: (json['nominal'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ProfitLossPurchasingItemDtoImplToJson(
|
||||
_$ProfitLossPurchasingItemDtoImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'date': instance.date,
|
||||
'item': instance.item,
|
||||
'quantity': instance.quantity,
|
||||
'nominal': instance.nominal,
|
||||
};
|
||||
|
||||
_$ProfitLossOperationalExpenseDtoImpl
|
||||
_$$ProfitLossOperationalExpenseDtoImplFromJson(Map<String, dynamic> json) =>
|
||||
_$ProfitLossOperationalExpenseDtoImpl(
|
||||
item: json['item'] as String?,
|
||||
nominal: (json['nominal'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ProfitLossOperationalExpenseDtoImplToJson(
|
||||
_$ProfitLossOperationalExpenseDtoImpl instance,
|
||||
) => <String, dynamic>{'item': instance.item, 'nominal': instance.nominal};
|
||||
|
||||
_$CategoryAnalyticDtoImpl _$$CategoryAnalyticDtoImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$CategoryAnalyticDtoImpl(
|
||||
|
||||
@ -6,12 +6,20 @@ class ProfitLossAnalyticDto with _$ProfitLossAnalyticDto {
|
||||
|
||||
const factory ProfitLossAnalyticDto({
|
||||
@JsonKey(name: 'organization_id') String? organizationId,
|
||||
@JsonKey(name: 'outlet_id') String? outletId,
|
||||
@JsonKey(name: 'outlet_name') String? outletName,
|
||||
@JsonKey(name: 'date_from') String? dateFrom,
|
||||
@JsonKey(name: 'date_to') String? dateTo,
|
||||
@JsonKey(name: 'group_by') String? groupBy,
|
||||
@JsonKey(name: 'summary') ProfitLossSummaryDto? summary,
|
||||
@JsonKey(name: 'data') List<ProfitLossDailyDataDto>? data,
|
||||
@JsonKey(name: 'product_data') List<ProfitLossProductDataDto>? productData,
|
||||
@JsonKey(name: 'main_summary')
|
||||
List<ProfitLossMainSummaryItemDto>? mainSummary,
|
||||
@JsonKey(name: 'purchasing') ProfitLossPurchasingDto? purchasing,
|
||||
@JsonKey(name: 'operational_expenses')
|
||||
List<ProfitLossOperationalExpenseDto>? operationalExpenses,
|
||||
@JsonKey(name: 'operational_expenses_total') int? operationalExpensesTotal,
|
||||
}) = _ProfitLossAnalyticDto;
|
||||
|
||||
factory ProfitLossAnalyticDto.fromJson(Map<String, dynamic> json) =>
|
||||
@ -19,12 +27,20 @@ class ProfitLossAnalyticDto with _$ProfitLossAnalyticDto {
|
||||
|
||||
ProfitLossAnalytic toDomain() => ProfitLossAnalytic(
|
||||
organizationId: organizationId ?? '',
|
||||
outletId: outletId ?? '',
|
||||
outletName: outletName ?? '',
|
||||
dateFrom: dateFrom ?? '',
|
||||
dateTo: dateTo ?? '',
|
||||
groupBy: groupBy ?? '',
|
||||
summary: summary?.toDomain() ?? ProfitLossSummary.empty(),
|
||||
data: (data ?? []).map((e) => e.toDomain()).toList(),
|
||||
productData: (productData ?? []).map((e) => e.toDomain()).toList(),
|
||||
mainSummary: (mainSummary ?? []).map((e) => e.toDomain()).toList(),
|
||||
purchasing: purchasing?.toDomain() ?? ProfitLossPurchasing.empty(),
|
||||
operationalExpenses: (operationalExpenses ?? [])
|
||||
.map((e) => e.toDomain())
|
||||
.toList(),
|
||||
operationalExpensesTotal: operationalExpensesTotal ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@ -135,3 +151,99 @@ class ProfitLossProductDataDto with _$ProfitLossProductDataDto {
|
||||
profitPerUnit: profitPerUnit ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossMainSummaryItemDto with _$ProfitLossMainSummaryItemDto {
|
||||
const ProfitLossMainSummaryItemDto._();
|
||||
|
||||
const factory ProfitLossMainSummaryItemDto({
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'label') String? label,
|
||||
@JsonKey(name: 'is_bold') bool? isBold,
|
||||
@JsonKey(name: 'today_nominal') int? todayNominal,
|
||||
@JsonKey(name: 'today_pct') double? todayPct,
|
||||
@JsonKey(name: 'mtd_nominal') int? mtdNominal,
|
||||
@JsonKey(name: 'mtd_pct') double? mtdPct,
|
||||
@JsonKey(name: 'sub_items') List<ProfitLossMainSummaryItemDto>? subItems,
|
||||
}) = _ProfitLossMainSummaryItemDto;
|
||||
|
||||
factory ProfitLossMainSummaryItemDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfitLossMainSummaryItemDtoFromJson(json);
|
||||
|
||||
ProfitLossMainSummaryItem toDomain() => ProfitLossMainSummaryItem(
|
||||
id: id ?? '',
|
||||
label: label ?? '',
|
||||
isBold: isBold ?? false,
|
||||
todayNominal: todayNominal ?? 0,
|
||||
todayPct: todayPct ?? 0.0,
|
||||
mtdNominal: mtdNominal ?? 0,
|
||||
mtdPct: mtdPct ?? 0.0,
|
||||
subItems: (subItems ?? []).map((e) => e.toDomain()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossPurchasingDto with _$ProfitLossPurchasingDto {
|
||||
const ProfitLossPurchasingDto._();
|
||||
|
||||
const factory ProfitLossPurchasingDto({
|
||||
@JsonKey(name: 'today_total') int? todayTotal,
|
||||
@JsonKey(name: 'mtd_total') int? mtdTotal,
|
||||
@JsonKey(name: 'today_raw_material') int? todayRawMaterial,
|
||||
@JsonKey(name: 'mtd_raw_material') int? mtdRawMaterial,
|
||||
@JsonKey(name: 'today_expense') int? todayExpense,
|
||||
@JsonKey(name: 'mtd_expense') int? mtdExpense,
|
||||
@JsonKey(name: 'items') List<ProfitLossPurchasingItemDto>? items,
|
||||
}) = _ProfitLossPurchasingDto;
|
||||
|
||||
factory ProfitLossPurchasingDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfitLossPurchasingDtoFromJson(json);
|
||||
|
||||
ProfitLossPurchasing toDomain() => ProfitLossPurchasing(
|
||||
todayTotal: todayTotal ?? 0,
|
||||
mtdTotal: mtdTotal ?? 0,
|
||||
todayRawMaterial: todayRawMaterial ?? 0,
|
||||
mtdRawMaterial: mtdRawMaterial ?? 0,
|
||||
todayExpense: todayExpense ?? 0,
|
||||
mtdExpense: mtdExpense ?? 0,
|
||||
items: (items ?? []).map((e) => e.toDomain()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossPurchasingItemDto with _$ProfitLossPurchasingItemDto {
|
||||
const ProfitLossPurchasingItemDto._();
|
||||
|
||||
const factory ProfitLossPurchasingItemDto({
|
||||
@JsonKey(name: 'date') String? date,
|
||||
@JsonKey(name: 'item') String? item,
|
||||
@JsonKey(name: 'quantity') int? quantity,
|
||||
@JsonKey(name: 'nominal') int? nominal,
|
||||
}) = _ProfitLossPurchasingItemDto;
|
||||
|
||||
factory ProfitLossPurchasingItemDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfitLossPurchasingItemDtoFromJson(json);
|
||||
|
||||
ProfitLossPurchasingItem toDomain() => ProfitLossPurchasingItem(
|
||||
date: date ?? '',
|
||||
item: item ?? '',
|
||||
quantity: quantity ?? 0,
|
||||
nominal: nominal ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProfitLossOperationalExpenseDto with _$ProfitLossOperationalExpenseDto {
|
||||
const ProfitLossOperationalExpenseDto._();
|
||||
|
||||
const factory ProfitLossOperationalExpenseDto({
|
||||
@JsonKey(name: 'item') String? item,
|
||||
@JsonKey(name: 'nominal') int? nominal,
|
||||
}) = _ProfitLossOperationalExpenseDto;
|
||||
|
||||
factory ProfitLossOperationalExpenseDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfitLossOperationalExpenseDtoFromJson(json);
|
||||
|
||||
ProfitLossOperationalExpense toDomain() =>
|
||||
ProfitLossOperationalExpense(item: item ?? '', nominal: nominal ?? 0);
|
||||
}
|
||||
|
||||
@ -496,5 +496,29 @@
|
||||
"example": "48"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mtd_month": "MTD ({month})",
|
||||
"@mtd_month": {
|
||||
"placeholders": {
|
||||
"month": {
|
||||
"type": "String",
|
||||
"example": "June"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profit_loss_date": "Profit / Loss · {date}",
|
||||
"@profit_loss_date": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "String",
|
||||
"example": "22 Jun 2026"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profit_loss_report": "Profit & Loss Report",
|
||||
"@profit_loss_report": {},
|
||||
"net_profit_loss": "Net Profit/Loss",
|
||||
"@net_profit_loss": {},
|
||||
"cost_breakdown": "Cost Breakdown",
|
||||
"@cost_breakdown": {}
|
||||
}
|
||||
|
||||
@ -496,5 +496,29 @@
|
||||
"example": "48"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mtd_month": "MTD ({month})",
|
||||
"@mtd_month": {
|
||||
"placeholders": {
|
||||
"month": {
|
||||
"type": "String",
|
||||
"example": "Juni"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profit_loss_date": "Laba / Rugi · {date}",
|
||||
"@profit_loss_date": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "String",
|
||||
"example": "22 Jun 2026"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profit_loss_report": "Laporan Laba Rugi",
|
||||
"@profit_loss_report": {},
|
||||
"net_profit_loss": "Laba/Rugi Bersih",
|
||||
"@net_profit_loss": {},
|
||||
"cost_breakdown": "Rincian Biaya",
|
||||
"@cost_breakdown": {}
|
||||
}
|
||||
|
||||
@ -1480,6 +1480,36 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} portions sold'**
|
||||
String portion_sold(int count);
|
||||
|
||||
/// No description provided for @mtd_month.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MTD ({month})'**
|
||||
String mtd_month(String month);
|
||||
|
||||
/// No description provided for @profit_loss_date.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Profit / Loss · {date}'**
|
||||
String profit_loss_date(String date);
|
||||
|
||||
/// No description provided for @profit_loss_report.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Profit & Loss Report'**
|
||||
String get profit_loss_report;
|
||||
|
||||
/// No description provided for @net_profit_loss.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Net Profit/Loss'**
|
||||
String get net_profit_loss;
|
||||
|
||||
/// No description provided for @cost_breakdown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cost Breakdown'**
|
||||
String get cost_breakdown;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
|
||||
|
||||
@ -710,4 +710,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String portion_sold(int count) {
|
||||
return '$count portions sold';
|
||||
}
|
||||
|
||||
@override
|
||||
String mtd_month(String month) {
|
||||
return 'MTD ($month)';
|
||||
}
|
||||
|
||||
@override
|
||||
String profit_loss_date(String date) {
|
||||
return 'Profit / Loss · $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get profit_loss_report => 'Profit & Loss Report';
|
||||
|
||||
@override
|
||||
String get net_profit_loss => 'Net Profit/Loss';
|
||||
|
||||
@override
|
||||
String get cost_breakdown => 'Cost Breakdown';
|
||||
}
|
||||
|
||||
@ -710,4 +710,23 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String portion_sold(int count) {
|
||||
return '$count porsi terjual';
|
||||
}
|
||||
|
||||
@override
|
||||
String mtd_month(String month) {
|
||||
return 'MTD ($month)';
|
||||
}
|
||||
|
||||
@override
|
||||
String profit_loss_date(String date) {
|
||||
return 'Laba / Rugi · $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get profit_loss_report => 'Laporan Laba Rugi';
|
||||
|
||||
@override
|
||||
String get net_profit_loss => 'Laba/Rugi Bersih';
|
||||
|
||||
@override
|
||||
String get cost_breakdown => 'Rincian Biaya';
|
||||
}
|
||||
|
||||
@ -1,21 +1,13 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:line_icons/line_icons.dart';
|
||||
|
||||
import '../../../application/analytic/category_analytic_loader/category_analytic_loader_bloc.dart';
|
||||
import '../../../application/analytic/profit_loss_loader/profit_loss_loader_bloc.dart';
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../domain/analytic/analytic.dart';
|
||||
import '../../../injection.dart';
|
||||
import '../../components/appbar/appbar.dart';
|
||||
import '../../components/field/date_range_picker_field.dart';
|
||||
import 'widgets/cash_flow.dart';
|
||||
import 'widgets/category.dart';
|
||||
import 'widgets/product.dart';
|
||||
import 'widgets/profit_loss.dart';
|
||||
import 'widgets/summary_card.dart';
|
||||
import 'widgets/cost_breakdown.dart';
|
||||
import 'widgets/profit_loss_header.dart';
|
||||
import 'widgets/profit_loss_report.dart';
|
||||
|
||||
@RoutePage()
|
||||
class FinancePage extends StatefulWidget implements AutoRouteWrapper {
|
||||
@ -25,80 +17,40 @@ class FinancePage extends StatefulWidget implements AutoRouteWrapper {
|
||||
State<FinancePage> createState() => _FinancePageState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
getIt<ProfitLossLoaderBloc>()..add(ProfitLossLoaderEvent.fetched()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
getIt<CategoryAnalyticLoaderBloc>()
|
||||
..add(CategoryAnalyticLoaderEvent.fetched()),
|
||||
),
|
||||
],
|
||||
Widget wrappedRoute(BuildContext context) => BlocProvider(
|
||||
create: (_) =>
|
||||
getIt<ProfitLossLoaderBloc>()..add(ProfitLossLoaderEvent.fetched()),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
class _FinancePageState extends State<FinancePage>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _slideController;
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _fadeController;
|
||||
late AnimationController _scaleController;
|
||||
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
int _selectedTabIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_slideController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideAnimation =
|
||||
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
|
||||
CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic),
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(parent: _fadeController, curve: Curves.easeIn));
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
// Start animations
|
||||
_fadeController.forward();
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
_slideController.forward();
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 400), () {
|
||||
_scaleController.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_slideController.dispose();
|
||||
_fadeController.dispose();
|
||||
_scaleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -119,89 +71,48 @@ class _FinancePageState extends State<FinancePage>
|
||||
builder: (context, state) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// SliverAppBar with animated background
|
||||
SliverAppBar(
|
||||
expandedHeight: 120,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: AppColor.primary,
|
||||
elevation: 0,
|
||||
flexibleSpace: CustomAppBar(title: context.lang.profit_loss),
|
||||
),
|
||||
|
||||
// Header dengan filter periode
|
||||
// Header with gradient background, tabs, and summary
|
||||
SliverToBoxAdapter(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: DateRangePickerField(
|
||||
maxDate: DateTime.now(),
|
||||
startDate: state.dateFrom,
|
||||
endDate: state.dateTo,
|
||||
onChanged: (startDate, endDate) {
|
||||
context.read<ProfitLossLoaderBloc>().add(
|
||||
ProfitLossLoaderEvent.rangeDateChanged(
|
||||
startDate!,
|
||||
endDate!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: ProfitLossHeader(
|
||||
state: state,
|
||||
selectedTabIndex: _selectedTabIndex,
|
||||
onTabChanged: (index) {
|
||||
setState(() {
|
||||
_selectedTabIndex = index;
|
||||
});
|
||||
_onTabChanged(context, index);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Summary Cards
|
||||
SliverToBoxAdapter(
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildSummaryCards(state.profitLoss.summary),
|
||||
),
|
||||
),
|
||||
|
||||
// Cash Flow Analysis
|
||||
SliverToBoxAdapter(
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: FinanceCashFlow(dailyData: state.profitLoss.data),
|
||||
),
|
||||
),
|
||||
|
||||
// Profit Loss Detail
|
||||
// Profit Loss Report Table
|
||||
SliverToBoxAdapter(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: FinanceProfitLoss(data: state.profitLoss.summary),
|
||||
child: ProfitLossReport(
|
||||
mainSummary: state.profitLoss.mainSummary,
|
||||
summary: state.profitLoss.summary,
|
||||
selectedTabIndex: _selectedTabIndex,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<
|
||||
CategoryAnalyticLoaderBloc,
|
||||
CategoryAnalyticLoaderState
|
||||
>(
|
||||
builder: (context, stateCategory) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FinanceCategory(
|
||||
categories: stateCategory.categoryAnalytic.data,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Product Analysis Section
|
||||
// Cost Breakdown
|
||||
SliverToBoxAdapter(
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildProductAnalysis(state.profitLoss.productData),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: CostBreakdown(
|
||||
purchasing: state.profitLoss.purchasing,
|
||||
selectedTabIndex: _selectedTabIndex,
|
||||
dateFrom: state.dateFrom,
|
||||
dateTo: state.dateTo,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Transaction Categories
|
||||
|
||||
// Bottom spacing
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
],
|
||||
@ -212,125 +123,23 @@ class _FinancePageState extends State<FinancePage>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCards(ProfitLossSummary summary) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FinanceSummaryCard(
|
||||
title: context.lang.total_revenue,
|
||||
amount: summary.totalRevenue.currencyFormatRp,
|
||||
icon: LineIcons.arrowUp,
|
||||
color: AppColor.success,
|
||||
isPositive: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FinanceSummaryCard(
|
||||
title: context.lang.total_expenditures,
|
||||
amount: summary.totalCost.currencyFormatRp,
|
||||
icon: LineIcons.arrowDown,
|
||||
color: AppColor.error,
|
||||
isPositive: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FinanceSummaryCard(
|
||||
title: context.lang.net_profit,
|
||||
amount: summary.netProfit.currencyFormatRp,
|
||||
icon: LineIcons.lineChart,
|
||||
color: AppColor.info,
|
||||
isPositive: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FinanceSummaryCard(
|
||||
title: context.lang.margin_profit,
|
||||
amount: '${summary.profitabilityRatio.round()}%',
|
||||
icon: LineIcons.percent,
|
||||
color: AppColor.warning,
|
||||
isPositive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
void _onTabChanged(BuildContext context, int index) {
|
||||
final now = DateTime.now();
|
||||
DateTime dateFrom;
|
||||
DateTime dateTo;
|
||||
|
||||
Widget _buildProductAnalysis(List<ProfitLossProductData> products) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.textLight.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.info.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
LineIcons.shoppingBag,
|
||||
color: AppColor.info,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.lang.product_analytic,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: Text(
|
||||
context.lang.view_all,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Product list
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
itemCount: products.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return ProfitLossProduct(product: product);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (index == 0) {
|
||||
// Today
|
||||
dateFrom = DateTime(now.year, now.month, now.day);
|
||||
dateTo = DateTime(now.year, now.month, now.day, 23, 59, 59);
|
||||
} else {
|
||||
// MTD (Month-to-Date)
|
||||
dateFrom = DateTime(now.year, now.month, 1);
|
||||
dateTo = now;
|
||||
}
|
||||
|
||||
context.read<ProfitLossLoaderBloc>().add(
|
||||
ProfitLossLoaderEvent.rangeDateChanged(dateFrom, dateTo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,476 +0,0 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:line_icons/line_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/analytic/analytic.dart';
|
||||
|
||||
class FinanceCashFlow extends StatelessWidget {
|
||||
final List<ProfitLossDailyData> dailyData;
|
||||
|
||||
const FinanceCashFlow({super.key, required this.dailyData});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Calculate totals from daily data
|
||||
final totalCashIn = _calculateTotalCashIn();
|
||||
final totalCashOut = _calculateTotalCashOut();
|
||||
final netFlow = totalCashIn - totalCashOut;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.textLight.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: AppColor.primaryGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
LineIcons.areaChart,
|
||||
color: AppColor.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.lang.cash_flow_analysis,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(
|
||||
LineIcons.alternateExternalLink,
|
||||
color: AppColor.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Cash Flow Indicators
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildCashFlowIndicator(
|
||||
context.lang.cash_in,
|
||||
_formatCurrency(totalCashIn),
|
||||
LineIcons.arrowUp,
|
||||
AppColor.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildCashFlowIndicator(
|
||||
context.lang.cash_out,
|
||||
_formatCurrency(totalCashOut),
|
||||
LineIcons.arrowDown,
|
||||
AppColor.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildCashFlowIndicator(
|
||||
context.lang.net_flow,
|
||||
_formatCurrency(netFlow),
|
||||
LineIcons.equals,
|
||||
AppColor.info,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// FL Chart Implementation
|
||||
Container(
|
||||
height: 200,
|
||||
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: [
|
||||
Text(
|
||||
context.lang.cash_flow_chart(dailyData.length),
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: dailyData.isEmpty
|
||||
? _buildEmptyChart()
|
||||
: LineChart(_buildLineChartData()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Legend
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildChartLegend(context.lang.cash_in, AppColor.success),
|
||||
const SizedBox(width: 20),
|
||||
_buildChartLegend(context.lang.cash_out, AppColor.error),
|
||||
const SizedBox(width: 20),
|
||||
_buildChartLegend(context.lang.net_flow, AppColor.info),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
LineChartData _buildLineChartData() {
|
||||
final maxValue = _getMaxChartValue();
|
||||
final minValue = _getMinChartValue();
|
||||
|
||||
return LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: (maxValue / 5).roundToDouble(),
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(color: AppColor.borderLight, strokeWidth: 1);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval: 1,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
final index = value.toInt();
|
||||
if (index >= 0 && index < dailyData.length) {
|
||||
final date = DateTime.parse(dailyData[index].date);
|
||||
final dayName = _getDayName(date.weekday);
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text(
|
||||
dayName,
|
||||
style: const TextStyle(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SideTitleWidget(meta: meta, child: Text(''));
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: (maxValue / 3).roundToDouble(),
|
||||
reservedSize: 42,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
return Text(
|
||||
_formatChartValue(value),
|
||||
style: const TextStyle(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 10,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(
|
||||
show: true,
|
||||
border: Border.all(color: AppColor.borderLight),
|
||||
),
|
||||
minX: 0,
|
||||
maxX: (dailyData.length - 1).toDouble(),
|
||||
minY: minValue,
|
||||
maxY: maxValue,
|
||||
lineBarsData: [
|
||||
// Cash In Line (Revenue)
|
||||
LineChartBarData(
|
||||
spots: _buildCashInSpots(),
|
||||
isCurved: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColor.success.withOpacity(0.8), AppColor.success],
|
||||
),
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: AppColor.success,
|
||||
strokeWidth: 2,
|
||||
strokeColor: AppColor.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColor.success.withOpacity(0.1),
|
||||
AppColor.success.withOpacity(0.0),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cash Out Line (Total Cost)
|
||||
LineChartBarData(
|
||||
spots: _buildCashOutSpots(),
|
||||
isCurved: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColor.error.withOpacity(0.8), AppColor.error],
|
||||
),
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: AppColor.error,
|
||||
strokeWidth: 2,
|
||||
strokeColor: AppColor.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Net Flow Line (Net Profit)
|
||||
LineChartBarData(
|
||||
spots: _buildNetFlowSpots(),
|
||||
isCurved: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColor.info.withOpacity(0.8), AppColor.info],
|
||||
),
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: AppColor.info,
|
||||
strokeWidth: 2,
|
||||
strokeColor: AppColor.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyChart() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LineIcons.lineChart,
|
||||
size: 48,
|
||||
color: AppColor.textSecondary.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Tidak ada data untuk ditampilkan',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper methods for calculating data
|
||||
int _calculateTotalCashIn() {
|
||||
return dailyData.fold(0, (sum, data) => sum + data.revenue);
|
||||
}
|
||||
|
||||
int _calculateTotalCashOut() {
|
||||
return dailyData.fold(
|
||||
0,
|
||||
(sum, data) => sum + data.cost + data.tax + data.discount,
|
||||
);
|
||||
}
|
||||
|
||||
double _getMaxChartValue() {
|
||||
if (dailyData.isEmpty) return 30000000;
|
||||
|
||||
final maxRevenue = dailyData
|
||||
.map((e) => e.revenue)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
final maxCost = dailyData
|
||||
.map((e) => e.cost + e.tax + e.discount)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
final maxValue = maxRevenue > maxCost ? maxRevenue : maxCost;
|
||||
|
||||
return (maxValue * 1.2).toDouble(); // Add 20% padding
|
||||
}
|
||||
|
||||
double _getMinChartValue() {
|
||||
if (dailyData.isEmpty) return -5000000;
|
||||
|
||||
final minNetProfit = dailyData
|
||||
.map((e) => e.netProfit)
|
||||
.reduce((a, b) => a < b ? a : b);
|
||||
return minNetProfit < 0 ? (minNetProfit * 1.2).toDouble() : 0;
|
||||
}
|
||||
|
||||
List<FlSpot> _buildCashInSpots() {
|
||||
return dailyData.asMap().entries.map((entry) {
|
||||
return FlSpot(entry.key.toDouble(), entry.value.revenue.toDouble());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<FlSpot> _buildCashOutSpots() {
|
||||
return dailyData.asMap().entries.map((entry) {
|
||||
final totalCost =
|
||||
entry.value.cost + entry.value.tax + entry.value.discount;
|
||||
return FlSpot(entry.key.toDouble(), totalCost.toDouble());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<FlSpot> _buildNetFlowSpots() {
|
||||
return dailyData.asMap().entries.map((entry) {
|
||||
return FlSpot(entry.key.toDouble(), entry.value.netProfit.toDouble());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _getDayName(int weekday) {
|
||||
switch (weekday) {
|
||||
case 1:
|
||||
return 'Sen';
|
||||
case 2:
|
||||
return 'Sel';
|
||||
case 3:
|
||||
return 'Rab';
|
||||
case 4:
|
||||
return 'Kam';
|
||||
case 5:
|
||||
return 'Jum';
|
||||
case 6:
|
||||
return 'Sab';
|
||||
case 7:
|
||||
return 'Min';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatChartValue(double value) {
|
||||
if (value.abs() >= 1000000) {
|
||||
return '${(value / 1000000).toStringAsFixed(0)}M';
|
||||
} else if (value.abs() >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(0)}K';
|
||||
} else {
|
||||
return value.toStringAsFixed(0);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatCurrency(int amount) {
|
||||
if (amount.abs() >= 1000000000) {
|
||||
return 'Rp ${(amount / 1000000000).toStringAsFixed(1)}B';
|
||||
} else if (amount.abs() >= 1000000) {
|
||||
return 'Rp ${(amount / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (amount.abs() >= 1000) {
|
||||
return 'Rp ${(amount / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
return 'Rp ${NumberFormat('#,###', 'id_ID').format(amount)}';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChartLegend(String label, Color color) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCashFlowIndicator(
|
||||
String label,
|
||||
String amount,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.2)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
amount,
|
||||
style: AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,209 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:line_icons/line_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/analytic/analytic.dart';
|
||||
import '../../../components/widgets/empty_widget.dart';
|
||||
|
||||
class FinanceCategory extends StatelessWidget {
|
||||
final List<CategoryAnalyticItem> categories;
|
||||
|
||||
const FinanceCategory({super.key, required this.categories});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalRevenue = _calculateTotalRevenue();
|
||||
final sortedCategories = _sortCategoriesByRevenue();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.textLight.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.secondary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
LineIcons.pieChart,
|
||||
color: AppColor.secondary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.lang.sales_category,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Show empty state if no categories
|
||||
if (categories.isEmpty)
|
||||
_buildEmptyState(context)
|
||||
else
|
||||
...sortedCategories.asMap().entries.map(
|
||||
(entry) => _buildCategoryItem(
|
||||
context,
|
||||
entry.value,
|
||||
_calculatePercentage(entry.value.totalRevenue, totalRevenue),
|
||||
_getCategoryColor(entry.key),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryItem(
|
||||
BuildContext context,
|
||||
CategoryAnalyticItem category,
|
||||
double percentage,
|
||||
Color color,
|
||||
) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.categoryName,
|
||||
style: AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${category.productCount} ${context.lang.product} • ${category.orderCount} ${context.lang.orders}',
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
category.totalRevenue.currencyFormatRp,
|
||||
style: AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${NumberFormat('#,###', 'id_ID').format(category.totalQuantity)} ${context.lang.unit}',
|
||||
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: percentage / 100,
|
||||
backgroundColor: AppColor.borderLight,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 6,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${percentage.toStringAsFixed(1)}%',
|
||||
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return EmptyWidget(
|
||||
title: context.lang.category_no_data,
|
||||
message: context.lang.category_no_data_desc,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
int _calculateTotalRevenue() {
|
||||
return categories.fold(0, (sum, category) => sum + category.totalRevenue);
|
||||
}
|
||||
|
||||
List<CategoryAnalyticItem> _sortCategoriesByRevenue() {
|
||||
final sorted = List<CategoryAnalyticItem>.from(categories);
|
||||
sorted.sort((a, b) => b.totalRevenue.compareTo(a.totalRevenue));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
double _calculatePercentage(int categoryRevenue, int totalRevenue) {
|
||||
if (totalRevenue == 0) return 0;
|
||||
return (categoryRevenue / totalRevenue) * 100;
|
||||
}
|
||||
|
||||
Color _getCategoryColor(int index) {
|
||||
// Predefined color palette for categories
|
||||
const colors = [
|
||||
AppColor.primary,
|
||||
AppColor.secondary,
|
||||
AppColor.success,
|
||||
AppColor.warning,
|
||||
AppColor.error,
|
||||
AppColor.info,
|
||||
];
|
||||
|
||||
// Generate additional colors if needed
|
||||
if (index < colors.length) {
|
||||
return colors[index];
|
||||
} else {
|
||||
// Generate colors based on index for unlimited categories
|
||||
final hue = (index * 137.5) % 360; // Golden angle approximation
|
||||
return HSLColor.fromAHSL(1.0, hue, 0.7, 0.5).toColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
143
lib/presentation/pages/finance/widgets/cost_breakdown.dart
Normal file
143
lib/presentation/pages/finance/widgets/cost_breakdown.dart
Normal file
@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/analytic/analytic.dart';
|
||||
|
||||
class CostBreakdown extends StatelessWidget {
|
||||
final ProfitLossPurchasing purchasing;
|
||||
final int selectedTabIndex;
|
||||
final DateTime dateFrom;
|
||||
final DateTime dateTo;
|
||||
|
||||
const CostBreakdown({
|
||||
super.key,
|
||||
required this.purchasing,
|
||||
required this.selectedTabIndex,
|
||||
required this.dateFrom,
|
||||
required this.dateTo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isToday = selectedTabIndex == 0;
|
||||
final total = isToday ? purchasing.todayTotal : purchasing.mtdTotal;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 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: [
|
||||
// Title row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.cost_breakdown,
|
||||
style: AppStyle.lg.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColor.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDateLabel(dateFrom, dateTo),
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Item list
|
||||
...purchasing.items.map((item) => _buildItemRow(item)),
|
||||
|
||||
// Total row
|
||||
const Divider(height: 24, color: AppColor.borderLight),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.total_cost,
|
||||
style: AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColor.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
total.currencyFormatRp,
|
||||
style: AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColor.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemRow(ProfitLossPurchasingItem item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.item,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.nominal.currencyFormatRp,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateLabel(DateTime from, DateTime to) {
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'Mei',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Agu',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Des',
|
||||
];
|
||||
|
||||
if (from.year == to.year && from.month == to.month && from.day == to.day) {
|
||||
return '${from.day} ${months[from.month - 1]} ${from.year}';
|
||||
}
|
||||
return '${from.day} ${months[from.month - 1]} - ${to.day} ${months[to.month - 1]} ${to.year}';
|
||||
}
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/analytic/analytic.dart';
|
||||
|
||||
class ProfitLossProduct extends StatelessWidget {
|
||||
final ProfitLossProductData product;
|
||||
const ProfitLossProduct({super.key, required this.product});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColor.border.withOpacity(0.5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product header
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.productName,
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
product.categoryName,
|
||||
style: AppStyle.xs.copyWith(color: AppColor.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${product.quantitySold} terjual',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
Text(
|
||||
'${product.grossProfitMargin.toStringAsFixed(1)}%',
|
||||
style: AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: product.grossProfitMargin > 25
|
||||
? AppColor.success
|
||||
: product.grossProfitMargin > 15
|
||||
? AppColor.warning
|
||||
: AppColor.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Financial metrics
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMetricColumn(
|
||||
context.lang.revenue,
|
||||
product.revenue.currencyFormatRp,
|
||||
AppColor.success,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildMetricColumn(
|
||||
context.lang.cost,
|
||||
product.cost.currencyFormatRp,
|
||||
AppColor.error,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildMetricColumn(
|
||||
context.lang.gross_profit,
|
||||
product.grossProfit.currencyFormatRp,
|
||||
AppColor.info,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Average metrics
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMetricColumn(
|
||||
context.lang.average_price,
|
||||
product.averagePrice.currencyFormatRp,
|
||||
AppColor.textSecondary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildMetricColumn(
|
||||
context.lang.profit_per_unit,
|
||||
product.profitPerUnit.currencyFormatRp,
|
||||
AppColor.primary,
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricColumn(String label, String value, Color color) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: AppStyle.xs.copyWith(color: AppColor.textSecondary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: AppStyle.sm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,192 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:line_icons/line_icons.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/analytic/analytic.dart';
|
||||
|
||||
class FinanceProfitLoss extends StatelessWidget {
|
||||
final ProfitLossSummary data;
|
||||
|
||||
const FinanceProfitLoss({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.textLight.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.info.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
LineIcons.calculator,
|
||||
color: AppColor.info,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.lang.profit_loss_detail,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Total Revenue (Penjualan Kotor)
|
||||
_buildPLItem(
|
||||
context.lang.gross_sales,
|
||||
data.totalRevenue.currencyFormatRp,
|
||||
AppColor.success,
|
||||
true,
|
||||
),
|
||||
|
||||
// Discount (Diskon & Retur)
|
||||
_buildPLItem(
|
||||
'${context.lang.discount} & ${context.lang.return_text}',
|
||||
'- ${data.totalDiscount.currencyFormatRp}',
|
||||
AppColor.error,
|
||||
false,
|
||||
),
|
||||
|
||||
const Divider(height: 24),
|
||||
|
||||
// Net Sales (Penjualan Bersih = Total Revenue - Discount)
|
||||
_buildPLItem(
|
||||
context.lang.net_sales,
|
||||
(data.totalRevenue - data.totalDiscount).currencyFormatRp,
|
||||
AppColor.textPrimary,
|
||||
true,
|
||||
isHeader: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Cost of Goods Sold (HPP)
|
||||
_buildPLItem(
|
||||
'${context.lang.cogs} (${context.lang.cost_of_goods_sold})',
|
||||
'- ${data.totalCost.currencyFormatRp}',
|
||||
AppColor.error,
|
||||
false,
|
||||
),
|
||||
|
||||
const Divider(height: 24),
|
||||
|
||||
// Gross Profit (Laba Kotor)
|
||||
_buildPLItem(
|
||||
context.lang.gross_profit,
|
||||
data.grossProfit.currencyFormatRp,
|
||||
AppColor.success,
|
||||
true,
|
||||
isHeader: true,
|
||||
showPercentage: true,
|
||||
percentage: '${data.grossProfitMargin.toStringAsFixed(1)}%',
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Operational Cost (Biaya Operasional) - calculated as difference
|
||||
_buildPLItem(
|
||||
context.lang.operating_costs,
|
||||
'- ${_calculateOperationalCost().currencyFormatRp}',
|
||||
AppColor.error,
|
||||
false,
|
||||
),
|
||||
|
||||
const Divider(height: 24),
|
||||
|
||||
// Net Profit (Laba Bersih)
|
||||
_buildPLItem(
|
||||
context.lang.net_profit,
|
||||
data.netProfit.currencyFormatRp,
|
||||
AppColor.primary,
|
||||
true,
|
||||
isHeader: true,
|
||||
showPercentage: true,
|
||||
percentage: '${data.netProfitMargin.round()}%',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPLItem(
|
||||
String title,
|
||||
String amount,
|
||||
Color color,
|
||||
bool isPositive, {
|
||||
bool isHeader = false,
|
||||
bool showPercentage = false,
|
||||
String? percentage,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: isHeader
|
||||
? AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
)
|
||||
: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
amount,
|
||||
style: isHeader
|
||||
? AppStyle.lg.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
)
|
||||
: AppStyle.md.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (showPercentage && percentage != null)
|
||||
Text(
|
||||
percentage,
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate operational cost as the difference between gross profit and net profit
|
||||
int _calculateOperationalCost() {
|
||||
return data.grossProfit - data.netProfit - data.totalTax;
|
||||
}
|
||||
}
|
||||
389
lib/presentation/pages/finance/widgets/profit_loss_header.dart
Normal file
389
lib/presentation/pages/finance/widgets/profit_loss_header.dart
Normal file
@ -0,0 +1,389 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../../application/analytic/profit_loss_loader/profit_loss_loader_bloc.dart';
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/painter/wave_painter.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../components/spacer/spacer.dart';
|
||||
|
||||
class ProfitLossHeader extends StatelessWidget {
|
||||
final ProfitLossLoaderState state;
|
||||
final int selectedTabIndex;
|
||||
final ValueChanged<int> onTabChanged;
|
||||
|
||||
const ProfitLossHeader({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.selectedTabIndex,
|
||||
required this.onTabChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final outletLabel = state.profitLoss.outletName.isNotEmpty
|
||||
? state.profitLoss.outletName
|
||||
: 'Semua Outlet';
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: AppColor.primaryGradient,
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(24),
|
||||
bottomRight: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Decorative circles
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: -30,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColor.textWhite.withOpacity(0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 30,
|
||||
right: 20,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColor.textWhite.withOpacity(0.05),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: -20,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColor.textWhite.withOpacity(0.04),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Wave pattern
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: WavePainter(
|
||||
animation: 0.0,
|
||||
color: AppColor.textWhite.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back button + Title row
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => context.router.maybePop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.chevron_left_rounded,
|
||||
color: AppColor.textWhite,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SpaceWidth(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.profit_loss,
|
||||
style: AppStyle.xl.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
outletLabel,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.75),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SpaceHeight(20),
|
||||
|
||||
// Tab selector (Today / MTD)
|
||||
_buildTabSelector(context),
|
||||
|
||||
const SpaceHeight(24),
|
||||
|
||||
// Profit / Loss label with date
|
||||
Text(
|
||||
context.lang.profit_loss_date(
|
||||
_formatDateLabel(state.dateFrom, state.dateTo),
|
||||
),
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.75),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
|
||||
const SpaceHeight(4),
|
||||
|
||||
// Big profit/loss value
|
||||
state.isFetching
|
||||
? _buildHeaderValueShimmer()
|
||||
: Text(
|
||||
state.profitLoss.summary.netProfit.currencyFormatRp,
|
||||
style: AppStyle.h1.copyWith(
|
||||
color: state.profitLoss.summary.netProfit >= 0
|
||||
? AppColor.textWhite
|
||||
: AppColor.textWhite.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 32,
|
||||
),
|
||||
),
|
||||
|
||||
const SpaceHeight(16),
|
||||
|
||||
// Chips row (Omset + Total Biaya)
|
||||
state.isFetching
|
||||
? _buildHeaderChipsShimmer()
|
||||
: _buildHeaderChips(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabSelector(BuildContext context) {
|
||||
final todayLabel = _formatTodayTabLabel(DateTime.now());
|
||||
final mtdLabel = context.lang.mtd_month(
|
||||
_getMonthName(DateTime.now().month),
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: AppColor.textWhite.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
label: todayLabel,
|
||||
isSelected: selectedTabIndex == 0,
|
||||
onTap: () => onTabChanged(0),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildTab(
|
||||
label: mtdLabel,
|
||||
isSelected: selectedTabIndex == 1,
|
||||
onTap: () => onTabChanged(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTab({
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColor.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: isSelected ? AppColor.textPrimary : AppColor.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderValueShimmer() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: AppColor.textWhite.withOpacity(0.3),
|
||||
highlightColor: AppColor.textWhite.withOpacity(0.6),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderChipsShimmer() {
|
||||
return Row(
|
||||
children: List.generate(
|
||||
2,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: AppColor.textWhite.withOpacity(0.15),
|
||||
highlightColor: AppColor.textWhite.withOpacity(0.3),
|
||||
child: Container(
|
||||
width: 130,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderChips(BuildContext context) {
|
||||
final summary = state.profitLoss.summary;
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildChip(
|
||||
'${context.lang.sales} ${summary.totalRevenue.currencyFormatRp}',
|
||||
),
|
||||
_buildChip(
|
||||
'${context.lang.total_cost} ${summary.totalCost.currencyFormatRp}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChip(String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColor.textWhite.withOpacity(0.25)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTodayTabLabel(DateTime date) {
|
||||
const months = [
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember',
|
||||
];
|
||||
return '${date.day} ${months[date.month - 1]}';
|
||||
}
|
||||
|
||||
String _formatDateLabel(DateTime from, DateTime to) {
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'Mei',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Agu',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Des',
|
||||
];
|
||||
|
||||
if (from.year == to.year && from.month == to.month && from.day == to.day) {
|
||||
return '${from.day} ${months[from.month - 1]} ${from.year}';
|
||||
}
|
||||
return '${from.day} ${months[from.month - 1]} - ${to.day} ${months[to.month - 1]} ${to.year}';
|
||||
}
|
||||
|
||||
String _getMonthName(int month) {
|
||||
const months = [
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember',
|
||||
];
|
||||
return months[month - 1];
|
||||
}
|
||||
}
|
||||
238
lib/presentation/pages/finance/widgets/profit_loss_report.dart
Normal file
238
lib/presentation/pages/finance/widgets/profit_loss_report.dart
Normal file
@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/analytic/analytic.dart';
|
||||
|
||||
class ProfitLossReport extends StatelessWidget {
|
||||
final List<ProfitLossMainSummaryItem> mainSummary;
|
||||
final ProfitLossSummary summary;
|
||||
final int selectedTabIndex;
|
||||
|
||||
const ProfitLossReport({
|
||||
super.key,
|
||||
required this.mainSummary,
|
||||
required this.summary,
|
||||
required this.selectedTabIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isToday = selectedTabIndex == 0;
|
||||
final marginPct = isToday
|
||||
? summary.netProfitMargin.round()
|
||||
: summary.netProfitMargin.round();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(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: [
|
||||
// Title row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.profit_loss_report,
|
||||
style: AppStyle.lg.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColor.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'margin $marginPct%',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Main summary items
|
||||
...mainSummary.map((item) => _buildSummarySection(item, isToday)),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Net Profit/Loss footer
|
||||
_buildNetProfitFooter(context, isToday),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummarySection(ProfitLossMainSummaryItem item, bool isToday) {
|
||||
final nominal = isToday ? item.todayNominal : item.mtdNominal;
|
||||
final pct = isToday ? item.todayPct : item.mtdPct;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Main item row
|
||||
_buildItemRow(
|
||||
label: item.label,
|
||||
nominal: nominal,
|
||||
pct: pct,
|
||||
isBold: item.isBold,
|
||||
isSubItem: false,
|
||||
),
|
||||
|
||||
// Sub items
|
||||
...item.subItems.map((subItem) {
|
||||
final subNominal = isToday
|
||||
? subItem.todayNominal
|
||||
: subItem.mtdNominal;
|
||||
final subPct = isToday ? subItem.todayPct : subItem.mtdPct;
|
||||
return _buildItemRow(
|
||||
label: subItem.label,
|
||||
nominal: subNominal,
|
||||
pct: subPct,
|
||||
isBold: subItem.isBold,
|
||||
isSubItem: true,
|
||||
);
|
||||
}),
|
||||
|
||||
// Divider after section (except for sub-items only sections)
|
||||
if (item.isBold)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Divider(height: 1, color: AppColor.borderLight),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemRow({
|
||||
required String label,
|
||||
required int nominal,
|
||||
required double pct,
|
||||
required bool isBold,
|
||||
required bool isSubItem,
|
||||
}) {
|
||||
final isNegative = nominal < 0;
|
||||
final displayNominal = isNegative
|
||||
? '-${nominal.abs().currencyFormatRp}'
|
||||
: nominal.currencyFormatRp;
|
||||
final pctText = '${pct.round()}%';
|
||||
|
||||
// Determine color based on context
|
||||
Color nominalColor;
|
||||
if (isBold && isNegative) {
|
||||
nominalColor = AppColor.error;
|
||||
} else if (isBold) {
|
||||
nominalColor = AppColor.textPrimary;
|
||||
} else {
|
||||
nominalColor = AppColor.textPrimary;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: isSubItem ? 16 : 0, top: 6, bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// Label
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
label,
|
||||
style: isBold
|
||||
? AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColor.textPrimary,
|
||||
)
|
||||
: AppStyle.md.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Nominal
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
displayNominal,
|
||||
textAlign: TextAlign.right,
|
||||
style: isBold
|
||||
? AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: nominalColor,
|
||||
)
|
||||
: AppStyle.md.copyWith(
|
||||
color: AppColor.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Percentage
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
pctText,
|
||||
textAlign: TextAlign.right,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNetProfitFooter(BuildContext context, bool isToday) {
|
||||
final netProfit = summary.netProfit;
|
||||
final isNegative = netProfit < 0;
|
||||
final displayValue = isNegative
|
||||
? '-${netProfit.abs().currencyFormatRp}'
|
||||
: netProfit.currencyFormatRp;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isNegative
|
||||
? AppColor.error.withOpacity(0.08)
|
||||
: AppColor.success.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
context.lang.net_profit_loss,
|
||||
style: AppStyle.md.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isNegative ? AppColor.error : AppColor.success,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
displayValue,
|
||||
style: AppStyle.lg.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isNegative ? AppColor.error : AppColor.success,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/theme/theme.dart';
|
||||
|
||||
class FinanceSummaryCard extends StatelessWidget {
|
||||
const FinanceSummaryCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.amount,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.isPositive,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String amount;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isPositive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.textLight.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
amount,
|
||||
style: AppStyle.lg.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:line_icons/line_icons.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart';
|
||||
@ -9,13 +8,12 @@ import '../../../application/outlet/outlet_list_loader/outlet_list_loader_bloc.d
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../injection.dart';
|
||||
import '../../components/appbar/appbar.dart';
|
||||
import '../../components/field/date_range_picker_field.dart';
|
||||
import '../../components/spacer/spacer.dart';
|
||||
import 'widgets/ingredient_card.dart';
|
||||
import 'widgets/outlet_selector_field.dart';
|
||||
import 'widgets/purchase_daily_tile.dart';
|
||||
import 'widgets/stat_card.dart';
|
||||
import 'widgets/purchase_header.dart';
|
||||
import 'widgets/purchase_rincian_card.dart';
|
||||
import 'widgets/vendor_card.dart';
|
||||
|
||||
@RoutePage()
|
||||
@ -29,12 +27,14 @@ class PurchasePage extends StatefulWidget implements AutoRouteWrapper {
|
||||
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => getIt<PurchasingAnalyticLoaderBloc>()
|
||||
..add(const PurchasingAnalyticLoaderEvent.fetched()),
|
||||
create: (context) =>
|
||||
getIt<PurchasingAnalyticLoaderBloc>()
|
||||
..add(const PurchasingAnalyticLoaderEvent.fetched()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => getIt<OutletListLoaderBloc>()
|
||||
..add(const OutletListLoaderEvent.fetched()),
|
||||
create: (context) =>
|
||||
getIt<OutletListLoaderBloc>()
|
||||
..add(const OutletListLoaderEvent.fetched()),
|
||||
),
|
||||
],
|
||||
child: this,
|
||||
@ -74,19 +74,20 @@ class _PurchasePageState extends State<PurchasePage>
|
||||
backgroundColor: AppColor.background,
|
||||
body: MultiBlocListener(
|
||||
listeners: [
|
||||
// Re-fetch when date range changes
|
||||
BlocListener<PurchasingAnalyticLoaderBloc,
|
||||
PurchasingAnalyticLoaderState>(
|
||||
BlocListener<
|
||||
PurchasingAnalyticLoaderBloc,
|
||||
PurchasingAnalyticLoaderState
|
||||
>(
|
||||
listenWhen: (prev, curr) =>
|
||||
prev.dateFrom != curr.dateFrom ||
|
||||
prev.dateTo != curr.dateTo,
|
||||
prev.dateFrom != curr.dateFrom || prev.dateTo != curr.dateTo,
|
||||
listener: (context, _) => context
|
||||
.read<PurchasingAnalyticLoaderBloc>()
|
||||
.add(const PurchasingAnalyticLoaderEvent.fetched()),
|
||||
),
|
||||
// Re-fetch when outlet changes
|
||||
BlocListener<PurchasingAnalyticLoaderBloc,
|
||||
PurchasingAnalyticLoaderState>(
|
||||
BlocListener<
|
||||
PurchasingAnalyticLoaderBloc,
|
||||
PurchasingAnalyticLoaderState
|
||||
>(
|
||||
listenWhen: (prev, curr) => prev.outletId != curr.outletId,
|
||||
listener: (context, _) => context
|
||||
.read<PurchasingAnalyticLoaderBloc>()
|
||||
@ -95,108 +96,61 @@ class _PurchasePageState extends State<PurchasePage>
|
||||
],
|
||||
child: BlocBuilder<OutletListLoaderBloc, OutletListLoaderState>(
|
||||
builder: (context, outletListState) {
|
||||
return BlocBuilder<PurchasingAnalyticLoaderBloc,
|
||||
PurchasingAnalyticLoaderState>(
|
||||
return BlocBuilder<
|
||||
PurchasingAnalyticLoaderBloc,
|
||||
PurchasingAnalyticLoaderState
|
||||
>(
|
||||
builder: (context, state) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
elevation: 0,
|
||||
backgroundColor: AppColor.primary,
|
||||
flexibleSpace:
|
||||
CustomAppBar(title: context.lang.purchase),
|
||||
// Header (same style as Sales)
|
||||
SliverToBoxAdapter(
|
||||
child: PurchaseHeader(
|
||||
state: state,
|
||||
onDateRangeChanged: (startDate, endDate) {
|
||||
context.read<PurchasingAnalyticLoaderBloc>().add(
|
||||
PurchasingAnalyticLoaderEvent.rangeDateChanged(
|
||||
startDate,
|
||||
endDate,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Date Range + Outlet Picker
|
||||
// Outlet Selector
|
||||
SliverToBoxAdapter(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
DateRangePickerField(
|
||||
maxDate: DateTime.now(),
|
||||
startDate: state.dateFrom,
|
||||
endDate: state.dateTo,
|
||||
onChanged: (startDate, endDate) {
|
||||
context
|
||||
.read<PurchasingAnalyticLoaderBloc>()
|
||||
.add(
|
||||
PurchasingAnalyticLoaderEvent
|
||||
.rangeDateChanged(
|
||||
startDate!,
|
||||
endDate!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SpaceHeight(8),
|
||||
PurchaseOutletSelectorField(
|
||||
selectedOutletId: state.outletId,
|
||||
outlets: outletListState.outlets,
|
||||
isLoading: outletListState.isFetching,
|
||||
onOutletChanged: (outletId) {
|
||||
context
|
||||
.read<PurchasingAnalyticLoaderBloc>()
|
||||
.add(
|
||||
PurchasingAnalyticLoaderEvent
|
||||
.outletChanged(outletId),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: PurchaseOutletSelectorField(
|
||||
selectedOutletId: state.outletId,
|
||||
outlets: outletListState.outlets,
|
||||
isLoading: outletListState.isFetching,
|
||||
onOutletChanged: (outletId) {
|
||||
context.read<PurchasingAnalyticLoaderBloc>().add(
|
||||
PurchasingAnalyticLoaderEvent.outletChanged(
|
||||
outletId,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SpaceHeight(16)),
|
||||
|
||||
// Summary Section
|
||||
// Rincian Pembelian (same style as Sales)
|
||||
SliverToBoxAdapter(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.summary,
|
||||
style: AppStyle.xxl.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textPrimary,
|
||||
),
|
||||
),
|
||||
const SpaceHeight(16),
|
||||
state.isFetching
|
||||
? _buildSummaryShimmer()
|
||||
: _buildSummaryCards(state),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: PurchaseRincianCard(state: state),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SpaceHeight(24)),
|
||||
|
||||
// Total Purchases Highlight Card
|
||||
SliverToBoxAdapter(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: state.isFetching
|
||||
? _buildHighlightShimmer()
|
||||
: _buildTotalPurchasesCard(state),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SpaceHeight(24)),
|
||||
|
||||
// Daily Breakdown Header
|
||||
SliverToBoxAdapter(
|
||||
child: FadeTransition(
|
||||
@ -278,211 +232,13 @@ class _PurchasePageState extends State<PurchasePage>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Summary Cards ────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildSummaryCards(PurchasingAnalyticLoaderState state) {
|
||||
final s = state.purchasing.summary;
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Total Pembelian',
|
||||
value: s.totalPurchases.currencyFormatRp,
|
||||
icon: LineIcons.shoppingCart,
|
||||
iconColor: AppColor.primary,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
const SpaceWidth(12),
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Total PO',
|
||||
value: '${s.totalPurchaseOrders} PO',
|
||||
icon: LineIcons.fileAlt,
|
||||
iconColor: AppColor.info,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SpaceHeight(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Bahan Baku',
|
||||
value: s.rawMaterialPurchases.currencyFormatRp,
|
||||
icon: LineIcons.leaf,
|
||||
iconColor: AppColor.secondary,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
const SpaceWidth(12),
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Pengeluaran',
|
||||
value: s.expensePurchases.currencyFormatRp,
|
||||
icon: LineIcons.receipt,
|
||||
iconColor: AppColor.warning,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SpaceHeight(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Total Qty',
|
||||
value: '${s.totalQuantity} pcs',
|
||||
icon: LineIcons.boxes,
|
||||
iconColor: AppColor.warning,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
const SpaceWidth(12),
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Rata-rata PO',
|
||||
value: s.averagePurchaseOrderValue.round().currencyFormatRp,
|
||||
icon: LineIcons.dollarSign,
|
||||
iconColor: AppColor.secondaryDark,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SpaceHeight(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Item Bahan Baku',
|
||||
value: '${s.totalIngredients} item',
|
||||
icon: LineIcons.leaf,
|
||||
iconColor: AppColor.secondaryDark,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
const SpaceWidth(12),
|
||||
Expanded(
|
||||
child: PurchaseStatCard(
|
||||
title: 'Vendor',
|
||||
value: '${s.totalVendors} vendor',
|
||||
icon: LineIcons.truck,
|
||||
iconColor: AppColor.primaryDark,
|
||||
cardAnimation: _fadeAnimation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalPurchasesCard(PurchasingAnalyticLoaderState state) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 900),
|
||||
curve: Curves.bounceOut,
|
||||
builder: (context, value, _) {
|
||||
return Transform.scale(
|
||||
scale: value,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: AppColor.primaryGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.primary.withOpacity(0.35),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
LineIcons.shoppingBag,
|
||||
color: AppColor.textWhite,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SpaceWidth(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.total_purchase,
|
||||
style: TextStyle(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SpaceHeight(4),
|
||||
Text(
|
||||
state.purchasing.summary.totalPurchases
|
||||
.currencyFormatRp,
|
||||
style: const TextStyle(
|
||||
color: AppColor.textWhite,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${state.purchasing.summary.totalPurchaseOrders} PO',
|
||||
style: const TextStyle(
|
||||
color: AppColor.textWhite,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'purchase order',
|
||||
style: TextStyle(
|
||||
color: AppColor.textWhite.withOpacity(0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lists ────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildDailyList(PurchasingAnalyticLoaderState state) {
|
||||
if (state.purchasing.data.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: _buildEmptyState('Tidak ada data harian'));
|
||||
child: _buildEmptyState('Tidak ada data harian'),
|
||||
);
|
||||
}
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
@ -499,7 +255,8 @@ class _PurchasePageState extends State<PurchasePage>
|
||||
Widget _buildIngredientList(PurchasingAnalyticLoaderState state) {
|
||||
if (state.purchasing.ingredientData.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: _buildEmptyState('Tidak ada data bahan baku'));
|
||||
child: _buildEmptyState('Tidak ada data bahan baku'),
|
||||
);
|
||||
}
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
@ -516,7 +273,8 @@ class _PurchasePageState extends State<PurchasePage>
|
||||
Widget _buildVendorList(PurchasingAnalyticLoaderState state) {
|
||||
if (state.purchasing.vendorData.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: _buildEmptyState('Tidak ada data vendor'));
|
||||
child: _buildEmptyState('Tidak ada data vendor'),
|
||||
);
|
||||
}
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
@ -552,51 +310,6 @@ class _PurchasePageState extends State<PurchasePage>
|
||||
|
||||
// ─── Shimmer Loaders ──────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildSummaryShimmer() {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
const SpaceWidth(12),
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
],
|
||||
),
|
||||
const SpaceHeight(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
const SpaceWidth(12),
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
],
|
||||
),
|
||||
const SpaceHeight(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
const SpaceWidth(12),
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
],
|
||||
),
|
||||
const SpaceHeight(12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
const SpaceWidth(12),
|
||||
Expanded(child: _shimmerCard(height: 100)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHighlightShimmer() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _shimmerCard(height: 88),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListShimmer() {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
|
||||
318
lib/presentation/pages/purchase/widgets/purchase_header.dart
Normal file
318
lib/presentation/pages/purchase/widgets/purchase_header.dart
Normal file
@ -0,0 +1,318 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart';
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/painter/wave_painter.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../components/bottom_sheet/date_range_bottom_sheet.dart';
|
||||
import '../../../components/spacer/spacer.dart';
|
||||
|
||||
class PurchaseHeader extends StatelessWidget {
|
||||
final PurchasingAnalyticLoaderState state;
|
||||
final void Function(DateTime startDate, DateTime endDate)? onDateRangeChanged;
|
||||
|
||||
const PurchaseHeader({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.onDateRangeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateLabel = _formatDateRange(state.dateFrom, state.dateTo, context);
|
||||
final outletLabel = state.purchasing.outletName.isNotEmpty
|
||||
? state.purchasing.outletName
|
||||
: 'Semua Outlet';
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: AppColor.primaryGradient,
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(24),
|
||||
bottomRight: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Decorative circles
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: -30,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColor.textWhite.withOpacity(0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 30,
|
||||
right: 20,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColor.textWhite.withOpacity(0.05),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: -20,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColor.textWhite.withOpacity(0.04),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Wave pattern
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: WavePainter(
|
||||
animation: 0.0,
|
||||
color: AppColor.textWhite.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back button + Title row + Calendar button
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => context.router.maybePop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.chevron_left_rounded,
|
||||
color: AppColor.textWhite,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SpaceWidth(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.purchase,
|
||||
style: AppStyle.xl.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$dateLabel · $outletLabel',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.75),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SpaceWidth(8),
|
||||
// Date filter button
|
||||
GestureDetector(
|
||||
onTap: () => _showDatePicker(context),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.calendar_month_rounded,
|
||||
color: AppColor.textWhite,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SpaceHeight(24),
|
||||
|
||||
// Total Pembelian label
|
||||
Text(
|
||||
context.lang.total_purchase,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.75),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
|
||||
const SpaceHeight(4),
|
||||
|
||||
// Big value
|
||||
state.isFetching
|
||||
? _buildHeaderValueShimmer()
|
||||
: Text(
|
||||
state
|
||||
.purchasing
|
||||
.summary
|
||||
.totalPurchases
|
||||
.currencyFormatRp,
|
||||
style: AppStyle.h1.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 32,
|
||||
),
|
||||
),
|
||||
|
||||
const SpaceHeight(16),
|
||||
|
||||
// Chips row
|
||||
state.isFetching
|
||||
? _buildHeaderChipsShimmer()
|
||||
: _buildHeaderChips(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDatePicker(BuildContext context) {
|
||||
DateRangePickerBottomSheet.show(
|
||||
context: context,
|
||||
primaryColor: AppColor.primary,
|
||||
initialStartDate: state.dateFrom,
|
||||
initialEndDate: state.dateTo,
|
||||
maxDate: DateTime.now(),
|
||||
onChanged: (startDate, endDate) {
|
||||
if (startDate != null && endDate != null) {
|
||||
onDateRangeChanged?.call(startDate, endDate);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderValueShimmer() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: AppColor.textWhite.withOpacity(0.3),
|
||||
highlightColor: AppColor.textWhite.withOpacity(0.6),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderChipsShimmer() {
|
||||
return Row(
|
||||
children: List.generate(
|
||||
3,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: AppColor.textWhite.withOpacity(0.15),
|
||||
highlightColor: AppColor.textWhite.withOpacity(0.3),
|
||||
child: Container(
|
||||
width: 90,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderChips(BuildContext context) {
|
||||
final summary = state.purchasing.summary;
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildChip('${summary.totalPurchaseOrders} PO'),
|
||||
_buildChip('${summary.totalQuantity} pcs'),
|
||||
_buildChip('${summary.totalVendors} vendor'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChip(String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.textWhite.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColor.textWhite.withOpacity(0.25)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateRange(DateTime from, DateTime to, BuildContext context) {
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'Mei',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Agu',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Des',
|
||||
];
|
||||
|
||||
if (from.year == to.year && from.month == to.month && from.day == to.day) {
|
||||
return '${context.lang.report} ${from.day} ${months[from.month - 1]} ${from.year}';
|
||||
}
|
||||
return '${context.lang.report} ${from.day} ${months[from.month - 1]} - ${to.day} ${months[to.month - 1]} ${to.year}';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart';
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../components/spacer/spacer.dart';
|
||||
|
||||
class PurchaseRincianCard extends StatelessWidget {
|
||||
final PurchasingAnalyticLoaderState state;
|
||||
|
||||
const PurchaseRincianCard({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (state.isFetching) return _buildShimmer();
|
||||
|
||||
final summary = state.purchasing.summary;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.daily_breakdown,
|
||||
style: AppStyle.xl.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textPrimary,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SpaceHeight(16),
|
||||
_buildRow('Total Pembelian', summary.totalPurchases.currencyFormatRp),
|
||||
_buildDivider(),
|
||||
_buildRow(
|
||||
'Bahan Baku',
|
||||
summary.rawMaterialPurchases.currencyFormatRp,
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildRow('Pengeluaran', summary.expensePurchases.currencyFormatRp),
|
||||
_buildDivider(),
|
||||
_buildRow('Total PO', '${summary.totalPurchaseOrders} PO'),
|
||||
_buildDivider(),
|
||||
_buildRow('Total Qty', '${summary.totalQuantity} pcs'),
|
||||
_buildDivider(),
|
||||
_buildRow(
|
||||
'Rata-rata PO',
|
||||
summary.averagePurchaseOrderValue.round().currencyFormatRp,
|
||||
),
|
||||
_buildDivider(),
|
||||
_buildRow('Item Bahan Baku', '${summary.totalIngredients} item'),
|
||||
_buildDivider(),
|
||||
_buildRow('Vendor', '${summary.totalVendors} vendor'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(String label, String value, {bool isBold = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: isBold ? AppColor.textPrimary : AppColor.textSecondary,
|
||||
fontWeight: isBold ? FontWeight.w700 : FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.textPrimary,
|
||||
fontWeight: isBold ? FontWeight.w700 : FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: AppColor.border.withOpacity(0.5),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShimmer() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 140,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SpaceHeight(20),
|
||||
...List.generate(
|
||||
8,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 80,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user