Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b5637fc
feat: P&L, stock valuation and debtor aging reports with charts
iamvirul Jun 16, 2026
a605602
feat: 7-day trend chart, MTD card and payment mix donut on dashboard
iamvirul Jun 16, 2026
d2e771c
docs: update CHANGELOG for phase 3 and phase 4
iamvirul Jun 16, 2026
eafc01b
feat: add flutter_local_notifications_windows to FFI plugin list
iamvirul Jun 16, 2026
1532277
feat: add nextReturnNumber() to ReturnsDao using MAX() aggregate
iamvirul Jun 16, 2026
e591fd7
feat: add optional movementType param to adjustStock
iamvirul Jun 16, 2026
ef79fd2
feat: add invoiceReturnsProvider for per-invoice return history
iamvirul Jun 16, 2026
d52b9eb
feat: add sales returns UI to invoice detail screen
iamvirul Jun 16, 2026
d940452
docs: update changelog with sales returns UI
iamvirul Jun 16, 2026
41d2ab6
feat: extend dashboard stats to 30-day trend with GP and avg order me…
iamvirul Jun 16, 2026
ab299c3
feat: redesign dashboard with enterprise charts
iamvirul Jun 16, 2026
a5c7348
fix: improve gross profit contrast on MTD card blue background
iamvirul Jun 16, 2026
b1831f6
feat: add proper empty state views to reports screen
iamvirul Jun 16, 2026
edaa21f
fix: reduce input field label and hint font size to 13sp globally
iamvirul Jun 16, 2026
50824b9
fix: replace aspect-ratio grid with content-driven rows in P&L summary
iamvirul Jun 16, 2026
6be4051
feat: support fractional qty in POS for weight/volume unit types
iamvirul Jun 16, 2026
d25492f
fix: make P&L bar chart fill available width instead of fixed 440px
iamvirul Jun 16, 2026
1b3c56a
fix: clean up dashboard appbar title and tooltip behavior
iamvirul Jun 16, 2026
114e8e5
redesign: enterprise KPI cards with left accent stripe and value-firs…
iamvirul Jun 16, 2026
b09f6bb
fix: use white text in line chart tooltip for readability
iamvirul Jun 16, 2026
0b0e279
fix: reduce KPI card height with tighter aspect ratio and padding
iamvirul Jun 16, 2026
6dbd424
feat: add BMS logo and copyright footer to login screen
iamvirul Jun 16, 2026
afa4348
fix: use actual bms_logo.svg asset on login screen
iamvirul Jun 16, 2026
d412864
fix: dynamic copyright year on login screen
iamvirul Jun 16, 2026
416dd9e
docs: update changelog with phase 4 dashboard, POS, reports and login…
iamvirul Jun 16, 2026
07766a5
fix: address code review findings - atomicity, discount calc, keyboar…
iamvirul Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]

### Added
- Login screen BMS SVG logo from `assets/images/bms_logo.svg` with dynamic copyright footer (`DateTime.now().year`)
- POS fractional quantity support for weight/volume unit types (`kg`, `g`, `l`, `ml`) - product card tap opens qty dialog with decimal input, stepper uses unit-appropriate increments (0.25 for kg/l, 50 for g/ml), cart displays formatted decimal quantities
- Dashboard 30-day revenue trend line chart with dual series (Revenue solid, Gross Profit dashed) and gradient fill below each line
- Dashboard 7-day grouped bar chart showing Revenue vs Gross Profit side by side per day
- Dashboard MTD card sub-metrics: Gross Profit, Margin %, and Avg Order Value
- Empty state views on Reports screen for P&L (no data), Stock (no stock on hand), and Debtor Aging (all clear) tabs - 88px icon circle, bold title, muted subtitle
- Reports screen with three tabs: P&L, Stock Valuation, and Debtor Aging
- P&L tab - date range picker, revenue/COGS/gross profit/margin summary cards, daily revenue bar chart with horizontal scrolling for wide ranges
- Stock Valuation tab - total stock value card, per-product value list sorted by value with relative progress bar
- Debtor Aging tab - donut chart splitting outstanding balances into 0-30d / 31-60d / 61-90d / 90+ buckets with per-customer aging badge
- Dashboard 7-day revenue bar chart showing last seven days with day-of-week labels
- Dashboard month-to-date sales card with percentage growth vs previous month
- Dashboard payment method donut chart (cash / card / cheque / credit / mixed) for the current month
- `ReportsDao` - plain DAO class providing `getDailySales`, `getStockValuation`, and `getDebtorAging` using Drift typed join queries; no code-gen required
- `DailySales`, `StockValuationRow`, `DebtorAgingRow` data classes with computed properties (grossProfit, agingBucket, daysPastDue)
- CodeQL Advanced workflow scanning GitHub Actions workflows with `security-extended` and `security-and-quality` query suites
- Dependabot configuration for both `pub` and `github-actions` ecosystems with grouped minor/patch updates and co-dependent package groups (drift, riverpod, freezed, go_router)

### Changed
- Dashboard KPI cards redesigned with left accent stripe, value-first hierarchy (large bold value at bottom, muted label at top, small icon bubble top-right), shadow instead of border
- Dashboard KPI card grid `childAspectRatio` increased to 3 for shorter cards with tighter padding
- Dashboard AppBar title split into "Dashboard" headline and date subtitle in white70 - no em-dash separator
- Dashboard 30-day trend replaces the previous 7-day bar chart; provider now fetches 30 days of daily sales with gross profit
- P&L bar chart replaced fixed-width horizontal scroll with a full-width `BarChart` that fills available screen width; zero-revenue day bars rendered in border gray
- P&L summary grid replaced `GridView.count` (fixed aspect ratio causing oversized cards on desktop) with `Column` of `Row(Expanded, Expanded)` pairs for content-driven card height
- `InventoryRepository.adjustStock()` accepts optional `movementType` parameter so callers can record `return_in` movements instead of the default `in`
- `InvoiceDetailScreen` action row switched from `Row` to `Wrap` to handle three buttons without overflow on narrow screens
- Dashboard provider expanded to fetch 7-day trend, payment mix, MTD sales, and last-month sales in a single parallel await using Dart 3 record `.wait`
- Reports screen replaced placeholder card with full three-tab report suite
- `nextGrnNumber()` in `SuppliersDao` replaced full-table scan with a single `MAX()` aggregate query; O(n) -> O(log n) on the unique index

### Fixed
- Dashboard line chart tooltip text color changed to white - previously used `AppColors.primary` and `AppColors.success` which had poor contrast on the dark tooltip background
- Gross Profit value color on MTD blue card changed to `Color(0xFF69F0AE)` (light mint) for legibility against the primary blue gradient
- Input field label and hint font size reduced to 13sp globally via `InputDecorationTheme` - was inheriting a larger size that looked oversized in dense fields
- Code Quality CI workflow now parses `flutter analyze` output for `error` level issues instead of relying on exit code, which behaved inconsistently between macOS and Linux runners
- `subosito/flutter-action` action pinned to immutable commit hash to satisfy CodeQL unpinned-action finding
- `dart pub audit` removed from CI; command does not exist in Dart 3.12
- Quick Sales - no-invoice cash sale with automatic stock deduction and ledger entry
- GRN (Goods Receipt Note) - supplier picker, product line items, qty/cost editing, stock-in on confirm, cost price update, purchase history tab
- Petty Cash - daily float card, receipt photo capture (camera and gallery), approval workflow, category chips
Expand All @@ -18,7 +56,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Banner and logo assets in `docs/` for GitHub org branding
- Apache 2.0 license
- GitHub PR template and issue templates (bug report, feature request, task)
- Sales returns schema tables scaffolded for the next release
- Sales Returns UI: "Process Return" button on invoice detail (admin/manager only) opens a bottom sheet to select items, quantities, return type (refund/credit/exchange), and reason
- Return history section on invoice detail showing all past returns with type, total, and date
- `nextReturnNumber()` in `ReturnsDao` using `MAX()` aggregate for O(log n) return number generation
- `invoiceReturnsProvider` Riverpod provider to watch returns for a specific invoice
- Role-based routing with guards (Admin, Manager, Cashier, Viewer)
- Drift (SQLite) database with UUID primary keys and versioned migrations
- Collapsible sidebar rail navigation
Expand Down
26 changes: 25 additions & 1 deletion lib/core/theme/app_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,31 @@ abstract final class AppTheme {
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.error),
),
hintStyle: AppTextStyles.bodyMedium.copyWith(color: AppColors.textDisabled),
// Resting label inside the field (13sp keeps it proportional to dense inputs)
labelStyle: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
// Floating label above the field when focused/filled
floatingLabelStyle: const TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.borderFocus,
),
hintStyle: const TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.textDisabled,
),
errorStyle: const TextStyle(
fontFamily: 'Inter',
fontSize: 11,
color: AppColors.error,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
Expand Down
176 changes: 176 additions & 0 deletions lib/data/database/daos/reports_dao.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import 'package:drift/drift.dart';

import 'package:bms/data/database/app_database.dart';

class DailySales {
DailySales({required this.date, required this.revenue, required this.cogs});
final DateTime date;
final double revenue;
final double cogs;
double get grossProfit => revenue - cogs;
}

class StockValuationRow {
StockValuationRow({
required this.name,
required this.qty,
required this.costPrice,
required this.value,
});
final String name;
final double qty;
final double costPrice;
final double value;
}

class DebtorAgingRow {
DebtorAgingRow({
required this.customerId,
required this.name,
required this.balance,
required this.oldestUnpaidDate,
});
final String customerId;
final String name;
final double balance;
final DateTime? oldestUnpaidDate;

int get daysPastDue {
if (oldestUnpaidDate == null) return 0;
return DateTime.now().difference(oldestUnpaidDate!).inDays;
}

// 0 = 0-30d, 1 = 31-60d, 2 = 61-90d, 3 = 90+d
int get agingBucket {
final d = daysPastDue;
if (d <= 30) return 0;
if (d <= 60) return 1;
if (d <= 90) return 2;
return 3;
}
}

class ReportsDao {
ReportsDao(this._db);
final AppDatabase _db;

static String _dateKey(DateTime dt) =>
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';

Future<List<DailySales>> getDailySales(DateTime from, DateTime to) async {
// Invoice revenue (non-void)
final invoiceList = await (_db.select(_db.invoices)
..where((i) => i.createdAt.isBetweenValues(from, to) & i.status.equals('void').not()))
.get();

// Invoice COGS: items × product cost price
final cogsQuery = _db.select(_db.invoiceItems).join([
innerJoin(_db.invoices, _db.invoices.id.equalsExp(_db.invoiceItems.invoiceId)),
innerJoin(_db.products, _db.products.id.equalsExp(_db.invoiceItems.productId)),
]);
cogsQuery.where(
_db.invoices.createdAt.isBetweenValues(from, to) &
_db.invoices.status.equals('void').not(),
);
final cogsRows = await cogsQuery.get();

// Quick-sale revenue
final qsList = await (_db.select(_db.noInvoiceSales)
..where((s) => s.createdAt.isBetweenValues(from, to)))
.get();

// Quick-sale COGS: qty × product cost price
final qsCogsQuery = _db.select(_db.noInvoiceSales).join([
innerJoin(_db.products, _db.products.id.equalsExp(_db.noInvoiceSales.productId)),
]);
qsCogsQuery.where(_db.noInvoiceSales.createdAt.isBetweenValues(from, to));
final qsCogsRows = await qsCogsQuery.get();

// Merge into day buckets
final Map<String, double> revByDay = {};
final Map<String, double> cogsByDay = {};

for (final inv in invoiceList) {
final k = _dateKey(inv.createdAt);
revByDay[k] = (revByDay[k] ?? 0) + inv.total;
}
for (final row in cogsRows) {
final inv = row.readTable(_db.invoices);
final item = row.readTable(_db.invoiceItems);
final product = row.readTable(_db.products);
final k = _dateKey(inv.createdAt);
cogsByDay[k] = (cogsByDay[k] ?? 0) + item.qty * product.costPrice;
}
for (final qs in qsList) {
final k = _dateKey(qs.createdAt);
revByDay[k] = (revByDay[k] ?? 0) + qs.qty * qs.price;
}
for (final row in qsCogsRows) {
final qs = row.readTable(_db.noInvoiceSales);
final product = row.readTable(_db.products);
final k = _dateKey(qs.createdAt);
cogsByDay[k] = (cogsByDay[k] ?? 0) + qs.qty * product.costPrice;
}

// Fill every day in range (including days with no sales → 0)
final result = <DailySales>[];
var cursor = DateTime(from.year, from.month, from.day);
final end = DateTime(to.year, to.month, to.day);
while (!cursor.isAfter(end)) {
final k = _dateKey(cursor);
result.add(DailySales(
date: cursor,
revenue: revByDay[k] ?? 0,
cogs: cogsByDay[k] ?? 0,
));
cursor = cursor.add(const Duration(days: 1));
}
return result;
}

Future<List<StockValuationRow>> getStockValuation() async {
final query = _db.select(_db.products).join([
leftOuterJoin(_db.stock, _db.stock.productId.equalsExp(_db.products.id)),
]);
query.where(_db.products.isActive.equals(true));
final rows = await query.get();

return (rows.map((row) {
final product = row.readTable(_db.products);
final stockLevel = row.readTableOrNull(_db.stock);
final qty = stockLevel?.qty ?? 0;
return StockValuationRow(
name: product.name,
qty: qty,
costPrice: product.costPrice,
value: product.costPrice * qty,
);
}).where((r) => r.qty > 0).toList()
..sort((a, b) => b.value.compareTo(a.value)));
}

Future<List<DebtorAgingRow>> getDebtorAging() async {
final debtors = await (_db.select(_db.customers)
..where((c) => c.balance.isBiggerThanValue(0))
..orderBy([(c) => OrderingTerm.desc(c.balance)]))
.get();

final result = <DebtorAgingRow>[];
for (final customer in debtors) {
final oldest = await (_db.select(_db.invoices)
..where((i) =>
i.customerId.equals(customer.id) &
i.status.isIn(['open', 'partial']))
..orderBy([(i) => OrderingTerm.asc(i.createdAt)])
..limit(1))
.getSingleOrNull();
result.add(DebtorAgingRow(
customerId: customer.id,
name: customer.name,
balance: customer.balance,
oldestUnpaidDate: oldest?.createdAt,
));
}
return result;
}
}
22 changes: 21 additions & 1 deletion lib/data/database/daos/returns_dao.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,32 @@ part 'returns_dao.g.dart';
class ReturnsDao extends DatabaseAccessor<AppDatabase> with _$ReturnsDaoMixin {
ReturnsDao(super.db);

// Generates the next return number atomically. Must be called inside a
// transaction to prevent duplicates under concurrent access.
Future<String> _nextReturnNumber() async {
final maxExpr = salesReturns.returnNo.max();
final row =
await (selectOnly(salesReturns)..addColumns([maxExpr])).getSingle();
final maxVal = row.read(maxExpr);
int maxNumber = 0;
if (maxVal != null) {
final match = RegExp(r'RET-(\d+)').firstMatch(maxVal);
if (match != null) maxNumber = int.tryParse(match.group(1)!) ?? 0;
}
return 'RET-${(maxNumber + 1).toString().padLeft(5, '0')}';
}

// Inserts a return and its line items atomically, generating the return
// number inside the transaction to prevent duplicates.
Future<SalesReturn> insertReturnWithItems(
SalesReturnsCompanion entry,
List<ReturnItemsCompanion> items,
) =>
transaction(() async {
final salesReturn = await into(salesReturns).insertReturning(entry);
final returnNo = await _nextReturnNumber();
final salesReturn = await into(salesReturns).insertReturning(
entry.copyWith(returnNo: Value(returnNo)),
);
await batch((b) => b.insertAll(returnItems, items));
return salesReturn;
});
Expand Down
3 changes: 2 additions & 1 deletion lib/data/repositories/inventory_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class InventoryRepository {
required String userName,
String? refId,
String? refType,
String? movementType,
}) async {
final existing = await _inventory.getStock(productId);
final currentQty = existing?.qty ?? 0;
Expand All @@ -94,7 +95,7 @@ class InventoryRepository {
await _inventory.recordMovement(
StockMovementsCompanion.insert(
id: _uuid.v7(),
type: delta >= 0 ? 'in' : 'out',
type: movementType ?? (delta >= 0 ? 'in' : 'out'),
productId: productId,
qty: delta.abs(),
reason: Value(reason),
Expand Down
Loading