diff --git a/.kilo/agent/coding-standard.md b/.kilo/agent/coding-standard.md new file mode 100644 index 0000000..8be1294 --- /dev/null +++ b/.kilo/agent/coding-standard.md @@ -0,0 +1,183 @@ +# Coding Standard — Dutra Store (POS Grosir Flutter) + +Gunakan skill ini sebagai acuan setiap kali menulis, mengubah, atau meninjau kode aplikasi. Jika ada pertentangan, prioritaskan yang tercantum di sini. + +## 1. Prinsip Umum + +### 1.1 Clean Code +- Satu function/method lakukan satu hal saja (Single Responsibility). +- Nama variable/function harus deskriptif: `isPaying` bukan `flag`, `recordPayment` bukan `save`. +- Jangan duplikasi logic — extract ke function/helper yang reuseable. +- Komentar hanya untuk "mengapa", bukan "apa yang dilakukan". Kode harus self-explanatory untuk "apa". + +### 1.2 File Organization +- **Satu file satu class utama**. Exception: helper kecil yang sangat spesifik untuk screen itu. +- Nama file: `snake_case.dart`. Nama class: `PascalCase`. +- Urutan import: + 1. `dart:` core + 2. `package:` third-party + 3. `package:/` internal + 4. Relative import `./` atau `../` +- Jangan gunakan relative import untuk file di folder berbeda; gunakan package import. + +### 1.3 Naming Convention +- **Class/Widget**: `PascalCase` → `KasirScreen`, `_CartBarState` +- **Private class/widget**: underscore prefix → `_CartBar`, `_ReceiptRow` +- **Function/method**: `camelCase` → `addToCart`, `_showReceipt` +- **Variable**: `camelCase` → `isPayingRef`, `payingStart` +- **Constant**: `lowercase with underscores` atau `PascalCase` sesuai konvensi project. Untuk warna di `colors.dart` pakai `lowercase_with_underscores`: `kAccent`, `kSuccess`. +- **Boolean**: pakai prefix `is`, `has`, `should`, `can` → `isLoading`, `hasError`, `shouldShow` + +### 1.4 Null Safety +- Hindari `!` (force unwrap) kecuali benar-benar yakin tidak null. +- Gunakan `late` hanya untuk field yang di-init di `initState` atau setelah async yang dijamin selesai. +- Gunakan `?` (nullable) untuk parameter yang opsional. +- Gunakan `??` (default value) daripada ternary cek null. +- Jangan biarkan `dynamic` keculatau untuk JSON parsing yang benar-benar dinamis. + +## 2. State Management (Provider) + +### 2.1 AppState Structure +- `AppState` extends `ChangeNotifier`. +- Semua mutable state disimpan di `AppState`, bukan di widget state kecuali state lokal UI (misal `_search` TextEditingController). +- Gunakan `context.watch()` untuk rebuild saat state berubah. +- Gunakan `context.read()` untuk aksi tanpa rebuild (misal di `onPressed`). +- Jangan panggil `notifyListeners()` di dalam getter — getter harus pure. + +### 2.2 Async Operations +- Semua async yang modifikasi state harus wrapped dengan try-catch. +- Gunakan `_withTimeout()` untuk semua operasi Supabase agar tidak hang selamanya. +- Set `notifyListeners()` di akhir operasi sukses maupun gagal agar UI tetap konsisten. +- Jangan lupa handle `mounted` check sebelum `setState` atau `notifyListeners` di dalam async callback. + +### 2.3 Offline Queue +- Semua operasi tulis (insert/update/delete) ke Supabase harus menyimpan ke `_offlineQueue` jika gagal. +- Jangan duplicate queue logic — panggil `_offlineQueue.addToQueue()` di catch block. +- Saat online, `OfflineQueueService` akan otomatis retry. + +## 3. Supabase & Database + +### 3.1 Query Pattern +- Pakai `_withTimeout()` untuk semua query Supabase. +- Gunakan `.maybeSingle()` untuk query yang return 0 atau 1 row. +- Gunakan `.select()` dengan kolom spesifik kecuali butuh semua kolom. +- Jangan hardcode ID di query — gunakan parameter. + +### 3.2 Error Handling +- Tangkap `PostgrestException` untuk error spesifik Supabase. +- Tangkap `TimeoutException` untuk timeout. +- Tangkap `Exception` umum sebagai fallback. +- Jangan expose stack trace ke user — tampilkan pesan yang ramah. + +### 3.3 Transaction Safety +- Untuk operasi yang butuh atomic (misal checkout + update stock), lakukan di urutan yang benar: + 1. Insert sale + 2. Update stock untuk setiap item + 3. Reload products + sales +- Jika salah satu gagal, rollback manual atau tambahkan ke offline queue. + +## 4. Widget & UI Pattern + +### 4.1 Widget Composition +- Prefer composition over inheritance. +- Ekstrak widget kecil yang berulang jadi `const` widget terpisah. +- Gunakan `const` constructor untuk semua widget yang immutable. +- Jangan buat `StatefulWidget` kecuali butuh state lokal. + +### 4.2 StatefulWidget Best Practices +- State harus minimal — simpan hanya yang benar-benar perlu berubah. +- Jangan simpan objek kompleks di state tanpa dispose (misal controller, timer, animation). +- Semua `FocusNode`, `AnimationController`, `Timer`, `StreamSubscription` harus di-dispose. +- Gunakan `SingleTickerProviderStateMixin` untuk `AnimationController`, `TickerProviderStateMixin` untuk multiple. + +### 4.3 Dialog & Bottom Sheet +- Gunakan `showDialog` + `AlertDialog` untuk dialog konfirmasi/form pendek. +- Gunakan `showModalBottomSheet` dengan `isScrollControlled: true` untuk form panjang atau list yang perlu scroll. +- Bungkus form dengan `SingleChildScrollView` + `padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom)`. +- Gunakan `StatefulBuilder` jika dialog butuh update UI internal (misal loading, toggle). +- Jangan pass `context` dari dialog ke parent untuk navigasi — pass context yang benar saat showDialog. + +### 4.4 List & Grid +- Gunakan `ListView.builder` untuk list panjang, `ListView.separated` untuk list dengan separator. +- Gunakan `GridView.builder` untuk grid panjang. +- Tambahkan `shrinkWrap: true` dan `physics: NeverScrollableScrollPhysics()` jika list/grid di dalam `SingleChildScrollView`. +- Jangan lupa `itemExtent` atau `prototypeItem` jika list item heightnya fixed untuk performa. + +### 4.5 Text & Format +- Semua angka mata uang **wajib** menggunakan `formatRupiah()`. +- Jangan hardcode string angka/uang di UI. +- Gunakan `TextOverflow.ellipsis` untuk teks yang bisa panjang. +- Gunakan `maxLines` untuk membatasi teks di `Row`/`Column` yang bisa overflow. + +## 5. Error & Edge Case Handling + +### 5.1 Mounted Check +- Setiap async callback yang akses `context` atau `setState` harus check `if (!mounted) return` sebelum akses. +- Jangan gunakan `mounted` untuk logic bisnis — hanya untuk guard UI update. + +### 5.2 Empty/Null State +- Selalu handle kasus data kosong: `if (products.isEmpty)`, `if (cart.isEmpty)`. +- Tampilkan empty state yang informatif, bukan cuma `Container()` atau `SizedBox.shrink()`. + +### 5.3 Boundary Value +- Qty minimum: 1 (jika <= 0, remove dari cart). +- Harga minimum: 0 (jika <= 0, tampilkan error atau disable button). +- Stok: clamp ke `max(0, stok - qty)` saat checkout. +- Diskon: clamp ke `min(diskon, subtotal)`. + +## 6. Testing & Verification + +### 6.1 Static Analysis +- Jalankan `flutter analyze` sebelum commit. +- Target: **No issues found** (tanpa error, warning yang bisa diperbaiki). +- Dead code harus dihapus, tidak di-`//` comment. + +### 6.2 Manual Testing Checklist +- [ ] Test flow lengkap: login → kasir → tambah produk → bayar → struk → riwayat → detail. +- [ ] Test role kasir: akses yang disembunyikan benar-benar hilang. +- [ ] Test role admin: semua aksi admin muncul dan berfungsi. +- [ ] Test offline: matikan wifi, buat transaksi, hidupkan wifi, cek sync. +- [ ] Test keyboard: input uang/diskon dengan keyboard muncul. +- [ ] Test small screen: HP 5" atau emulator 360x640. +- [ ] Test landscape (jika applicable). +- [ ] Test rotation (jika applicable). + +### 6.3 Performance +- Jangan rebuild seluruh list saat satu item berubah — gunakan `ListView.builder` + `key`. +- Jangan simpan controller di `build()` — simpan di `State` dan dispose. +- Jangan lakukan network call di `build()` — pindah ke `initState` atau `didChangeDependencies`. +- Gunakan `const` constructor untuk widget yang immutable. + +## 7. Git & Commit + +### 7.1 Commit Message +- Format: `(): ` +- Type: `feat`, `fix`, `refactor`, `style`, `docs`, `test`, `chore` +- Contoh: `feat(kasir): tambah tombol Tambah Manual untuk pesanan offline` +- Jangan commit dengan message "update" atau "fix" tanpa deskripsi. + +### 7.2 Branching +- Feature branch dari `main`: `feature/nama-fitur` +- Bug fix dari `main`: `fix/nama-bug` +- Jangan commit langsung ke `main` kecuali hotfix yang sudah di-review. + +## 8. Anti-Patterns (Jangan Lakukan) + +- ❌ Jangan hardcode warna, spacing, atau font size — gunakan design tokens. +- ❌ Jangan pakai `GlobalKey` kecuali benar-benar perlu. +- ❌ Jangan simpan state di `StatelessWidget` — pindah ke `StatefulWidget` atau `AppState`. +- ❌ Jangan lupa dispose controller/timer/stream subscription. +- ❌ Jangan paksa `setState` di dalam `build()` — akan infinite loop. +- ❌ Jangan pakai `await` di dalam `build()` tanpa FutureBuilder/future handling. +- ❌ Jangan lupa handle error untuk semua async — jangan biarkan app crash. +- ❌ Jangan gabungkan logic bisnis dengan UI — pisahkan di `AppState` atau service layer. +- ❌ Jangan duplicate widget — ekstrak jadi widget terpisah atau helper function. +- ❌ Jangan lupa check `mounted` sebelum akses context setelah async gap. + +## 9. Referensi Codebase +- `lib/state/app_state.dart` — pattern state management, async handling, offline queue. +- `lib/screens/kasir_screen.dart` — pattern dialog, bottom sheet, form handling. +- `lib/screens/riwayat_screen.dart` — pattern list dengan role-based visibility, detail sheet. +- `lib/utils/colors.dart` — design tokens (warna, spacing, radius). +- `lib/utils/format.dart` — formatRupiah, formatTanggal, formatWaktu. +- `lib/models/` — pattern model dengan factory constructor + toJson/fromJson. diff --git a/.kilo/agent/uiux-pos-grosir.md b/.kilo/agent/uiux-pos-grosir.md index b61b5ae..c24726d 100644 --- a/.kilo/agent/uiux-pos-grosir.md +++ b/.kilo/agent/uiux-pos-grosir.md @@ -16,50 +16,150 @@ Gunakan skill ini sebagai acuan setiap kali mengubah, menambah, atau meninjau ta - Radius default: 18 untuk kartu, 12 untuk input/dialog, 8 untuk chip/tag. - Font: gunakan `Theme.of(context).textTheme` agar sesuai pengaturan sistem. -### 2.2 Warna Semantik +### 2.2 Warna Semantik & Token - `kAccent` — amber untuk aksen dan highlight. - `kSuccess` — hijau untuk status lunas/berhasil. - `kDanger` — merah untuk pembatalan/error/stok habis. +- `kWarning` — amber/oranye untuk peringatan stok rendah, bayar nanti. - `kMuted` — abu-abu untuk teks sekunder/timestamp. - `kSurface2` — background chip yang tidak aktif. - `kBorder` — border untuk chip dan outline. +- `kText` — warna teks utama (default di theme gelap). -### 2.3 Spacing & Layout -- Gunakan `EdgeInsets` tetap untuk konsistensi: - - `EdgeInsets.all(12)` padding screen/list. - - `EdgeInsets.all(16)` padding form/dialog. - - `EdgeInsets.symmetric(horizontal: 14, vertical: 10)` untuk baris list. -- Gap antar field/form: `SizedBox(height: 10)`. -- Gap antar section: `SizedBox(height: 14)`. +### 2.3 Typography Scale +Gunakan scale berikut, jangan hardcode font size kecuali specified: +- **Display / H1**: 28px, bold, untuk heading besar +- **H2**: 22px, bold, untuk section title +- **H3**: 18px, semi-bold, untuk card title / dialog title +- **Body large**: 16px, regular, untuk teks isi utama +- **Body**: 14px, regular, untuk teks body standar +- **Body small**: 13px, regular, untuk helper/subtitle +- **Caption**: 12px, regular, untuk timestamp/label kecil +- **Overline**: 10px, bold, uppercase tracking, untuk badge/status kecil +- **Monospace**: untuk angka/harga agar stabil -### 2.4 Kartu (Card) -- Semua item list/kartu harus punya padding minimal `EdgeInsets.all(12)`. -- Jangan hardcode tinggi kartu; gunakan `Column` + `MainAxisSize.min` atau `IntrinsicHeight` bila perlu. -- Jika ada 3+ field di dalam kartu, prioritaskan wrap/scroll. -- Status transaksi harus jelas: - - Lunas: badge/icon hijau. - - Belum lunas: badge/icon amber/oranye. - - Dibatalkan: badge/icon merah + `TextDecoration.lineThrough`. +Semua angka harga/uang **wajib** menggunakan `formatRupiah()`. -### 2.5 Input & Form -- Label jelas, helper text opsional untuk field opsional. -- Jangan taruh form di dalam `Column` tanpa `SingleChildScrollView` jika total tinggi form bisa melebihi layar. -- Saat keyboard muncul, pastikan field terlihat: - - Bungkus form dengan `SingleChildScrollView` + `padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom)`. +### 2.4 Spacing Scale +Pakai multiples of 4 untuk spacing: +- 4, 8, 12, 16, 20, 24, 32, 48 +- Padding screen/list: `EdgeInsets.all(12)` +- Padding form/dialog: `EdgeInsets.all(16)` +- Gap antar field: `SizedBox(height: 10)` atau `SizedBox(height: 12)` +- Gap antar section: `SizedBox(height: 14)` atau `SizedBox(height: 20)` +- Jangan gunakan nilai spacing yang tidak kelipatan 4 kecuali ada alasan khusus -### 2.6 Tombol & Aksi -- Primary action (Bayar, Tambah, Simpan): `FilledButton` berwarna aksen. -- Destructive action (Hapus, Batalkan): `FilledButton` / `OutlinedButton` berwarna merah (`kDanger`). -- Secondary action (Batal, Tutup): `TextButton` atau `OutlinedButton`. -- Setiap dialog harus punya urutan aksi: **Konfirmasi** di kanan/bawah, **Batal** di kiri/atas. +### 2.5 Elevation & Surface +- Card: `Card` widget dengan default elevation, atau `Container` dengan `color: kSurface2` jika butuh kontrol penuh. +- Active/pressed state: gunakan `InkWell` / `InkResponse` dengan splash color aksen transparan. +- Bottom sheet: `color: kSurface`, border top `kBorder`, radius top 24. +- Dialog: `backgroundColor: kSurface`, content padding dari `AlertDialog` default atau override sesuai kebutuhan. -### 2.6a Haptic & Feedback +### 2.6 Iconography +- Ukuran icon default: 20–24dp untuk list tile, 18dp untuk button, 28dp untuk dialog icon. +- Gunakan outlined variant untuk icon di dalam input/field (prefixIcon). +- Gunakan filled/rounded untuk icon utama dan CTA. +- Jangan campur gaya icon dalam satu screen. + +### 2.7 Animation Standard +- **Micro-interaction** (button press, card tap): duration 150–200ms, curve `Curves.easeInOut` +- **Sheet expand/collapse**: duration 300ms, curve `Curves.fastOutSlowIn` +- **Dialog enter/exit**: duration 200ms, curve `Curves.easeInOut` +- **Spinner**: `CircularProgressIndicator` dengan `strokeWidth: 2`, duration default +- **Pulse animation** (cart badge): duration 180ms, scale 1.0 → 0.9 → 1.0 +- **SnackBar**: duration default 3 detik, kecuali butuh lebih pendek/lama +- Jangan animasikan opacity/color untuk status penting (sukses/gagal) — gunakan warna solid agar cepat terdeteksi + +### 2.8 Haptic & Feedback - Aksi signifikan yang cepat: tambah item ke keranjang, print struk, scan barcode → `HapticFeedback.lightImpact()`. - Aksi destruktif atau butuh konfirmasi: hapus item/batal transaksi → `HapticFeedback.mediumImpact()`. - Sukses transaksi/tampilkan feedback →lanjut ke `SnackBar` hijau (`kSuccess`), jangan pakai dialog sukses untuk hal yang cepat. - Jangan paksa haptic di setiap tap; hanya untuk aksi yang jelas berdampak. -### 2.7 Auto-Scroll & Keyboard Behavior +### 2.9 Loading State +- **Inline di button** (recommended untuk semua operasi > 500ms): + - Spinner: `SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))` untuk light button text + - Spinner: `SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: kAccent))` untuk dark button text + - Ganti label menjadi `Mencetak...` / `Memproses...` / `Menyimpan...` + - Disable button (`onPressed: submitting ? null : ()`) +- **Full-screen**: gunakan `CircularProgressIndicator(color: kAccent)` di `Center`. +- **Inline di list/sheet**: gunakan `LinearProgressIndicator` hanya untuk determinate progress yang jelas. +- **Skeleton**: untuk list yang loading, pakai `ListTile` dengan `shimmer effect` atau placeholder abu-abu. +- Jangan biarkan user bisa double-tap aksi yang sudah loading. + +### 2.10 Empty State +- Jangan tampilkan teks kosong tanpa visual indicator. +- Minimal: icon 48dp + teks. Contoh: `Icon(Icons.shopping_cart_outlined, size: 48, color: kMuted)` + teks "Keranjang kosong". +- Center alignment, `Column` dengan `MainAxisSize.min`. +- Jika ada aksi yang bisa dilakukan (misal tambah produk), tampilkan `FilledButton` sebagai helper. +- Konsisten pakai ikon `outlined` variant untuk empty state. + +### 2.11 Error State +- Error dari form validation: tampilkan inline di bawah field, warna `kDanger`, font 13, `maxLines: 2`. +- Error dari operasi database/API: tampilkan `SnackBar` merah (`kDanger`) dengan durasi 3 detik. +- Jangan hanya pakai warna tanpa label. Tambahkan ikon/prefix jika perlu. +- Jika field error, pastikan `TextField` tetap bisa di-focus dan di-scroll. +- Untuk error yang bisa diperbaiki (misal form), jangan matikan form. + +### 2.12 Dialog Button Layout +- **Mobile (< 600dp)**: tombol dialog harus **full-width stacked vertikal** dengan `SizedBox(height: 8)` antar tombol. +- **Tablet (>= 600dp)**: bisa pakai `Row` horizontal untuk 2 tombol, dengan `Expanded`. +- Urutan: **Batal** di atas/kiri, **Konfirmasi** di bawah/kanan. +- Tombol konfirmasi destruktif: `FilledButton` dengan `backgroundColor: kDanger`. +- Tombol konfirmasi positif: `FilledButton` dengan `backgroundColor: kAccent` atau `kSuccess`. +- Gunakan `StatefulBuilder` jika dialog butuh loading state atau update UI setelah aksi. + +### 2.13 Role-Based Visibility +- Fitur yang tidak diizinkan untuk role tertentu harus **disembunyikan sepenuhnya**, bukan cuma di-disable. +- Admin: lihat semua transaksi + filter tanggal + aksi batalkan transaksi + pengaturan toko + kelola akun kasir + harga pokok/laba. +- Kasir: lihat transaksi hari ini, tidak ada aksi batalkan, tidak ada filter tanggal, tidak ada pengaturan toko, tidak ada harga pokok/laba. +- Guest: tidak diizinkan akses. + +### 2.14 Bottom Sheet +- Keranjang harus berupa bottom sheet, bukan panel tetap. +- Sheet harus bisa di-drag (`DraggableScrollableSheet` atau `showModalBottomSheet` dengan `isScrollControlled: true`). +- Konten sheet harus **selalu scrollable** (`SingleChildScrollView`), tanpa `Expanded` di dalamnya, agar footer tidak pernah terhalangi keyboard. +- Untuk menutup sheet dari aksi di dalamnya, gunakan `Navigator.pop(sheetContext)` bukan `parentContext`. +- Tambah handle bar (container 40x4 dengan radius 4, warna `kBorder`) di atas sheet untuk affordance drag. + +### 2.15 Dialog +- Semua dialog input yang panjang harus dibungkus `SingleChildScrollView` atau `SingleChildScrollView` di dalam `AlertDialog` agar bisa di-scroll saat keyboard muncul. +- Jangan pakai `Expanded` di dalam dialog yang berpotensi overflow. +- Dialog dengan form: `contentPadding: EdgeInsets.fromLTRB(16, 16, 16, 8)`, `actionsPadding: EdgeInsets.fromLTRB(16, 0, 16, 16)`. +- Dialog dengan list items: `contentPadding: EdgeInsets.all(16)`. +- Jangan buat dialog terlalu tinggi; batasi dengan `ConstrainedBox` + `SingleChildScrollView` jika konten bisa panjang. + +### 2.16 SnackBar +- Gunakan `ScaffoldMessenger.of(context).showSnackBar()` untuk feedback singkat. +- Durasi default 3 detik untuk info, 5 detik untuk error penting. +- Posisikan di atas bottom navigation jika ada, atau default bottom. +- Jangan tampilkan SnackBar di atas dialog yang masih terbuka — tunggu dialog ditutup dulu. + +### 2.17 Status & Badge +- Gunakan badge untuk status dengan warna sesuai: + - Lunas / Berhasil: badge hijau (`kSuccess`) dengan label teks + - Belum Lunas / Bayar Nanti: badge amber (`kWarning`) dengan label teks + - Dibatalkan: badge merah (`kDanger`) + teks `DIBATALKAN` + - Stok Habis: badge merah + teks "Habis" + - Stok Rendah: badge oranye + teks "Stok rendah (X/Y)" + - Manual: badge amber dengan label "MANUAL" (caps, tracking +0.5) +- Badge style: `Container` dengan `padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2)`, `borderRadius: BorderRadius.circular(8)`, `color: warna.withValues(alpha: 0.15)`. +- Untuk badge kecil (10–12px), gunakan `fontWeight: FontWeight.w800` agar tetap terbaca. + +### 2.18 List & Card +- Gunakan `ListView.separated` dengan `itemCount` yang sesuai untuk performa. +- Separator: `Divider(height: 1, thickness: 1, color: kBorder)` atau `SizedBox(height: 8/12)` untuk card list. +- Card list: `Card` widget dengan `margin: EdgeInsets.symmetric(horizontal: 12, vertical: 4)` atau `EdgeInsets.all(12)`. +- Jangan hardcode tinggi list item; gunakan `Column` + `MainAxisSize.min`. +- Teks panjang: `maxLines: 2`, `overflow: TextOverflow.ellipsis` untuk nama produk. + +### 2.19 Grid Produk +- Default: 2 kolom di layar HP (`crossAxisCount: 2`). +- Tablet (lebar > 600dp): naik jadi 3 kolom. +- Jangan pakai `childAspectRatio` tetap yang bikin kartu terlalu tinggi/pendek. Gunakan `AspectRatio(1.1)` atau biarkan `GridView` yang menentukan tinggi. +- Setiap kartu produk harus menampilkan: inisial nama (avatar circle amber), nama produk, harga, stok/badge stok. + +### 2.20 Auto-Scroll & Keyboard - Setiap form di dalam bottom sheet atau dialog harus meng-handle keyboard muncul/tutup. - Gunakan `MediaQuery.of(context).viewInsets.bottom` untuk padding bawah dinamis pada `SingleChildScrollView`. - Untuk bottom sheet form yang tinggi dan perlu auto-expand: @@ -69,69 +169,20 @@ Gunakan skill ini sebagai acuan setiap kali mengubah, menambah, atau meninjau ta 4. Durasi animasi sheet: `300ms`, scroll: `250ms`, alignment: `0.0`. - Jangan andal `Scaffold.resizeToAvoidBottomInset` saja untuk bottom sheet form kompleks; manual padding lebih stabil untuk kasus form panjang. -### 2.8 Empty State -- Jangan tampilkan teks kosong tanpa visual indicator. -- Minimal: icon + teks. Contoh: `Icon(Icons.shopping_cart_outlined, size: 48, color: kMuted)` + teks "Keranjang kosong". -- Center alignment, `Column` dengan `MainAxisSize.min`. -- Konsisten pakai ikon `outlined` variant untuk empty state. +### 2.21 Responsif +- Mobile (< 600dp): 2 kolom grid, tombol stacked vertikal di dialog. +- Tablet (>= 600dp): 3–4 kolom grid, tombol bisa horizontal di dialog. +- Gunakan `LayoutBuilder` atau `MediaQuery` untuk menentukan layout berdasarkan lebar screen. +- Jangan gunakan hardcoded width untuk widget utama; gunakan `Expanded`, `Flexible`, atau `MediaQuery`. -### 2.9 Dialog Button Layout -- Mobile: tombol dialog harus **full-width stacked vertikal** dengan `SizedBox(height: 8)` antar tombol. -- Urutan: **Batal** di atas, **Konfirmasi** di bawah. -- Jangan pakai `Row` untuk 2 tombol di dialog mobile, kecuali screen width > 600dp. -- Tombol konfirmasi destruktif: `FilledButton` dengan `backgroundColor: kDanger`. -- Tombol konfirmasi positif: `FilledButton` dengan `backgroundColor: kAccent` atau `kSuccess`. +### 2.22 Aksesibilitas +- Minimal tap target: 44x44dp untuk semua tombol/icon. +- Kontras teks minimal 4.5:1 (cek dengan tool contrast checker). +- Jangan andal warna saja (tambah ikon/label). Contoh: stok habis = badge merah + teks "Habis". +- Setiap aksi ada `SnackBar` feedback: sukses hijau, error merah, info biru/abu. +- Gunakan `Semantics` widget untuk icon-only button agar screen reader bisa membaca. -### 2.10 Loading State -- Untuk operasi < 500ms: tidak perlu loading indicator. -- Untuk operasi > 500ms: tampilkan loading di button yang dipencet. - - Spinner: `CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F))` untuk light button text. - - Spinner: `CircularProgressIndicator(strokeWidth: 2, color: kAccent)` untuk dark button text. -- Untuk full-screen loading: gunakan `CircularProgressIndicator(color: kAccent)` di `Center`. -- Jangan gunakan `LinearProgressIndicator` kecuali untuk determinate progress yang jelas. -- Jika list kosong dan pengguna bisa melakukan aksi (misal tambah produk), tampilkan `FloatingActionButton` atau `FilledButton` sebagai helper agar tidak dead-end. - -### 2.11 Error State -- Error dari form validation: tampilkan inline di bawah field, warna `kDanger`, font 13. -- Error dari operasi database/API: tampilkan `SnackBar` merah (`kDanger`). -- Jangan hanya pakai warna tanpa label. Tambahkan ikon/prefix jika perlu. -- Jika field error, pastikan `TextField` tetap bisa di-focus dan di-scroll. - -## 3. Responsif & Overflow - -### 3.1 Grid Produk (Kasir) -- Default: 2 kolom di layar HP (`crossAxisCount: 2`). -- Tablet (lebar > 600dp): naik jadi 3 kolom. -- Jangan pakai `childAspectRatio` tetap yang bikin kartu terlalu tinggi/pendek. Gunakan `AspectRatio(1.1)` atau biarkan `GridView` yang menentukan tinggi. - -### 3.2 Payment Method Chips -- Urutan chip di UI: **Tunai**, **Bank**, **QRIS**, **Bayar Nanti**, **Split**. -- Untuk mobile, kelompokkan menjadi: - - Baris atas: 3 chip (`Tunai`, `Bank`, `QRIS`) dalam satu `Row` dengan `Expanded`. - - Divider tipis antar kelompok. - - Baris bawah: 2 chip (`Bayar Nanti`, `Split`) dalam satu `Row` dengan `Expanded`. -- Jangan pakai `Wrap` untuk payment chips. `Wrap` boleh dipakai kembali untuk kasus lain seperti `Filter chips` atau `Tag`. -- Chip harus compact, aman di layar kecil, dan tidak memaksa scroll horizontal. - -### 3.3 Bottom Sheet -- Keranjang harus berupa bottom sheet, bukan panel tetap. -- Sheet harus bisa di-drag (`DraggableScrollableSheet` atau `showModalBottomSheet` dengan `isScrollControlled: true`). -- Konten sheet harus **selalu scrollable** (`SingleChildScrollView`), tanpa `Expanded` di dalamnya, agar footer tidak pernah terhalangi keyboard. -- Untuk menutup sheet dari aksi di dalamnya, gunakan `Navigator.pop(sheetContext)` bukan `parentContext`. - -### 3.4 Dialog -- Semua dialog input yang panjang harus dibungkus `SingleChildScrollView` atau `SingleChildScrollView` di dalam `AlertDialog` agar bisa di-scroll saat keyboard muncul. -- Jangan pakai `Expanded` di dalam dialog yang berpotensi overflow. - -### 3.5 Produk & Form Panjang -- Tab Produk adalah gabungan form + list produk. Gunakan `SingleChildScrollView` + `ListView.builder` dengan `shrinkWrap: true` agar form bisa di-scroll tanpa overflow. - -### 3.6 Teks -- Jangan hardcode font size. Gunakan skema `TextStyle` dari `ThemeData`. -- Jika label teks bisa panjang (mis. nama produk), batasi maksimal 2 baris dengan `maxLines: 2` + `overflow: TextOverflow.ellipsis`. -- ID transaksi/nama panjang: `Expanded` + `TextOverflow.ellipsis` agar tidak overflow di `Row`. - -## 4. UX untuk Kasir (Critical Path) +## 3. UX untuk Kasir (Critical Path) - Target: transaksi selesai dalam 3 tap (pilih produk → bayar → selesai). - Jangan sembunyikan aksi utama (Bayar) di bawah banyak opsi. - Tampilkan total, diskon, dan kembalian secara **real-time**. @@ -139,48 +190,22 @@ Gunakan skill ini sebagai acuan setiap kali mengubah, menambah, atau meninjau ta - Setiap item di keranjang harus bisa diubah qty atau tipe harga dalam 1 tap. - Jika stok habis, matikan visual (opacity turun, disable tap) dan tampilkan badge "Habis". - Jika stok menipis, tampilkan badge peringatan oranye sebelum habis. +- Bottom bar keranjang harus selalu terlihat, tidak tertutup keyboard. -## 5. Payment Flow: Bayar Nanti +## 4. Payment Flow: Bayar Nanti - Saat checkout pilih **Bayar Nanti**, tombol harus jelas: **"Pesanan Sekarang"**. - Field "Uang Dibayar" menjadi disabled dengan label **"Belum Bayar"**. -- Tampilkan status **"Belum Bayar"** di receipt/summary dengan warna `kMuted`. +- Tampilkan status **"Belum Bayar"** di receipt/summary dengan warna `kWarning`. - Di Riwayat, transaksi unpaid harus punya badge/status yang menonjol. - Saat customer datang bayar: tombol **"Terima Pembayaran"** atau **"Bayar Sisa"** di detail transaksi. -## 6. Aksesibilitas & Feedback -- Minimal tap target: 44x44dp untuk semua tombol/icon. -- Warna teks harus kontras dengan background (minimal 4.5:1 untuk teks normal). -- Jangan hanya andal warna untuk status (tambah ikon/label). Contoh: stok habis = badge merah + teks "Habis". -- Setiap aksi berhasil/gagal harus ada `SnackBar` feedback: - - Sukses: hijau (`kSuccess`). - - Error: merah (`kDanger`). - - Info: biru/abu. -- Loading state harus muncul saat operasi > 500ms. Gunakan `CircularProgressIndicator(color: kAccent)`. - -## 7. Role-Based Visibility -- Fitur yang tidak diizinkan untuk role tertentu harus **disembunyikan sepenuhnya**, bukan cuma di-disable. -- Admin: lihat semua transaksi + filter tanggal + aksi batalkan transaksi. -- Kasir: lihat transaksi hari ini, tidak ada aksi batalkan, tidak ada filter tanggal. - -## 8. List & Pagination -- Gunakan `ListView.separated` dengan `itemCount` yang sesuai untuk performa. -- Tombol "Muat lebih banyak" harus ada di bawah list jika data bisa banyak. -- Filter chips: gunakan `Wrap` agar responsif di layar kecil. - -## 8a. Printer & Thermal Receipt -- Semua teks struk harus Bahasa Indonesia dan ringkas. -- Struk wajib menampilkan: nama toko, tanggal, jam, nomor transaksi, daftar item, diskon (jika ada), total, metode pembayaran, detail pembayaran tunai/split (jika ada), dan **nama kasir**. -- Jangan tampilkan label per-item **Umum/Reseller** di struk thermal, agar lebih rapi. -- Jika `splitPayment` ada, tampilkan per bagian: `Split Tunai`, `Split Bank`, atau `Split QRIS` beserta nominalnya. -- Batasi lebar konten sesuai PaperSize.mm58 agar tidak terpotong. -- Setelah cetak, arahkan feedback ke user melalui `SnackBar`, bukan hanya log. -- Flow pairing printer harus bisa diakses oleh role kasir, bukan hanya admin. - -## 9. Reusability -- Buat widget kecil reuseable untuk komponen yang berulang: `_Tag`, `_ReportCard`, `_Avatar`, `_Stepper`, `_PaymentMethodChip`. +## 5. Reusability & Konsistensi +- Buat widget reuseable untuk komponen berulang: `_Tag`, `_ReportCard`, `_Avatar`, `_Stepper`, `_PaymentMethodChip`, `_ReceiptRow`, `_SettingsListTile`. - Jangan duplikasi warna/spacing. Taruh konstanta di `lib/utils/colors.dart`. +- Konsisten pakai `formatRupiah()` untuk semua angka mata uang. +- Nama widget internal: pakai underscore prefix (`_`) kecuali widget yang diekspor. -## 10. Checklist Sebelum Submit Perubahan UI/UX +## 6. Checklist Sebelum Submit Perubahan UI/UX - [ ] `flutter analyze` → No issues found (tanpa error). - [ ] Coba di layar kecil (HP 5") dan besar (tablet). - [ ] Coba dengan keyboard muncul (input uang/diskon). @@ -188,8 +213,12 @@ Gunakan skill ini sebagai acuan setiap kali mengubah, menambah, atau meninjau ta - [ ] Pastikan tidak ada hardcoded height yang bikin overflow. - [ ] Semua teks dalam Bahasa Indonesia. - [ ] Konsisten pakai `formatRupiah()` untuk angka mata uang. +- [ ] Coba flow lengkap: tambah produk → checkout → cetak struk → riwayat → detail. +- [ ] Coba role kasir dan admin untuk memastikan visibility benar. -## 11. Referensi Project +## 7. Referensi Project - `lib/utils/colors.dart` — design tokens. - `lib/screens/kasir_screen.dart` — contoh bottom sheet cart, grid produk, dialog pembayaran. - `lib/screens/riwayat_screen.dart` — contoh kartu laporan, filter role-based. +- `lib/screens/pengaturan_screen.dart` — contoh settings form + role-based visibility. + diff --git a/PRD.md b/PRD.md index 6b37988..c740ec9 100644 --- a/PRD.md +++ b/PRD.md @@ -2,6 +2,23 @@ ## Changelog +### v1.1.0 — 2026-07-24 +- Dialog pembayaran kasir: tombol "Selesai & Cetak" menampilkan loading state (spinner + "Memproses...") dengan guard anti-doble-klik. +- Dialog struk pembayaran: tombol "Cetak Struk" memiliki loading state ("Mencetak...") dan disable saat proses cetak. +- Metode Bayar Nanti: dialog struk mengubah judul menjadi "Pesanan Berhasil" dengan icon receipt. +- Dialog pembayaran di Riwayat: di-redesign dengan card summary (Total, Sudah Bayar, Sisa), badge status, helper text dinamis, StatefulBuilder + loading spinner, actions horizontal (Batal | Simpan). +- Dialog batalkan transaksi di Riwayat: icon warning amber, StatefulBuilder + loading spinner, actions horizontal (Batal | Batalkan). +- Bottom sheet detail riwayat: menggunakan StatefulBuilder dengan `currentSale` mutable agar data ter-update setelah pembayaran tercatat; dialog pembayaran otomatis tutup setelah simpan. +- Perbaiki kembalian minus: field "Kembali" hanya ditampilkan jika `paymentStatus == 'paid'` dan di-clamp ke `max(0, ...)`. Kolom `change` juga di-update saat `recordPayment`. +- Fitur "Tambah Pesanan Manual" di kasir: tombol "Tambah Manual" di bawah search bar untuk input produk yang belum ada di database, masuk keranjang dengan badge "MANUAL", dan tidak mengotori stok produk. +- Role kasir diperbolehkan menerima pembayaran untuk transaksi unpaid/partial dari detail riwayat. +- Sembunyikan "Harga Pokok" dan "Laba Kotor" di detail transaksi untuk role kasir; hanya admin yang melihatnya. +- Bottom nav kasir: tab "Printer" diganti "Pengaturan". Admin tetap punya tab "Pengaturan" untuk pengaturan toko + akun kasir. +- Settings: tambah periodic sync setiap 30 detik agar multi-device tetap sinkron; pull-to-refresh tetap ada dan reset timer. +- Tampilkan versi aplikasi di Pengaturan menggunakan `package_info_plus` (format: "Versi X.Y.Z (build)"). +- **Preparasi multi-tenant:** tambah kolom `store_id` ke tabel `products`, `sales`, `store_settings`, dan tabel baru `stores`. Semua query app kini include `store_id`. RLS dimatikan untuk sementara. +- Deploy release APK ke device: **RMX2061** (Android 11 API 30). + ### v1.0.1 — 2026-07-22 - Perbaikan UI/UX form produk: auto-scroll keyboard pada Tambah Produk dan Edit Produk. - reorganisasi metode pembayaran: kelompok atas 3 (Tunai, Bank, QRIS), kelompok bawah 2 (Bayar Nanti, Split). @@ -454,7 +471,8 @@ Dutra Store adalah aplikasi kasir digital berbasis Flutter untuk toko grosir. Ap - ✅ Dialog pembayaran: split payment support dengan total split real-time - ✅ Receipt & detail riwayat: menampilkan metode pembayaran dan breakdown split payment - ✅ Sale model: field payment_method + split_payment untuk dokumentasi transaksi -- ✅ Release APK (app-release.apk 66.9MB) +- ✅ Release APK (app-release.apk 67.5MB) +- ✅ Multi-tenant preparation: kolom `store_id` ditambahkan ke tabel `products`, `sales`, `store_settings`, dan tabel baru `stores`. Semua query app kini include `store_id` untuk preparasi multi-store. ### Fitur yang Didefer / Belum - ✅ Ganti password akun kasir via Supabase admin API (`auth.admin.updateUserById` tersedia di gotrue 2.26.0). @@ -472,5 +490,6 @@ Dutra Store adalah aplikasi kasir digital berbasis Flutter untuk toko grosir. Ap 1. Jalankan SQL di `supabase/migrations/20240101_add_cancel_and_min_stock.sql` untuk kolom `is_cancelled` (sales) dan `min_stock` (products). 2. Jalankan SQL di `supabase/migrations/20240102_add_harga_pokok_and_profit.sql` untuk kolom `harga_pokok` (products) dan `total_cost` (sales). 3. Jalankan SQL di `supabase/migrations/20240103_add_payment_method.sql` untuk kolom `payment_method` dan `split_payment` (sales) — **sudah dijalankan**. + 4. Jalankan SQL tambahan untuk kolom `store_id` (multi-tenant preparation): tambah tabel `stores`, tambah kolom `store_id` ke `products`, `sales`, `store_settings`, backfill data existing dengan default store ID, dan buat index. RLS dimatikan untuk semua tabel. - UI/UX skill tersimpan di `.kilo/agent/uiux-pos-grosir.md` dan bagian PRD 10.1-10.7. - Color scheme: brand amber dominant di Kasir & Produk, hijau untuk harga umum, merah untuk error/habis, abu untuk muted. diff --git a/lib/models/product.dart b/lib/models/product.dart index b5beda9..5fd3d5c 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -1,3 +1,5 @@ +import 'package:uuid/uuid.dart'; + class Product { final String id; final String name; @@ -19,6 +21,17 @@ class Product { this.hargaPokok = 0, }); + factory Product.manual(String name, double price) { + return Product( + id: 'manual-${const Uuid().v4()}', + name: name, + price: price, + stock: 99999, + minStock: 0, + hargaPokok: 0, + ); + } + factory Product.fromJson(Map json) { return Product( id: json['id'] as String, diff --git a/lib/screens/home_page.dart b/lib/screens/home_page.dart index d75fc2c..3bb86a8 100644 --- a/lib/screens/home_page.dart +++ b/lib/screens/home_page.dart @@ -5,7 +5,6 @@ import 'kasir_screen.dart'; import 'produk_screen.dart'; import 'riwayat_screen.dart'; import 'pengaturan_screen.dart'; -import 'printer_screen.dart'; import '../state/app_state.dart'; import '../utils/colors.dart'; @@ -30,7 +29,7 @@ class _HomePageState extends State { } else { screens.addAll([ const RiwayatScreen(), - const PrinterScreen(), + const PengaturanScreen(), ]); } return screens; @@ -65,8 +64,8 @@ class _HomePageState extends State { label: 'Riwayat', ), const BottomNavigationBarItem( - icon: Icon(Icons.print_outlined), - label: 'Printer', + icon: Icon(Icons.settings_outlined), + label: 'Pengaturan', ), ]); } diff --git a/lib/screens/kasir_screen.dart b/lib/screens/kasir_screen.dart index c2271cd..b71624d 100644 --- a/lib/screens/kasir_screen.dart +++ b/lib/screens/kasir_screen.dart @@ -72,6 +72,125 @@ class _KasirScreenState extends State { ), ), ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + final nameCtrl = TextEditingController(); + final priceCtrl = TextEditingController(); + final qtyCtrl = TextEditingController(text: '1'); + final result = await showDialog>( + context: context, + builder: (ctx) { + bool submitting = false; + return StatefulBuilder( + builder: (context, setDialog) { + return AlertDialog( + title: const Text('Tambah Pesanan Manual'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: 'Nama Produk', + prefixIcon: Icon(Icons.shopping_bag_outlined), + ), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 12), + TextField( + controller: priceCtrl, + keyboardType: TextInputType.number, + inputFormatters: [RupiahInputFormatter()], + decoration: const InputDecoration( + labelText: 'Harga', + prefixText: 'Rp ', + prefixIcon: Icon(Icons.payments_outlined), + ), + ), + const SizedBox(height: 12), + TextField( + controller: qtyCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Qty', + prefixIcon: Icon(Icons.numbers), + ), + ), + ], + ), + ), + actions: [ + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: submitting ? null : () => Navigator.pop(ctx), + style: OutlinedButton.styleFrom( + foregroundColor: kMuted, + side: const BorderSide(color: kBorder), + ), + child: const Text('Batal'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: submitting + ? null + : () { + final name = nameCtrl.text.trim(); + final price = (RupiahInputFormatter.getRawValue(priceCtrl.text) ?? 0).toDouble(); + final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1; + if (name.isEmpty || price <= 0 || qty <= 0) return; + Navigator.pop(ctx, { + 'name': name, + 'price': price, + 'qty': qty, + }); + }, + style: FilledButton.styleFrom(backgroundColor: kAccent, foregroundColor: const Color(0xFF06210F)), + child: submitting + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F))) + : const Text('Tambah'), + ), + ), + ], + ), + ], + ); + } + ); + }, + ); + if (result != null) { + final manualProduct = Product.manual( + result['name'] as String, + (result['price'] as double), + ); + for (int i = 0; i < (result['qty'] as int); i++) { + state.addToCart(manualProduct); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Pesanan manual ditambahkan: ${result['name']}'), + backgroundColor: kSuccess, + ), + ); + } + } + }, + icon: const Icon(Icons.edit_note_rounded), + label: const Text('Tambah Manual'), + ), + ), + ), if (state.resellerEnabled) Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), child: Row( @@ -182,10 +301,10 @@ class _KasirScreenState extends State { showDialog( context: sheetContext, builder: (dialogContext) { + final isPayingRef = [false]; + DateTime? payingStart; return StatefulBuilder( builder: (context, setDialog) { - final isPayingRef = [false]; - DateTime? payingStart; final messenger = ScaffoldMessenger.of(context); void updatePaid(String v) { @@ -233,7 +352,7 @@ class _KasirScreenState extends State { Navigator.pop(sheetContext); if (mounted) { HapticFeedback.mediumImpact(); - _showReceipt(dialogContext, sale); + _showReceipt(dialogContext, sale, isBayarNanti: isBayarNanti); } } catch (e) { if (!dialogContext.mounted) return; @@ -662,157 +781,162 @@ class _KasirScreenState extends State { ); } - void _showReceipt(BuildContext context, Sale sale) { + void _showReceipt(BuildContext context, Sale sale, {bool isBayarNanti = false}) { showDialog( context: context, - builder: (context) { - return AlertDialog( - contentPadding: const EdgeInsets.all(20), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircleAvatar( - radius: 26, - backgroundColor: kSuccess, - child: Icon(Icons.check, color: Colors.white, size: 30), - ), - const SizedBox(height: 10), - const Text('Pembayaran Berhasil', - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), - const SizedBox(height: 2), - Text( - '${formatTanggal(sale.date)} ${formatWaktu(sale.date)}', - style: const TextStyle(color: kMuted, fontSize: 12), - ), - const SizedBox(height: 4), - Text('#${sale.id.toUpperCase()}', - style: const TextStyle(color: kMuted, fontSize: 11)), - const Divider(height: 24), - ...sale.items.map((it) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text('${it.name} x${it.qty}', - style: const TextStyle(fontSize: 14)), - ), - Text(formatRupiah(it.subtotal), - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w600)), - ], - ), - const SizedBox(height: 2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: it.isReseller - ? kAccent.withValues(alpha: 0.15) - : kMuted.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - it.isReseller ? 'Reseller' : 'Umum', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: it.isReseller ? kAccent : kMuted, - ), - ), - ), - ], - ), - )), - const SizedBox(height: 4), - Text( - 'Kasir: ${context.read().getProfileName(sale.userId)}', - style: const TextStyle(color: kMuted, fontSize: 12), - ), - if (sale.discount > 0) - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Diskon', - style: TextStyle(color: kMuted)), - Text('-${formatRupiah(sale.discount)}', - style: const TextStyle(color: kMuted)), - ], + builder: (ctx) { + final state = ctx.read(); + bool printing = false; + return StatefulBuilder( + builder: (context, setReceipt) { + return AlertDialog( + contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + icon: Icon(isBayarNanti ? Icons.receipt_long : Icons.check_circle, color: kSuccess, size: 28), + title: Text(isBayarNanti ? 'Pesanan Berhasil' : 'Pembayaran Berhasil'), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${formatTanggal(sale.date)} ${formatWaktu(sale.date)} • #${sale.id.toUpperCase()}', + style: const TextStyle(color: kMuted, fontSize: 12), ), - ), - const Divider(height: 24), - _ReceiptRow('Total', formatRupiah(sale.total), true), - _ReceiptRow('Metode', _paymentMethodLabel(sale.paymentMethod), false), - if (sale.paymentStatus == 'unpaid') ...[ - _ReceiptRow('Status', 'Belum Lunas', false), - _ReceiptRow('Sisa', formatRupiah(sale.dueAmount), false), - ] else if (sale.paymentMethod == 'tunai') ...[ - _ReceiptRow('Bayar', formatRupiah(sale.paid), false), - _ReceiptRow('Kembali', formatRupiah(sale.change), false), - ], - if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty) - ...sale.splitPayment!.entries.map((e) { - final label = e.key == 'tunai' - ? 'Tunai' - : e.key == 'transfer' - ? 'Bank' - : 'QRIS'; - return _ReceiptRow('Split $label', formatRupiah(e.value), false); - }), - const SizedBox(height: 16), - const Text('Terima kasih!', - style: TextStyle(color: kMuted)), - ], - ), - ), - actions: [ - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () => Navigator.pop(context), - child: const Text('Tutup'), - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () async { - HapticFeedback.lightImpact(); - final messenger = ScaffoldMessenger.of(context); - final prefs = await SharedPreferences.getInstance(); - final storeName = - prefs.getString('store_name') ?? 'Toko Grosir'; - final err = await printerService.printSale( - sale, - storeName, - kasirName: context.read().getProfileName(sale.userId), - whatsapp: prefs.getString('store_whatsapp'), - notes: prefs.getString('store_notes'), - ); - if (mounted) { - messenger.showSnackBar( - SnackBar( - content: Text(err == null - ? 'Struk dikirim ke printer' - : 'Gagal cetak: $err'), - backgroundColor: err == null ? kSuccess : kDanger, + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...sale.items.map((it) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Expanded( + child: Text( + it.productId.startsWith('manual-') + ? '[MANUAL] ${it.name} x${it.qty}' + : '${it.name} x${it.qty}', + style: TextStyle( + fontSize: 13, + color: it.productId.startsWith('manual-') ? kAccent : null, + ), + ), + ), + Text(formatRupiah(it.subtotal), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600)), + ], + ), + )), + const SizedBox(height: 8), + Text( + 'Kasir: ${state.getProfileName(sale.userId)}', + style: const TextStyle(color: kMuted, fontSize: 12), + ), + if (sale.discount > 0) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Diskon', + style: TextStyle(color: kMuted)), + Text('-${formatRupiah(sale.discount)}', + style: const TextStyle(color: kMuted)), + ], + ), + ), + const Divider(height: 20), + _ReceiptRow('Total', formatRupiah(sale.total), true), + _ReceiptRow( + 'Metode', _paymentMethodLabel(sale.paymentMethod), false), + if (sale.paymentStatus == 'unpaid') ...[ + _ReceiptRow('Status', 'Belum Lunas', false), + _ReceiptRow('Sisa', formatRupiah(sale.dueAmount), false), + ] else if (sale.paymentMethod == 'tunai') ...[ + _ReceiptRow('Bayar', formatRupiah(sale.paid), false), + _ReceiptRow('Kembali', formatRupiah(sale.change), false), + ], + if (sale.splitPayment != null && + sale.splitPayment!.isNotEmpty) + ...sale.splitPayment!.entries.map((e) { + final label = e.key == 'tunai' + ? 'Tunai' + : e.key == 'transfer' + ? 'Bank' + : 'QRIS'; + return _ReceiptRow( + 'Split $label', formatRupiah(e.value), false); + }), + const SizedBox(height: 12), + const Text('Terima kasih!', + style: TextStyle(color: kMuted)), + ], + ), ), - ); - } - }, - icon: const Icon(Icons.print_outlined), - label: const Text('Cetak Struk'), + ), + ], + ), ), - ), - ], + actions: [ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: printing + ? null + : () async { + setReceipt(() => printing = true); + HapticFeedback.lightImpact(); + final messenger = ScaffoldMessenger.of(context); + final prefs = await SharedPreferences.getInstance(); + final storeName = + prefs.getString('store_name') ?? 'Toko Grosir'; + final err = await printerService.printSale( + sale, + storeName, + kasirName: state.getProfileName(sale.userId), + whatsapp: prefs.getString('store_whatsapp'), + notes: prefs.getString('store_notes'), + ); + if (!context.mounted) return; + setReceipt(() => printing = false); + messenger.showSnackBar( + SnackBar( + content: Text(err == null + ? 'Struk dikirim ke printer' + : 'Gagal cetak: $err'), + backgroundColor: err == null ? kSuccess : kDanger, + ), + ); + }, + icon: printing + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF06210F), + ), + ) + : const Icon(Icons.print_outlined, size: 18), + label: Text(printing ? 'Mencetak...' : 'Cetak Struk'), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Tutup'), + ), + ), + ], + ); + } ); }, ); @@ -1032,11 +1156,36 @@ class _CartSheetContent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(line.product.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w600)), + Row( + children: [ + Expanded( + child: Text(line.product.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + ), + if (line.product.id.startsWith('manual-')) + Container( + margin: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: kAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: kAccent.withValues(alpha: 0.4)), + ), + child: Text( + 'MANUAL', + style: TextStyle( + color: kAccent, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + ), + ), + ), + ], + ), const SizedBox(height: 2), Text( '${formatRupiah(line.price)} /pcs', diff --git a/lib/screens/pengaturan_screen.dart b/lib/screens/pengaturan_screen.dart index c525d4f..64b7229 100644 --- a/lib/screens/pengaturan_screen.dart +++ b/lib/screens/pengaturan_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'dart:async'; import '../state/app_state.dart'; import '../utils/colors.dart'; @@ -21,23 +24,61 @@ class _PengaturanScreenState extends State { final TextEditingController _notesCtrl = TextEditingController(); bool _resellerEnabled = true; bool _loading = true; + Timer? _settingsSyncTimer; + bool _loadingSettings = false; @override void initState() { super.initState(); _loadSettings(); + _settingsSyncTimer = Timer.periodic(const Duration(seconds: 30), (_) { + if (!_loadingSettings && mounted) { + _loadSettings(); + } + }); } + Future _loadSettings() async { - final prefs = await SharedPreferences.getInstance(); - setState(() { - _storeNameCtrl.text = prefs.getString('store_name') ?? 'Toko Grosir'; - _currencyCtrl.text = prefs.getString('currency') ?? 'IDR'; - _whatsappCtrl.text = prefs.getString('store_whatsapp') ?? ''; - _notesCtrl.text = prefs.getString('store_notes') ?? ''; - _resellerEnabled = prefs.getBool('reseller_enabled') ?? true; - _loading = false; - }); + if (_loadingSettings) return; + _loadingSettings = true; + try { + final prefs = await SharedPreferences.getInstance(); + String storeName = prefs.getString('store_name') ?? 'Toko Grosir'; + String currency = prefs.getString('currency') ?? 'IDR'; + String whatsapp = prefs.getString('store_whatsapp') ?? ''; + String notes = prefs.getString('store_notes') ?? ''; + bool resellerEnabled = prefs.getBool('reseller_enabled') ?? true; + + try { + final data = await Supabase.instance.client + .from('store_settings') + .select() + .eq('store_id', '00000000-0000-0000-0000-000000000000') + .maybeSingle(); + if (data != null) { + storeName = (data['store_name'] as String?) ?? storeName; + currency = (data['currency'] as String?) ?? currency; + whatsapp = data['whatsapp'] as String? ?? whatsapp; + notes = data['notes'] as String? ?? notes; + resellerEnabled = data['reseller_enabled'] as bool? ?? resellerEnabled; + } + } catch (e) { + debugPrint('Gagal load store settings dari Supabase: $e'); + } + + if (!mounted) return; + setState(() { + _storeNameCtrl.text = storeName; + _currencyCtrl.text = currency; + _whatsappCtrl.text = whatsapp; + _notesCtrl.text = notes; + _resellerEnabled = resellerEnabled; + _loading = false; + }); + } finally { + _loadingSettings = false; + } } Future _saveSettings() async { @@ -50,6 +91,21 @@ class _PengaturanScreenState extends State { if (!mounted) return; final state = context.read(); state.setResellerEnabled(_resellerEnabled); + + try { + await Supabase.instance.client.from('store_settings').upsert({ + 'id': 'store', + 'store_id': '00000000-0000-0000-0000-000000000000', + 'store_name': _storeNameCtrl.text.trim(), + 'currency': _currencyCtrl.text.trim(), + 'whatsapp': _whatsappCtrl.text.trim(), + 'notes': _notesCtrl.text.trim(), + 'reseller_enabled': _resellerEnabled, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }); + } catch (e) { + debugPrint('Gagal simpan store settings ke Supabase: $e'); + } } @override @@ -58,38 +114,49 @@ class _PengaturanScreenState extends State { return const Center(child: CircularProgressIndicator(color: kAccent)); } + final state = context.watch(); + final isAdmin = state.isAdmin; + return RefreshIndicator( onRefresh: () async { context.read().recordManualRefresh(); await context.read().refresh(); + _settingsSyncTimer?.cancel(); + _settingsSyncTimer = Timer.periodic(const Duration(seconds: 30), (_) { + if (!_loadingSettings && mounted) { + _loadSettings(); + } + }); }, color: kAccent, child: ListView( padding: const EdgeInsets.all(12), children: [ - _SettingsListTile( - icon: Icons.store_outlined, - title: 'Pengaturan Toko', - subtitle: _whatsappCtrl.text.isEmpty - ? '${_storeNameCtrl.text} • ${_currencyCtrl.text}' - : '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}', - onTap: () => _showStoreSettingsDialog(context), - ), - const SizedBox(height: 8), - _SettingsListTile( - icon: Icons.people_outline, - title: 'Akun Kasir', - subtitle: 'Tambah, edit, atau hapus akun kasir', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const KelolaKasirScreen(), - ), - ); - }, - ), - const SizedBox(height: 8), + if (isAdmin) ...[ + _SettingsListTile( + icon: Icons.receipt_long_outlined, + title: 'Pengaturan Struk', + subtitle: _whatsappCtrl.text.isEmpty + ? '${_storeNameCtrl.text} • ${_currencyCtrl.text}' + : '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}', + onTap: () => _showStoreSettingsDialog(context), + ), + const SizedBox(height: 8), + _SettingsListTile( + icon: Icons.people_outline, + title: 'Akun Kasir', + subtitle: 'Tambah, edit, atau hapus akun kasir', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const KelolaKasirScreen(), + ), + ); + }, + ), + const SizedBox(height: 8), + ], _SettingsListTile( icon: Icons.print_outlined, title: 'Printer Bluetooth', @@ -103,6 +170,35 @@ class _PengaturanScreenState extends State { ); }, ), + const SizedBox(height: 8), + if (!isAdmin) + _SettingsListTile( + icon: Icons.receipt_long_outlined, + title: 'Info Struk', + subtitle: _whatsappCtrl.text.isEmpty + ? '${_storeNameCtrl.text} • ${_currencyCtrl.text}' + : '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}', + onTap: null, + ), + const SizedBox(height: 24), + Center( + child: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + if (snapshot.hasData) { + final info = snapshot.data!; + return Text( + 'Versi ${info.version} (${info.buildNumber})', + style: const TextStyle(color: kMuted, fontSize: 12), + ); + } + return const Text( + 'Versi ...', + style: TextStyle(color: kMuted, fontSize: 12), + ); + }, + ), + ), ], ), ); @@ -180,20 +276,27 @@ class _PengaturanScreenState extends State { onPressed: () => Navigator.pop(ctx, false), child: const Text('Batal'), ), - FilledButton.icon( - onPressed: () async { - _storeNameCtrl.text = storeNameCtrl.text.trim(); - _currencyCtrl.text = currencyCtrl.text.trim(); - _whatsappCtrl.text = whatsappCtrl.text.trim(); - _notesCtrl.text = notesCtrl.text.trim(); - _resellerEnabled = resellerEnabled; - await _saveSettings(); - if (ctx.mounted) { - Navigator.pop(ctx, true); - } - }, - icon: const Icon(Icons.save_outlined, size: 18), - label: const Text('Simpan'), + const SizedBox(width: 8), + SizedBox( + width: 120, + child: FilledButton.icon( + onPressed: () async { + _storeNameCtrl.text = storeNameCtrl.text.trim(); + _currencyCtrl.text = currencyCtrl.text.trim(); + _whatsappCtrl.text = whatsappCtrl.text.trim(); + _notesCtrl.text = notesCtrl.text.trim(); + _resellerEnabled = resellerEnabled; + await _saveSettings(); + if (ctx.mounted) { + Navigator.pop(ctx, true); + } + }, + icon: const Icon(Icons.save_outlined, size: 18), + label: const Text('Simpan'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), ), ], ), @@ -212,6 +315,7 @@ class _PengaturanScreenState extends State { @override void dispose() { + _settingsSyncTimer?.cancel(); _storeNameCtrl.dispose(); _currencyCtrl.dispose(); _whatsappCtrl.dispose(); @@ -224,12 +328,12 @@ class _SettingsListTile extends StatelessWidget { final IconData icon; final String title; final String subtitle; - final VoidCallback onTap; + final VoidCallback? onTap; const _SettingsListTile({ required this.icon, required this.title, required this.subtitle, - required this.onTap, + this.onTap, }); @override diff --git a/lib/screens/riwayat_screen.dart b/lib/screens/riwayat_screen.dart index 98f2377..f99a9ee 100644 --- a/lib/screens/riwayat_screen.dart +++ b/lib/screens/riwayat_screen.dart @@ -512,66 +512,71 @@ class _RiwayatScreenState extends State { BuildContext parentContext, BuildContext sheetContext, Sale sale, ScrollController controller) { final state = parentContext.read(); final isAdmin = state.isAdmin; - final isCancelled = sale.isCancelled; + final isKasir = state.isKasir; - return Container( - decoration: const BoxDecoration( - color: kSurface, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - border: Border(top: BorderSide(color: kBorder)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - margin: const EdgeInsets.only(top: 12), - width: 40, - height: 4, - decoration: BoxDecoration( - color: kBorder, - borderRadius: BorderRadius.circular(4), - ), + return StatefulBuilder( + builder: (context, setSheet) { + Sale currentSale = sale; + final isCancelled = currentSale.isCancelled; + + return Container( + decoration: const BoxDecoration( + color: kSurface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + border: Border(top: BorderSide(color: kBorder)), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('#${sale.id.toUpperCase()}', - style: const TextStyle(fontSize: 10, color: kMuted), - overflow: TextOverflow.ellipsis), - Text( - 'Kasir: ${state.getProfileName(sale.userId)}', - style: const TextStyle(color: kMuted, fontSize: 11), - ), - ], - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: kBorder, + borderRadius: BorderRadius.circular(4), ), - if (isCancelled) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: kDanger.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('#${currentSale.id.toUpperCase()}', + style: const TextStyle(fontSize: 10, color: kMuted), + overflow: TextOverflow.ellipsis), + Text( + 'Kasir: ${state.getProfileName(currentSale.userId)}', + style: const TextStyle(color: kMuted, fontSize: 11), + ), + ], + ), ), - child: const Text( - 'DIBATALKAN', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - color: kDanger), - ), - ), - ], - ), - ), + if (isCancelled) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: kDanger.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'DIBATALKAN', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: kDanger), + ), + ), + ], + ), + ), const SizedBox(height: 2), Text( - '${formatTanggal(sale.date)} ${formatWaktu(sale.date)}', + '${formatTanggal(currentSale.date)} ${formatWaktu(currentSale.date)}', style: const TextStyle(color: kMuted, fontSize: 12), ), const Divider(height: 24), @@ -582,14 +587,21 @@ class _RiwayatScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...sale.items.map((it) => Padding( + ...currentSale.items.map((it) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text('${it.name} x${it.qty}', - style: const TextStyle(fontSize: 14)), + child: Text( + it.productId.startsWith('manual-') + ? '[MANUAL] ${it.name} x${it.qty}' + : '${it.name} x${it.qty}', + style: TextStyle( + fontSize: 14, + color: it.productId.startsWith('manual-') ? kAccent : null, + ), + ), ), Text(formatRupiah(it.subtotal), style: const TextStyle( @@ -597,40 +609,41 @@ class _RiwayatScreenState extends State { ], ), )), - if (sale.discount > 0) - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Diskon', style: TextStyle(color: kMuted)), - Text('-${formatRupiah(sale.discount)}', - style: const TextStyle(color: kMuted)), - ], - ), - ), - const Divider(height: 24), - _receiptRow('Total', formatRupiah(sale.total), true), - _receiptRow('Bayar', formatRupiah(sale.paid), false), - if (sale.paymentStatus == 'unpaid' || sale.paymentStatus == 'partial') - _receiptRow('Sisa', formatRupiah(sale.dueAmount), false), - _receiptRow('Kembali', formatRupiah(sale.change), false), - _receiptRow('Metode', _paymentMethodLabel(sale.paymentMethod), false), - _receiptRow('Status', sale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false), - if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty) - ...sale.splitPayment!.entries.map((e) { - final label = e.key == 'tunai' - ? 'Tunai' - : e.key == 'transfer' - ? 'Bank' - : 'QRIS'; - return _receiptRow('Split $label', formatRupiah(e.value), false); - }), - if (sale.totalCost > 0) ...[ - const Divider(height: 24), - _receiptRow('Harga Pokok', formatRupiah(sale.totalCost), false), - _receiptRow('Laba Kotor', formatRupiah(sale.total - sale.totalCost), true), - ], + if (currentSale.discount > 0) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Diskon', style: TextStyle(color: kMuted)), + Text('-${formatRupiah(currentSale.discount)}', + style: const TextStyle(color: kMuted)), + ], + ), + ), + const Divider(height: 24), + _receiptRow('Total', formatRupiah(currentSale.total), true), + _receiptRow('Bayar', formatRupiah(currentSale.paid), false), + if (currentSale.paymentStatus == 'unpaid' || currentSale.paymentStatus == 'partial') + _receiptRow('Sisa', formatRupiah(currentSale.dueAmount), false), + if (currentSale.paymentStatus == 'paid') + _receiptRow('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false), + _receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false), + _receiptRow('Status', currentSale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false), + if (currentSale.splitPayment != null && currentSale.splitPayment!.isNotEmpty) + ...currentSale.splitPayment!.entries.map((e) { + final label = e.key == 'tunai' + ? 'Tunai' + : e.key == 'transfer' + ? 'Bank' + : 'QRIS'; + return _receiptRow('Split $label', formatRupiah(e.value), false); + }), + if (!state.isKasir && currentSale.totalCost > 0) ...[ + const Divider(height: 24), + _receiptRow('Harga Pokok', formatRupiah(currentSale.totalCost), false), + _receiptRow('Laba Kotor', formatRupiah(currentSale.total - currentSale.totalCost), true), + ], const SizedBox(height: 16), SizedBox( width: double.infinity, @@ -644,9 +657,9 @@ class _RiwayatScreenState extends State { prefs.getString('store_name') ?? 'Toko Grosir'; final err = await printerService.printSale( - sale, + currentSale, storeName, - kasirName: state.getProfileName(sale.userId), + kasirName: state.getProfileName(currentSale.userId), whatsapp: prefs.getString('store_whatsapp'), notes: prefs.getString('store_notes'), ); @@ -665,7 +678,7 @@ class _RiwayatScreenState extends State { label: const Text('Cetak Struk'), ), ), - if (isAdmin && !isCancelled && sale.paymentStatus != 'paid') ...[ + if ((isAdmin || isKasir) && !isCancelled && currentSale.paymentStatus != 'paid') ...[ const SizedBox(height: 4), SizedBox( width: double.infinity, @@ -674,60 +687,168 @@ class _RiwayatScreenState extends State { final payCtrl = TextEditingController(); final result = await showDialog( context: parentContext, - builder: (ctx) => AlertDialog( - title: Text(sale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Total: ${formatRupiah(sale.total)}'), - Text('Sudah bayar: ${formatRupiah(sale.paid)}'), - Text('Sisa: ${formatRupiah(sale.dueAmount)}'), - const SizedBox(height: 12), - TextField( - controller: payCtrl, - keyboardType: TextInputType.number, - inputFormatters: [RupiahInputFormatter()], - decoration: const InputDecoration( - labelText: 'Nominal Pembayaran', - prefixText: 'Rp ', + builder: (ctx) { + bool submitting = false; + return StatefulBuilder( + builder: (context, setDialog) { + final isUnpaid = currentSale.paymentStatus == 'unpaid'; + final remain = currentSale.dueAmount - currentSale.paid; + final remainColor = remain <= 0 ? kSuccess : kDanger; + return AlertDialog( + title: Text(isUnpaid ? 'Terima Pembayaran' : 'Bayar Sisa'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: kSurface2, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Total', style: TextStyle(color: kMuted, fontSize: 12)), + Text(formatRupiah(currentSale.total), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Sudah Bayar', style: TextStyle(color: kMuted, fontSize: 12)), + Text(formatRupiah(currentSale.paid), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: remainColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(isUnpaid ? 'Sisa Tagihan' : 'Sisa', + style: TextStyle(color: remainColor, fontSize: 13, fontWeight: FontWeight.w600)), + Text(formatRupiah(remain > 0 ? remain : 0), + style: TextStyle(color: remainColor, fontSize: 16, fontWeight: FontWeight.w800)), + ], + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isUnpaid ? kMuted.withValues(alpha: 0.12) : kAccent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text(isUnpaid ? 'Transaksi belum lunas' : 'Pembayaran sebagian', + style: TextStyle(color: isUnpaid ? kMuted : kAccent, fontSize: 12, fontWeight: FontWeight.w600)), + ), + const SizedBox(height: 14), + TextField( + controller: payCtrl, + keyboardType: TextInputType.number, + inputFormatters: [RupiahInputFormatter()], + decoration: InputDecoration( + labelText: 'Nominal Pembayaran', + prefixText: 'Rp ', + helperText: isUnpaid ? 'Masukkan nominal sesuai yang dibayar customer' : 'Masukkan jumlah pelunasan', + helperStyle: TextStyle(color: kMuted, fontSize: 12), + ), + ), + ], + ), ), - ), - ], - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Batal')), - FilledButton( - onPressed: () { - final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble(); - if (val <= 0) return; - Navigator.pop(ctx, val); - }, - style: FilledButton.styleFrom(backgroundColor: kSuccess), - child: const Text('Simpan'), - ), - ], - ), - ); - if (result != null && result > 0) { - final err = await state.recordPayment(sale.id, result); - if (mounted) { - final messenger = ScaffoldMessenger.of(parentContext); - messenger.showSnackBar( - SnackBar( - content: Text(err == null ? 'Pembayaran berhasil dicatat' : 'Gagal: $err'), - backgroundColor: err == null ? kSuccess : kDanger, - ), + actions: [ + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: submitting ? null : () => Navigator.pop(ctx), + style: OutlinedButton.styleFrom( + foregroundColor: kMuted, + side: const BorderSide(color: kBorder), + ), + child: const Text('Batal'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: submitting + ? null + : () { + final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble(); + if (val <= 0) return; + Navigator.pop(ctx, val); + }, + style: FilledButton.styleFrom(backgroundColor: kSuccess, foregroundColor: const Color(0xFF06210F)), + child: submitting + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F))) + : const Text('Simpan'), + ), + ), + ], + ), + ], + ); + } ); + }, + ); + if (result != null && result > 0) { + final err = await state.recordPayment(currentSale.id, result); + Sale? updated; + try { + updated = state.sales.firstWhere((s) => s.id == currentSale.id); + } catch (_) {} + final isNowPaid = updated?.paymentStatus == 'paid'; + final remainAfter = isNowPaid + ? 0.0 + : (updated?.dueAmount ?? (currentSale.dueAmount - result)).clamp(0, double.infinity); + + if (sheetContext.mounted) { + Navigator.pop(sheetContext); + } + if (mounted) { + final messenger = ScaffoldMessenger.of(parentContext); + final msg = err == null + ? isNowPaid + ? 'Pembayaran lunas dicatat' + : 'Pembayaran ${formatRupiah(result.toDouble())} dicatat. Sisa: ${formatRupiah(remainAfter.toDouble())}' + : 'Gagal: $err'; + messenger.showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: err == null ? kSuccess : kDanger, + ), + ); + } } - } }, - icon: const Icon(Icons.payments_outlined, color: Colors.white), - label: Text(sale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'), - style: FilledButton.styleFrom( - backgroundColor: kSuccess, - foregroundColor: const Color(0xFF06210F), - ), + icon: const Icon(Icons.payments_outlined, color: Colors.white), + label: Text(currentSale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'), + style: FilledButton.styleFrom( + backgroundColor: kSuccess, + foregroundColor: const Color(0xFF06210F), + ), ), ), ], @@ -740,40 +861,51 @@ class _RiwayatScreenState extends State { final messenger = ScaffoldMessenger.of(parentContext); final confirm = await showDialog( context: parentContext, - builder: (ctx) => AlertDialog( - icon: Icon(Icons.cancel_outlined, color: kDanger, size: 28), - title: const Text('Batalkan Transaksi?'), - content: const Text( - 'Stok akan dikembalikan secara otomatis. Tindakan ini tidak dapat dibatalkan.', - ), - actions: [ - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () => Navigator.pop(ctx, false), - style: OutlinedButton.styleFrom( - foregroundColor: kMuted, - side: const BorderSide(color: kBorder), + builder: (ctx) { + bool cancelling = false; + return StatefulBuilder( + builder: (context, setDialog) { + return AlertDialog( + icon: Icon(Icons.warning_amber_rounded, color: kDanger, size: 28), + title: const Text('Batalkan Transaksi?'), + content: const Text( + 'Stok akan dikembalikan secara otomatis. Tindakan ini tidak dapat dibatalkan.', ), - child: const Text('Batal'), - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom( - backgroundColor: kDanger, - ), - child: const Text('Batalkan'), - ), - ), - ], - ), + actions: [ + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: cancelling ? null : () => Navigator.pop(ctx, false), + style: OutlinedButton.styleFrom( + foregroundColor: kMuted, + side: const BorderSide(color: kBorder), + ), + child: const Text('Batal'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: cancelling + ? null + : () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: kDanger), + child: cancelling + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Batalkan'), + ), + ), + ], + ), + ], + ); + } + ); + }, ); - if (confirm == true && mounted) { - final err = await state.cancelSale(sale.id); + if (confirm == true && mounted) { + final err = await state.cancelSale(currentSale.id); if (mounted) { Navigator.pop(sheetContext); messenger.showSnackBar( @@ -802,6 +934,8 @@ class _RiwayatScreenState extends State { ), ], ), + ); + }, ); } diff --git a/lib/services/offline_queue_service.dart b/lib/services/offline_queue_service.dart index 51e3323..1ed2b3a 100644 --- a/lib/services/offline_queue_service.dart +++ b/lib/services/offline_queue_service.dart @@ -97,11 +97,13 @@ class OfflineQueueService { final items = (transaction['items'] as List?) ?? []; final saleId = sale['id'] as String; + final storeId = sale['store_id'] as String?; final existing = await _withTimeout( _supabase .from('sales') .select('id, total_cost') .eq('id', saleId) + .eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000') .maybeSingle(), 'Check existing sale', ); @@ -118,7 +120,8 @@ class OfflineQueueService { _supabase .from('sales') .update({'total_cost': sale['total_cost']}) - .eq('id', saleId), + .eq('id', saleId) + .eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000'), 'Update sale total_cost', ); } @@ -132,6 +135,7 @@ class OfflineQueueService { .from('products') .select() .eq('id', productId) + .eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000') .maybeSingle(), 'Load product for stock update', ); @@ -142,7 +146,8 @@ class OfflineQueueService { _supabase .from('products') .update({'stock': newStock}) - .eq('id', productId), + .eq('id', productId) + .eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000'), 'Update product stock', ); } diff --git a/lib/services/printer_service.dart b/lib/services/printer_service.dart index 93719a4..1d1bd89 100644 --- a/lib/services/printer_service.dart +++ b/lib/services/printer_service.dart @@ -177,7 +177,6 @@ class PrinterService { ])); } } - bytes.addAll(generator.hr()); if (kasirName != null && kasirName.isNotEmpty) { bytes.addAll(generator.text('Kasir: $kasirName', styles: const PosStyles(align: PosAlign.center))); @@ -195,12 +194,10 @@ class PrinterService { styles: const PosStyles(align: PosAlign.center))); } } - bytes.addAll(generator.hr()); bytes.addAll(generator.text( 'Terima kasih!', styles: const PosStyles(align: PosAlign.center), )); - bytes.addAll(generator.feed(2)); bytes.addAll(generator.cut()); return bytes; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index fe43c98..1a59251 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -33,6 +33,8 @@ class AppState extends ChangeNotifier { static const Duration _timeout = Duration(seconds: 30); + static const String _currentStoreId = '00000000-0000-0000-0000-000000000000'; + Future _withTimeout(Future future, [String? operation]) async { try { return await future.timeout( @@ -451,7 +453,7 @@ class AppState extends ChangeNotifier { Future _loadProducts() async { final rows = await _withTimeout( - _supabase.from('products').select().order('name'), + _supabase.from('products').select().eq('store_id', _currentStoreId).order('name'), 'Load products', ); products = rows.map((e) => Product.fromJson(e)).toList(); @@ -463,6 +465,7 @@ class AppState extends ChangeNotifier { _supabase .from('sales') .select() + .eq('store_id', _currentStoreId) .order('created_at', ascending: false), 'Load sales', ); @@ -480,7 +483,7 @@ class AppState extends ChangeNotifier { Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25), ]; await _withTimeout( - _supabase.from('products').insert(seeds.map((e) => e.toJson()).toList()), + _supabase.from('products').insert(seeds.map((e) => e.toJson()..['store_id'] = _currentStoreId).toList()), 'Seed products', ); products = seeds; @@ -517,7 +520,7 @@ class AppState extends ChangeNotifier { hargaPokok: hargaPokok, ); await _withTimeout( - _supabase.from('products').insert(product.toJson()), + _supabase.from('products').insert(product.toJson()..['store_id'] = _currentStoreId), 'Add product', ); } catch (e) { @@ -547,6 +550,7 @@ class AppState extends ChangeNotifier { 'stock': stock, 'min_stock': minStock, 'harga_pokok': hargaPokok, + 'store_id': _currentStoreId, }).eq('id', id), 'Update product', ); @@ -564,7 +568,7 @@ class AppState extends ChangeNotifier { try { cart.removeWhere((c) => c.product.id == id); await _withTimeout( - _supabase.from('products').delete().eq('id', id), + _supabase.from('products').delete().eq('id', id).eq('store_id', _currentStoreId), 'Delete product', ); } catch (e) { @@ -641,7 +645,7 @@ class AppState extends ChangeNotifier { discount: _discount, total: total, paid: paid, - change: paid - total, + change: paymentStatus == 'paid' ? (paid - total).clamp(0, double.infinity) : 0, totalCost: totalCost, paymentMethod: paymentMethod, paymentStatus: paymentStatus, @@ -652,11 +656,12 @@ class AppState extends ChangeNotifier { try { await _withTimeout( - _supabase.from('sales').insert(sale.toJson()), + _supabase.from('sales').insert(sale.toJson()..['store_id'] = _currentStoreId), 'Create sale', ); for (final line in cart) { + if (line.product.id.startsWith('manual-')) continue; try { final current = products.firstWhere((p) => p.id == line.product.id); @@ -664,7 +669,8 @@ class AppState extends ChangeNotifier { _supabase .from('products') .update({'stock': current.stock - line.qty}) - .eq('id', line.product.id), + .eq('id', line.product.id) + .eq('store_id', _currentStoreId), 'Update stock', ); } catch (_) {} @@ -703,7 +709,7 @@ class AppState extends ChangeNotifier { final sale = sales.firstWhere((s) => s.id == saleId); await _withTimeout( - _supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId), + _supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId).eq('store_id', _currentStoreId), 'Cancel sale', ); @@ -717,7 +723,8 @@ class AppState extends ChangeNotifier { _supabase .from('products') .update({'stock': product.stock + item.qty}) - .eq('id', product.id), + .eq('id', product.id) + .eq('store_id', _currentStoreId), 'Restore stock', ); } @@ -745,7 +752,8 @@ class AppState extends ChangeNotifier { 'paid': newPaid, 'due_amount': newDue, 'payment_status': newStatus, - }).eq('id', saleId), + 'change': (newPaid - sale.total).clamp(0, double.infinity), + }).eq('id', saleId).eq('store_id', _currentStoreId), 'Record payment', ); await _loadSales(); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0da94d3..161825b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import app_links import connectivity_plus import mobile_scanner +import package_info_plus import path_provider_foundation import print_bluetooth_thermal import share_plus @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PrintBluetoothThermalPlugin.register(with: registry.registrar(forPlugin: "PrintBluetoothThermalPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 839ae49..0d5ff64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -440,6 +440,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" passkeys_platform_interface: dependency: transitive description: @@ -838,7 +854,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" diff --git a/pubspec.yaml b/pubspec.yaml index acd2fdd..18c0fc8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+2 +version: 1.1.0+4 environment: sdk: ^3.9.2 @@ -45,6 +45,8 @@ dependencies: share_plus: ^8.0.0 print_bluetooth_thermal: 1.2.1 esc_pos_utils_plus: ^2.0.4 + uuid: ^4.0.0 + package_info_plus: ^8.0.0 dev_dependencies: flutter_test: diff --git a/supabase/migrations/20240104_add_store_settings.sql b/supabase/migrations/20240104_add_store_settings.sql new file mode 100644 index 0000000..41c9ed0 --- /dev/null +++ b/supabase/migrations/20240104_add_store_settings.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS store_settings ( + id TEXT PRIMARY KEY DEFAULT 'store', + store_name TEXT NOT NULL DEFAULT 'Toko Grosir', + currency TEXT NOT NULL DEFAULT 'IDR', + whatsapp TEXT, + notes TEXT, + reseller_enabled BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT +); + +ALTER TABLE store_settings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Allow public read store_settings" ON store_settings FOR SELECT USING (true); +CREATE POLICY "Allow authenticated upsert store_settings" ON store_settings FOR INSERT WITH CHECK (auth.role() = 'authenticated'); +CREATE POLICY "Allow authenticated update store_settings" ON store_settings FOR UPDATE USING (auth.role() = 'authenticated'); + +INSERT INTO store_settings (id, store_name, currency, reseller_enabled) VALUES ('store', 'Toko Grosir', 'IDR', true) ON CONFLICT (id) DO NOTHING;