POS Grosir Dutra Store: update kasir/produk/riwayat/pengaturan + offline queue

This commit is contained in:
onie
2026-10-17 06:38:50 +07:00
parent bec02d196c
commit 0121eca8bb
15 changed files with 1202 additions and 524 deletions

View File

@@ -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:<project>/` 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<AppState>()` untuk rebuild saat state berubah.
- Gunakan `context.read<AppState>()` 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>(<scope>): <description>`
- 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.

View File

@@ -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. - Radius default: 18 untuk kartu, 12 untuk input/dialog, 8 untuk chip/tag.
- Font: gunakan `Theme.of(context).textTheme` agar sesuai pengaturan sistem. - 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. - `kAccent` — amber untuk aksen dan highlight.
- `kSuccess` — hijau untuk status lunas/berhasil. - `kSuccess` — hijau untuk status lunas/berhasil.
- `kDanger` — merah untuk pembatalan/error/stok habis. - `kDanger` — merah untuk pembatalan/error/stok habis.
- `kWarning` — amber/oranye untuk peringatan stok rendah, bayar nanti.
- `kMuted` — abu-abu untuk teks sekunder/timestamp. - `kMuted` — abu-abu untuk teks sekunder/timestamp.
- `kSurface2` — background chip yang tidak aktif. - `kSurface2` — background chip yang tidak aktif.
- `kBorder` — border untuk chip dan outline. - `kBorder` — border untuk chip dan outline.
- `kText` — warna teks utama (default di theme gelap).
### 2.3 Spacing & Layout ### 2.3 Typography Scale
- Gunakan `EdgeInsets` tetap untuk konsistensi: Gunakan scale berikut, jangan hardcode font size kecuali specified:
- `EdgeInsets.all(12)` padding screen/list. - **Display / H1**: 28px, bold, untuk heading besar
- `EdgeInsets.all(16)` padding form/dialog. - **H2**: 22px, bold, untuk section title
- `EdgeInsets.symmetric(horizontal: 14, vertical: 10)` untuk baris list. - **H3**: 18px, semi-bold, untuk card title / dialog title
- Gap antar field/form: `SizedBox(height: 10)`. - **Body large**: 16px, regular, untuk teks isi utama
- Gap antar section: `SizedBox(height: 14)`. - **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 angka harga/uang **wajib** menggunakan `formatRupiah()`.
- 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`.
### 2.5 Input & Form ### 2.4 Spacing Scale
- Label jelas, helper text opsional untuk field opsional. Pakai multiples of 4 untuk spacing:
- Jangan taruh form di dalam `Column` tanpa `SingleChildScrollView` jika total tinggi form bisa melebihi layar. - 4, 8, 12, 16, 20, 24, 32, 48
- Saat keyboard muncul, pastikan field terlihat: - Padding screen/list: `EdgeInsets.all(12)`
- Bungkus form dengan `SingleChildScrollView` + `padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom)`. - 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 ### 2.5 Elevation & Surface
- Primary action (Bayar, Tambah, Simpan): `FilledButton` berwarna aksen. - Card: `Card` widget dengan default elevation, atau `Container` dengan `color: kSurface2` jika butuh kontrol penuh.
- Destructive action (Hapus, Batalkan): `FilledButton` / `OutlinedButton` berwarna merah (`kDanger`). - Active/pressed state: gunakan `InkWell` / `InkResponse` dengan splash color aksen transparan.
- Secondary action (Batal, Tutup): `TextButton` atau `OutlinedButton`. - Bottom sheet: `color: kSurface`, border top `kBorder`, radius top 24.
- Setiap dialog harus punya urutan aksi: **Konfirmasi** di kanan/bawah, **Batal** di kiri/atas. - 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 signifikan yang cepat: tambah item ke keranjang, print struk, scan barcode → `HapticFeedback.lightImpact()`.
- Aksi destruktif atau butuh konfirmasi: hapus item/batal transaksi → `HapticFeedback.mediumImpact()`. - 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. - 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. - 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. - 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`. - Gunakan `MediaQuery.of(context).viewInsets.bottom` untuk padding bawah dinamis pada `SingleChildScrollView`.
- Untuk bottom sheet form yang tinggi dan perlu auto-expand: - 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`. 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. - Jangan andal `Scaffold.resizeToAvoidBottomInset` saja untuk bottom sheet form kompleks; manual padding lebih stabil untuk kasus form panjang.
### 2.8 Empty State ### 2.21 Responsif
- Jangan tampilkan teks kosong tanpa visual indicator. - Mobile (< 600dp): 2 kolom grid, tombol stacked vertikal di dialog.
- Minimal: icon + teks. Contoh: `Icon(Icons.shopping_cart_outlined, size: 48, color: kMuted)` + teks "Keranjang kosong". - Tablet (>= 600dp): 3–4 kolom grid, tombol bisa horizontal di dialog.
- Center alignment, `Column` dengan `MainAxisSize.min`. - Gunakan `LayoutBuilder` atau `MediaQuery` untuk menentukan layout berdasarkan lebar screen.
- Konsisten pakai ikon `outlined` variant untuk empty state. - Jangan gunakan hardcoded width untuk widget utama; gunakan `Expanded`, `Flexible`, atau `MediaQuery`.
### 2.9 Dialog Button Layout ### 2.22 Aksesibilitas
- Mobile: tombol dialog harus **full-width stacked vertikal** dengan `SizedBox(height: 8)` antar tombol. - Minimal tap target: 44x44dp untuk semua tombol/icon.
- Urutan: **Batal** di atas, **Konfirmasi** di bawah. - Kontras teks minimal 4.5:1 (cek dengan tool contrast checker).
- Jangan pakai `Row` untuk 2 tombol di dialog mobile, kecuali screen width > 600dp. - Jangan andal warna saja (tambah ikon/label). Contoh: stok habis = badge merah + teks "Habis".
- Tombol konfirmasi destruktif: `FilledButton` dengan `backgroundColor: kDanger`. - Setiap aksi ada `SnackBar` feedback: sukses hijau, error merah, info biru/abu.
- Tombol konfirmasi positif: `FilledButton` dengan `backgroundColor: kAccent` atau `kSuccess`. - Gunakan `Semantics` widget untuk icon-only button agar screen reader bisa membaca.
### 2.10 Loading State ## 3. UX untuk Kasir (Critical Path)
- 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)
- Target: transaksi selesai dalam 3 tap (pilih produk → bayar → selesai). - Target: transaksi selesai dalam 3 tap (pilih produk → bayar → selesai).
- Jangan sembunyikan aksi utama (Bayar) di bawah banyak opsi. - Jangan sembunyikan aksi utama (Bayar) di bawah banyak opsi.
- Tampilkan total, diskon, dan kembalian secara **real-time**. - 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. - 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 habis, matikan visual (opacity turun, disable tap) dan tampilkan badge "Habis".
- Jika stok menipis, tampilkan badge peringatan oranye sebelum 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"**. - Saat checkout pilih **Bayar Nanti**, tombol harus jelas: **"Pesanan Sekarang"**.
- Field "Uang Dibayar" menjadi disabled dengan label **"Belum Bayar"**. - 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. - Di Riwayat, transaksi unpaid harus punya badge/status yang menonjol.
- Saat customer datang bayar: tombol **"Terima Pembayaran"** atau **"Bayar Sisa"** di detail transaksi. - Saat customer datang bayar: tombol **"Terima Pembayaran"** atau **"Bayar Sisa"** di detail transaksi.
## 6. Aksesibilitas & Feedback ## 5. Reusability & Konsistensi
- Minimal tap target: 44x44dp untuk semua tombol/icon. - Buat widget reuseable untuk komponen berulang: `_Tag`, `_ReportCard`, `_Avatar`, `_Stepper`, `_PaymentMethodChip`, `_ReceiptRow`, `_SettingsListTile`.
- 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`.
- Jangan duplikasi warna/spacing. Taruh konstanta di `lib/utils/colors.dart`. - 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). - [ ] `flutter analyze` → No issues found (tanpa error).
- [ ] Coba di layar kecil (HP 5") dan besar (tablet). - [ ] Coba di layar kecil (HP 5") dan besar (tablet).
- [ ] Coba dengan keyboard muncul (input uang/diskon). - [ ] 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. - [ ] Pastikan tidak ada hardcoded height yang bikin overflow.
- [ ] Semua teks dalam Bahasa Indonesia. - [ ] Semua teks dalam Bahasa Indonesia.
- [ ] Konsisten pakai `formatRupiah()` untuk angka mata uang. - [ ] 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/utils/colors.dart` — design tokens.
- `lib/screens/kasir_screen.dart` — contoh bottom sheet cart, grid produk, dialog pembayaran. - `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/riwayat_screen.dart` — contoh kartu laporan, filter role-based.
- `lib/screens/pengaturan_screen.dart` — contoh settings form + role-based visibility.

21
PRD.md
View File

@@ -2,6 +2,23 @@
## Changelog ## 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 ### v1.0.1 — 2026-07-22
- Perbaikan UI/UX form produk: auto-scroll keyboard pada Tambah Produk dan Edit Produk. - 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). - 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 - ✅ Dialog pembayaran: split payment support dengan total split real-time
- ✅ Receipt & detail riwayat: menampilkan metode pembayaran dan breakdown split payment - ✅ Receipt & detail riwayat: menampilkan metode pembayaran dan breakdown split payment
- ✅ Sale model: field payment_method + split_payment untuk dokumentasi transaksi - ✅ 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 ### Fitur yang Didefer / Belum
- ✅ Ganti password akun kasir via Supabase admin API (`auth.admin.updateUserById` tersedia di gotrue 2.26.0). - ✅ 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). 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). 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**. 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. - 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. - Color scheme: brand amber dominant di Kasir & Produk, hijau untuk harga umum, merah untuk error/habis, abu untuk muted.

View File

@@ -1,3 +1,5 @@
import 'package:uuid/uuid.dart';
class Product { class Product {
final String id; final String id;
final String name; final String name;
@@ -19,6 +21,17 @@ class Product {
this.hargaPokok = 0, 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<String, dynamic> json) { factory Product.fromJson(Map<String, dynamic> json) {
return Product( return Product(
id: json['id'] as String, id: json['id'] as String,

View File

@@ -5,7 +5,6 @@ import 'kasir_screen.dart';
import 'produk_screen.dart'; import 'produk_screen.dart';
import 'riwayat_screen.dart'; import 'riwayat_screen.dart';
import 'pengaturan_screen.dart'; import 'pengaturan_screen.dart';
import 'printer_screen.dart';
import '../state/app_state.dart'; import '../state/app_state.dart';
import '../utils/colors.dart'; import '../utils/colors.dart';
@@ -30,7 +29,7 @@ class _HomePageState extends State<HomePage> {
} else { } else {
screens.addAll([ screens.addAll([
const RiwayatScreen(), const RiwayatScreen(),
const PrinterScreen(), const PengaturanScreen(),
]); ]);
} }
return screens; return screens;
@@ -65,8 +64,8 @@ class _HomePageState extends State<HomePage> {
label: 'Riwayat', label: 'Riwayat',
), ),
const BottomNavigationBarItem( const BottomNavigationBarItem(
icon: Icon(Icons.print_outlined), icon: Icon(Icons.settings_outlined),
label: 'Printer', label: 'Pengaturan',
), ),
]); ]);
} }

View File

@@ -72,6 +72,125 @@ class _KasirScreenState extends State<KasirScreen> {
), ),
), ),
), ),
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<Map<String, dynamic>>(
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( if (state.resellerEnabled) Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: Row( child: Row(
@@ -182,10 +301,10 @@ class _KasirScreenState extends State<KasirScreen> {
showDialog( showDialog(
context: sheetContext, context: sheetContext,
builder: (dialogContext) { builder: (dialogContext) {
final isPayingRef = [false];
DateTime? payingStart;
return StatefulBuilder( return StatefulBuilder(
builder: (context, setDialog) { builder: (context, setDialog) {
final isPayingRef = [false];
DateTime? payingStart;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
void updatePaid(String v) { void updatePaid(String v) {
@@ -233,7 +352,7 @@ class _KasirScreenState extends State<KasirScreen> {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
if (mounted) { if (mounted) {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
_showReceipt(dialogContext, sale); _showReceipt(dialogContext, sale, isBayarNanti: isBayarNanti);
} }
} catch (e) { } catch (e) {
if (!dialogContext.mounted) return; if (!dialogContext.mounted) return;
@@ -662,157 +781,162 @@ class _KasirScreenState extends State<KasirScreen> {
); );
} }
void _showReceipt(BuildContext context, Sale sale) { void _showReceipt(BuildContext context, Sale sale, {bool isBayarNanti = false}) {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (ctx) {
return AlertDialog( final state = ctx.read<AppState>();
contentPadding: const EdgeInsets.all(20), bool printing = false;
content: SizedBox( return StatefulBuilder(
width: double.maxFinite, builder: (context, setReceipt) {
child: Column( return AlertDialog(
mainAxisSize: MainAxisSize.min, contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
children: [ actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
const CircleAvatar( icon: Icon(isBayarNanti ? Icons.receipt_long : Icons.check_circle, color: kSuccess, size: 28),
radius: 26, title: Text(isBayarNanti ? 'Pesanan Berhasil' : 'Pembayaran Berhasil'),
backgroundColor: kSuccess, content: SizedBox(
child: Icon(Icons.check, color: Colors.white, size: 30), width: double.maxFinite,
), child: Column(
const SizedBox(height: 10), mainAxisSize: MainAxisSize.min,
const Text('Pembayaran Berhasil', children: [
style: Text(
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), '${formatTanggal(sale.date)} ${formatWaktu(sale.date)} • #${sale.id.toUpperCase()}',
const SizedBox(height: 2), style: const TextStyle(color: kMuted, fontSize: 12),
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<AppState>().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)),
],
), ),
), const SizedBox(height: 12),
const Divider(height: 24), ConstrainedBox(
_ReceiptRow('Total', formatRupiah(sale.total), true), constraints: const BoxConstraints(maxHeight: 320),
_ReceiptRow('Metode', _paymentMethodLabel(sale.paymentMethod), false), child: SingleChildScrollView(
if (sale.paymentStatus == 'unpaid') ...[ child: Column(
_ReceiptRow('Status', 'Belum Lunas', false), crossAxisAlignment: CrossAxisAlignment.start,
_ReceiptRow('Sisa', formatRupiah(sale.dueAmount), false), children: [
] else if (sale.paymentMethod == 'tunai') ...[ ...sale.items.map((it) => Padding(
_ReceiptRow('Bayar', formatRupiah(sale.paid), false), padding: const EdgeInsets.symmetric(vertical: 3),
_ReceiptRow('Kembali', formatRupiah(sale.change), false), child: Row(
], children: [
if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty) Expanded(
...sale.splitPayment!.entries.map((e) { child: Text(
final label = e.key == 'tunai' it.productId.startsWith('manual-')
? 'Tunai' ? '[MANUAL] ${it.name} x${it.qty}'
: e.key == 'transfer' : '${it.name} x${it.qty}',
? 'Bank' style: TextStyle(
: 'QRIS'; fontSize: 13,
return _ReceiptRow('Split $label', formatRupiah(e.value), false); color: it.productId.startsWith('manual-') ? kAccent : null,
}), ),
const SizedBox(height: 16), ),
const Text('Terima kasih!', ),
style: TextStyle(color: kMuted)), Text(formatRupiah(it.subtotal),
], style: const TextStyle(
), fontSize: 13,
), fontWeight: FontWeight.w600)),
actions: [ ],
SizedBox( ),
width: double.infinity, )),
child: FilledButton( const SizedBox(height: 8),
onPressed: () => Navigator.pop(context), Text(
child: const Text('Tutup'), 'Kasir: ${state.getProfileName(sale.userId)}',
), style: const TextStyle(color: kMuted, fontSize: 12),
), ),
const SizedBox(height: 8), if (sale.discount > 0)
SizedBox( Padding(
width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 3),
child: OutlinedButton.icon( child: Row(
onPressed: () async { mainAxisAlignment: MainAxisAlignment.spaceBetween,
HapticFeedback.lightImpact(); children: [
final messenger = ScaffoldMessenger.of(context); const Text('Diskon',
final prefs = await SharedPreferences.getInstance(); style: TextStyle(color: kMuted)),
final storeName = Text('-${formatRupiah(sale.discount)}',
prefs.getString('store_name') ?? 'Toko Grosir'; style: const TextStyle(color: kMuted)),
final err = await printerService.printSale( ],
sale, ),
storeName, ),
kasirName: context.read<AppState>().getProfileName(sale.userId), const Divider(height: 20),
whatsapp: prefs.getString('store_whatsapp'), _ReceiptRow('Total', formatRupiah(sale.total), true),
notes: prefs.getString('store_notes'), _ReceiptRow(
); 'Metode', _paymentMethodLabel(sale.paymentMethod), false),
if (mounted) { if (sale.paymentStatus == 'unpaid') ...[
messenger.showSnackBar( _ReceiptRow('Status', 'Belum Lunas', false),
SnackBar( _ReceiptRow('Sisa', formatRupiah(sale.dueAmount), false),
content: Text(err == null ] else if (sale.paymentMethod == 'tunai') ...[
? 'Struk dikirim ke printer' _ReceiptRow('Bayar', formatRupiah(sale.paid), false),
: 'Gagal cetak: $err'), _ReceiptRow('Kembali', formatRupiah(sale.change), false),
backgroundColor: err == null ? kSuccess : kDanger, ],
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(line.product.name, Row(
maxLines: 2, children: [
overflow: TextOverflow.ellipsis, Expanded(
style: const TextStyle( child: Text(line.product.name,
fontSize: 14, fontWeight: FontWeight.w600)), 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), const SizedBox(height: 2),
Text( Text(
'${formatRupiah(line.price)} /pcs', '${formatRupiah(line.price)} /pcs',

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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 '../state/app_state.dart';
import '../utils/colors.dart'; import '../utils/colors.dart';
@@ -21,23 +24,61 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
final TextEditingController _notesCtrl = TextEditingController(); final TextEditingController _notesCtrl = TextEditingController();
bool _resellerEnabled = true; bool _resellerEnabled = true;
bool _loading = true; bool _loading = true;
Timer? _settingsSyncTimer;
bool _loadingSettings = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadSettings(); _loadSettings();
_settingsSyncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (!_loadingSettings && mounted) {
_loadSettings();
}
});
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); if (_loadingSettings) return;
setState(() { _loadingSettings = true;
_storeNameCtrl.text = prefs.getString('store_name') ?? 'Toko Grosir'; try {
_currencyCtrl.text = prefs.getString('currency') ?? 'IDR'; final prefs = await SharedPreferences.getInstance();
_whatsappCtrl.text = prefs.getString('store_whatsapp') ?? ''; String storeName = prefs.getString('store_name') ?? 'Toko Grosir';
_notesCtrl.text = prefs.getString('store_notes') ?? ''; String currency = prefs.getString('currency') ?? 'IDR';
_resellerEnabled = prefs.getBool('reseller_enabled') ?? true; String whatsapp = prefs.getString('store_whatsapp') ?? '';
_loading = false; 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<void> _saveSettings() async { Future<void> _saveSettings() async {
@@ -50,6 +91,21 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
if (!mounted) return; if (!mounted) return;
final state = context.read<AppState>(); final state = context.read<AppState>();
state.setResellerEnabled(_resellerEnabled); 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 @override
@@ -58,38 +114,49 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
return const Center(child: CircularProgressIndicator(color: kAccent)); return const Center(child: CircularProgressIndicator(color: kAccent));
} }
final state = context.watch<AppState>();
final isAdmin = state.isAdmin;
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
context.read<AppState>().recordManualRefresh(); context.read<AppState>().recordManualRefresh();
await context.read<AppState>().refresh(); await context.read<AppState>().refresh();
_settingsSyncTimer?.cancel();
_settingsSyncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (!_loadingSettings && mounted) {
_loadSettings();
}
});
}, },
color: kAccent, color: kAccent,
child: ListView( child: ListView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
children: [ children: [
_SettingsListTile( if (isAdmin) ...[
icon: Icons.store_outlined, _SettingsListTile(
title: 'Pengaturan Toko', icon: Icons.receipt_long_outlined,
subtitle: _whatsappCtrl.text.isEmpty title: 'Pengaturan Struk',
? '${_storeNameCtrl.text} • ${_currencyCtrl.text}' subtitle: _whatsappCtrl.text.isEmpty
: '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}', ? '${_storeNameCtrl.text} • ${_currencyCtrl.text}'
onTap: () => _showStoreSettingsDialog(context), : '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}',
), onTap: () => _showStoreSettingsDialog(context),
const SizedBox(height: 8), ),
_SettingsListTile( const SizedBox(height: 8),
icon: Icons.people_outline, _SettingsListTile(
title: 'Akun Kasir', icon: Icons.people_outline,
subtitle: 'Tambah, edit, atau hapus akun kasir', title: 'Akun Kasir',
onTap: () { subtitle: 'Tambah, edit, atau hapus akun kasir',
Navigator.push( onTap: () {
context, Navigator.push(
MaterialPageRoute( context,
builder: (_) => const KelolaKasirScreen(), MaterialPageRoute(
), builder: (_) => const KelolaKasirScreen(),
); ),
}, );
), },
const SizedBox(height: 8), ),
const SizedBox(height: 8),
],
_SettingsListTile( _SettingsListTile(
icon: Icons.print_outlined, icon: Icons.print_outlined,
title: 'Printer Bluetooth', title: 'Printer Bluetooth',
@@ -103,6 +170,35 @@ class _PengaturanScreenState extends State<PengaturanScreen> {
); );
}, },
), ),
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<PackageInfo>(
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<PengaturanScreen> {
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
child: const Text('Batal'), child: const Text('Batal'),
), ),
FilledButton.icon( const SizedBox(width: 8),
onPressed: () async { SizedBox(
_storeNameCtrl.text = storeNameCtrl.text.trim(); width: 120,
_currencyCtrl.text = currencyCtrl.text.trim(); child: FilledButton.icon(
_whatsappCtrl.text = whatsappCtrl.text.trim(); onPressed: () async {
_notesCtrl.text = notesCtrl.text.trim(); _storeNameCtrl.text = storeNameCtrl.text.trim();
_resellerEnabled = resellerEnabled; _currencyCtrl.text = currencyCtrl.text.trim();
await _saveSettings(); _whatsappCtrl.text = whatsappCtrl.text.trim();
if (ctx.mounted) { _notesCtrl.text = notesCtrl.text.trim();
Navigator.pop(ctx, true); _resellerEnabled = resellerEnabled;
} await _saveSettings();
}, if (ctx.mounted) {
icon: const Icon(Icons.save_outlined, size: 18), Navigator.pop(ctx, true);
label: const Text('Simpan'), }
},
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<PengaturanScreen> {
@override @override
void dispose() { void dispose() {
_settingsSyncTimer?.cancel();
_storeNameCtrl.dispose(); _storeNameCtrl.dispose();
_currencyCtrl.dispose(); _currencyCtrl.dispose();
_whatsappCtrl.dispose(); _whatsappCtrl.dispose();
@@ -224,12 +328,12 @@ class _SettingsListTile extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
final String subtitle; final String subtitle;
final VoidCallback onTap; final VoidCallback? onTap;
const _SettingsListTile({ const _SettingsListTile({
required this.icon, required this.icon,
required this.title, required this.title,
required this.subtitle, required this.subtitle,
required this.onTap, this.onTap,
}); });
@override @override

View File

@@ -512,66 +512,71 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
BuildContext parentContext, BuildContext sheetContext, Sale sale, ScrollController controller) { BuildContext parentContext, BuildContext sheetContext, Sale sale, ScrollController controller) {
final state = parentContext.read<AppState>(); final state = parentContext.read<AppState>();
final isAdmin = state.isAdmin; final isAdmin = state.isAdmin;
final isCancelled = sale.isCancelled; final isKasir = state.isKasir;
return Container( return StatefulBuilder(
decoration: const BoxDecoration( builder: (context, setSheet) {
color: kSurface, Sale currentSale = sale;
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), final isCancelled = currentSale.isCancelled;
border: Border(top: BorderSide(color: kBorder)),
), return Container(
child: Column( decoration: const BoxDecoration(
mainAxisSize: MainAxisSize.min, color: kSurface,
children: [ borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
Container( border: Border(top: BorderSide(color: kBorder)),
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: kBorder,
borderRadius: BorderRadius.circular(4),
),
), ),
Padding( child: Column(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), mainAxisSize: MainAxisSize.min,
child: Row( children: [
children: [ Container(
Expanded( margin: const EdgeInsets.only(top: 12),
child: Column( width: 40,
crossAxisAlignment: CrossAxisAlignment.start, height: 4,
children: [ decoration: BoxDecoration(
Text('#${sale.id.toUpperCase()}', color: kBorder,
style: const TextStyle(fontSize: 10, color: kMuted), borderRadius: BorderRadius.circular(4),
overflow: TextOverflow.ellipsis),
Text(
'Kasir: ${state.getProfileName(sale.userId)}',
style: const TextStyle(color: kMuted, fontSize: 11),
),
],
),
), ),
if (isCancelled) ),
Container( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
horizontal: 8, vertical: 2), child: Row(
decoration: BoxDecoration( children: [
color: kDanger.withValues(alpha: 0.15), Expanded(
borderRadius: BorderRadius.circular(8), 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( if (isCancelled)
'DIBATALKAN', Container(
style: TextStyle( padding: const EdgeInsets.symmetric(
fontSize: 10, horizontal: 8, vertical: 2),
fontWeight: FontWeight.w700, decoration: BoxDecoration(
color: kDanger), 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), const SizedBox(height: 2),
Text( Text(
'${formatTanggal(sale.date)} ${formatWaktu(sale.date)}', '${formatTanggal(currentSale.date)} ${formatWaktu(currentSale.date)}',
style: const TextStyle(color: kMuted, fontSize: 12), style: const TextStyle(color: kMuted, fontSize: 12),
), ),
const Divider(height: 24), const Divider(height: 24),
@@ -582,14 +587,21 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
...sale.items.map((it) => Padding( ...currentSale.items.map((it) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Text('${it.name} x${it.qty}', child: Text(
style: const TextStyle(fontSize: 14)), 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), Text(formatRupiah(it.subtotal),
style: const TextStyle( style: const TextStyle(
@@ -597,40 +609,41 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
], ],
), ),
)), )),
if (sale.discount > 0) if (currentSale.discount > 0)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Diskon', style: TextStyle(color: kMuted)), const Text('Diskon', style: TextStyle(color: kMuted)),
Text('-${formatRupiah(sale.discount)}', Text('-${formatRupiah(currentSale.discount)}',
style: const TextStyle(color: kMuted)), style: const TextStyle(color: kMuted)),
], ],
), ),
), ),
const Divider(height: 24), const Divider(height: 24),
_receiptRow('Total', formatRupiah(sale.total), true), _receiptRow('Total', formatRupiah(currentSale.total), true),
_receiptRow('Bayar', formatRupiah(sale.paid), false), _receiptRow('Bayar', formatRupiah(currentSale.paid), false),
if (sale.paymentStatus == 'unpaid' || sale.paymentStatus == 'partial') if (currentSale.paymentStatus == 'unpaid' || currentSale.paymentStatus == 'partial')
_receiptRow('Sisa', formatRupiah(sale.dueAmount), false), _receiptRow('Sisa', formatRupiah(currentSale.dueAmount), false),
_receiptRow('Kembali', formatRupiah(sale.change), false), if (currentSale.paymentStatus == 'paid')
_receiptRow('Metode', _paymentMethodLabel(sale.paymentMethod), false), _receiptRow('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false),
_receiptRow('Status', sale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false), _receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false),
if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty) _receiptRow('Status', currentSale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false),
...sale.splitPayment!.entries.map((e) { if (currentSale.splitPayment != null && currentSale.splitPayment!.isNotEmpty)
final label = e.key == 'tunai' ...currentSale.splitPayment!.entries.map((e) {
? 'Tunai' final label = e.key == 'tunai'
: e.key == 'transfer' ? 'Tunai'
? 'Bank' : e.key == 'transfer'
: 'QRIS'; ? 'Bank'
return _receiptRow('Split $label', formatRupiah(e.value), false); : 'QRIS';
}), return _receiptRow('Split $label', formatRupiah(e.value), false);
if (sale.totalCost > 0) ...[ }),
const Divider(height: 24), if (!state.isKasir && currentSale.totalCost > 0) ...[
_receiptRow('Harga Pokok', formatRupiah(sale.totalCost), false), const Divider(height: 24),
_receiptRow('Laba Kotor', formatRupiah(sale.total - sale.totalCost), true), _receiptRow('Harga Pokok', formatRupiah(currentSale.totalCost), false),
], _receiptRow('Laba Kotor', formatRupiah(currentSale.total - currentSale.totalCost), true),
],
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -644,9 +657,9 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
prefs.getString('store_name') ?? 'Toko Grosir'; prefs.getString('store_name') ?? 'Toko Grosir';
final err = final err =
await printerService.printSale( await printerService.printSale(
sale, currentSale,
storeName, storeName,
kasirName: state.getProfileName(sale.userId), kasirName: state.getProfileName(currentSale.userId),
whatsapp: prefs.getString('store_whatsapp'), whatsapp: prefs.getString('store_whatsapp'),
notes: prefs.getString('store_notes'), notes: prefs.getString('store_notes'),
); );
@@ -665,7 +678,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
label: const Text('Cetak Struk'), label: const Text('Cetak Struk'),
), ),
), ),
if (isAdmin && !isCancelled && sale.paymentStatus != 'paid') ...[ if ((isAdmin || isKasir) && !isCancelled && currentSale.paymentStatus != 'paid') ...[
const SizedBox(height: 4), const SizedBox(height: 4),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -674,60 +687,168 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
final payCtrl = TextEditingController(); final payCtrl = TextEditingController();
final result = await showDialog<double>( final result = await showDialog<double>(
context: parentContext, context: parentContext,
builder: (ctx) => AlertDialog( builder: (ctx) {
title: Text(sale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'), bool submitting = false;
content: Column( return StatefulBuilder(
mainAxisSize: MainAxisSize.min, builder: (context, setDialog) {
crossAxisAlignment: CrossAxisAlignment.start, final isUnpaid = currentSale.paymentStatus == 'unpaid';
children: [ final remain = currentSale.dueAmount - currentSale.paid;
Text('Total: ${formatRupiah(sale.total)}'), final remainColor = remain <= 0 ? kSuccess : kDanger;
Text('Sudah bayar: ${formatRupiah(sale.paid)}'), return AlertDialog(
Text('Sisa: ${formatRupiah(sale.dueAmount)}'), title: Text(isUnpaid ? 'Terima Pembayaran' : 'Bayar Sisa'),
const SizedBox(height: 12), content: SingleChildScrollView(
TextField( child: Column(
controller: payCtrl, mainAxisSize: MainAxisSize.min,
keyboardType: TextInputType.number, crossAxisAlignment: CrossAxisAlignment.start,
inputFormatters: [RupiahInputFormatter()], children: [
decoration: const InputDecoration( Container(
labelText: 'Nominal Pembayaran', width: double.infinity,
prefixText: 'Rp ', 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: [
], Row(
), children: [
actions: [ Expanded(
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Batal')), child: OutlinedButton(
FilledButton( onPressed: submitting ? null : () => Navigator.pop(ctx),
onPressed: () { style: OutlinedButton.styleFrom(
final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble(); foregroundColor: kMuted,
if (val <= 0) return; side: const BorderSide(color: kBorder),
Navigator.pop(ctx, val); ),
}, child: const Text('Batal'),
style: FilledButton.styleFrom(backgroundColor: kSuccess), ),
child: const Text('Simpan'), ),
), const SizedBox(width: 10),
], Expanded(
), child: FilledButton(
); onPressed: submitting
if (result != null && result > 0) { ? null
final err = await state.recordPayment(sale.id, result); : () {
if (mounted) { final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble();
final messenger = ScaffoldMessenger.of(parentContext); if (val <= 0) return;
messenger.showSnackBar( Navigator.pop(ctx, val);
SnackBar( },
content: Text(err == null ? 'Pembayaran berhasil dicatat' : 'Gagal: $err'), style: FilledButton.styleFrom(backgroundColor: kSuccess, foregroundColor: const Color(0xFF06210F)),
backgroundColor: err == null ? kSuccess : kDanger, 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), icon: const Icon(Icons.payments_outlined, color: Colors.white),
label: Text(sale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'), label: Text(currentSale.paymentStatus == 'unpaid' ? 'Terima Pembayaran' : 'Bayar Sisa'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: kSuccess, backgroundColor: kSuccess,
foregroundColor: const Color(0xFF06210F), foregroundColor: const Color(0xFF06210F),
), ),
), ),
), ),
], ],
@@ -740,40 +861,51 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
final messenger = ScaffoldMessenger.of(parentContext); final messenger = ScaffoldMessenger.of(parentContext);
final confirm = await showDialog<bool>( final confirm = await showDialog<bool>(
context: parentContext, context: parentContext,
builder: (ctx) => AlertDialog( builder: (ctx) {
icon: Icon(Icons.cancel_outlined, color: kDanger, size: 28), bool cancelling = false;
title: const Text('Batalkan Transaksi?'), return StatefulBuilder(
content: const Text( builder: (context, setDialog) {
'Stok akan dikembalikan secara otomatis. Tindakan ini tidak dapat dibatalkan.', return AlertDialog(
), icon: Icon(Icons.warning_amber_rounded, color: kDanger, size: 28),
actions: [ title: const Text('Batalkan Transaksi?'),
SizedBox( content: const Text(
width: double.infinity, 'Stok akan dikembalikan secara otomatis. Tindakan ini tidak dapat dibatalkan.',
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
), ),
child: const Text('Batal'), actions: [
), Row(
), children: [
const SizedBox(height: 8), Expanded(
SizedBox( child: OutlinedButton(
width: double.infinity, onPressed: cancelling ? null : () => Navigator.pop(ctx, false),
child: FilledButton( style: OutlinedButton.styleFrom(
onPressed: () => Navigator.pop(ctx, true), foregroundColor: kMuted,
style: FilledButton.styleFrom( side: const BorderSide(color: kBorder),
backgroundColor: kDanger, ),
), child: const Text('Batal'),
child: const Text('Batalkan'), ),
), ),
), 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) { if (confirm == true && mounted) {
final err = await state.cancelSale(sale.id); final err = await state.cancelSale(currentSale.id);
if (mounted) { if (mounted) {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
messenger.showSnackBar( messenger.showSnackBar(
@@ -802,6 +934,8 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
), ),
], ],
), ),
);
},
); );
} }

View File

@@ -97,11 +97,13 @@ class OfflineQueueService {
final items = (transaction['items'] as List?) ?? []; final items = (transaction['items'] as List?) ?? [];
final saleId = sale['id'] as String; final saleId = sale['id'] as String;
final storeId = sale['store_id'] as String?;
final existing = await _withTimeout( final existing = await _withTimeout(
_supabase _supabase
.from('sales') .from('sales')
.select('id, total_cost') .select('id, total_cost')
.eq('id', saleId) .eq('id', saleId)
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000')
.maybeSingle(), .maybeSingle(),
'Check existing sale', 'Check existing sale',
); );
@@ -118,7 +120,8 @@ class OfflineQueueService {
_supabase _supabase
.from('sales') .from('sales')
.update({'total_cost': sale['total_cost']}) .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', 'Update sale total_cost',
); );
} }
@@ -132,6 +135,7 @@ class OfflineQueueService {
.from('products') .from('products')
.select() .select()
.eq('id', productId) .eq('id', productId)
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000')
.maybeSingle(), .maybeSingle(),
'Load product for stock update', 'Load product for stock update',
); );
@@ -142,7 +146,8 @@ class OfflineQueueService {
_supabase _supabase
.from('products') .from('products')
.update({'stock': newStock}) .update({'stock': newStock})
.eq('id', productId), .eq('id', productId)
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000'),
'Update product stock', 'Update product stock',
); );
} }

View File

@@ -177,7 +177,6 @@ class PrinterService {
])); ]));
} }
} }
bytes.addAll(generator.hr());
if (kasirName != null && kasirName.isNotEmpty) { if (kasirName != null && kasirName.isNotEmpty) {
bytes.addAll(generator.text('Kasir: $kasirName', bytes.addAll(generator.text('Kasir: $kasirName',
styles: const PosStyles(align: PosAlign.center))); styles: const PosStyles(align: PosAlign.center)));
@@ -195,12 +194,10 @@ class PrinterService {
styles: const PosStyles(align: PosAlign.center))); styles: const PosStyles(align: PosAlign.center)));
} }
} }
bytes.addAll(generator.hr());
bytes.addAll(generator.text( bytes.addAll(generator.text(
'Terima kasih!', 'Terima kasih!',
styles: const PosStyles(align: PosAlign.center), styles: const PosStyles(align: PosAlign.center),
)); ));
bytes.addAll(generator.feed(2));
bytes.addAll(generator.cut()); bytes.addAll(generator.cut());
return bytes; return bytes;
} }

View File

@@ -33,6 +33,8 @@ class AppState extends ChangeNotifier {
static const Duration _timeout = Duration(seconds: 30); static const Duration _timeout = Duration(seconds: 30);
static const String _currentStoreId = '00000000-0000-0000-0000-000000000000';
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async { Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
try { try {
return await future.timeout( return await future.timeout(
@@ -451,7 +453,7 @@ class AppState extends ChangeNotifier {
Future<void> _loadProducts() async { Future<void> _loadProducts() async {
final rows = await _withTimeout( final rows = await _withTimeout(
_supabase.from('products').select().order('name'), _supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
'Load products', 'Load products',
); );
products = rows.map((e) => Product.fromJson(e)).toList(); products = rows.map((e) => Product.fromJson(e)).toList();
@@ -463,6 +465,7 @@ class AppState extends ChangeNotifier {
_supabase _supabase
.from('sales') .from('sales')
.select() .select()
.eq('store_id', _currentStoreId)
.order('created_at', ascending: false), .order('created_at', ascending: false),
'Load sales', 'Load sales',
); );
@@ -480,7 +483,7 @@ class AppState extends ChangeNotifier {
Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25), Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25),
]; ];
await _withTimeout( 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', 'Seed products',
); );
products = seeds; products = seeds;
@@ -517,7 +520,7 @@ class AppState extends ChangeNotifier {
hargaPokok: hargaPokok, hargaPokok: hargaPokok,
); );
await _withTimeout( await _withTimeout(
_supabase.from('products').insert(product.toJson()), _supabase.from('products').insert(product.toJson()..['store_id'] = _currentStoreId),
'Add product', 'Add product',
); );
} catch (e) { } catch (e) {
@@ -547,6 +550,7 @@ class AppState extends ChangeNotifier {
'stock': stock, 'stock': stock,
'min_stock': minStock, 'min_stock': minStock,
'harga_pokok': hargaPokok, 'harga_pokok': hargaPokok,
'store_id': _currentStoreId,
}).eq('id', id), }).eq('id', id),
'Update product', 'Update product',
); );
@@ -564,7 +568,7 @@ class AppState extends ChangeNotifier {
try { try {
cart.removeWhere((c) => c.product.id == id); cart.removeWhere((c) => c.product.id == id);
await _withTimeout( await _withTimeout(
_supabase.from('products').delete().eq('id', id), _supabase.from('products').delete().eq('id', id).eq('store_id', _currentStoreId),
'Delete product', 'Delete product',
); );
} catch (e) { } catch (e) {
@@ -641,7 +645,7 @@ class AppState extends ChangeNotifier {
discount: _discount, discount: _discount,
total: total, total: total,
paid: paid, paid: paid,
change: paid - total, change: paymentStatus == 'paid' ? (paid - total).clamp(0, double.infinity) : 0,
totalCost: totalCost, totalCost: totalCost,
paymentMethod: paymentMethod, paymentMethod: paymentMethod,
paymentStatus: paymentStatus, paymentStatus: paymentStatus,
@@ -652,11 +656,12 @@ class AppState extends ChangeNotifier {
try { try {
await _withTimeout( await _withTimeout(
_supabase.from('sales').insert(sale.toJson()), _supabase.from('sales').insert(sale.toJson()..['store_id'] = _currentStoreId),
'Create sale', 'Create sale',
); );
for (final line in cart) { for (final line in cart) {
if (line.product.id.startsWith('manual-')) continue;
try { try {
final current = final current =
products.firstWhere((p) => p.id == line.product.id); products.firstWhere((p) => p.id == line.product.id);
@@ -664,7 +669,8 @@ class AppState extends ChangeNotifier {
_supabase _supabase
.from('products') .from('products')
.update({'stock': current.stock - line.qty}) .update({'stock': current.stock - line.qty})
.eq('id', line.product.id), .eq('id', line.product.id)
.eq('store_id', _currentStoreId),
'Update stock', 'Update stock',
); );
} catch (_) {} } catch (_) {}
@@ -703,7 +709,7 @@ class AppState extends ChangeNotifier {
final sale = sales.firstWhere((s) => s.id == saleId); final sale = sales.firstWhere((s) => s.id == saleId);
await _withTimeout( 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', 'Cancel sale',
); );
@@ -717,7 +723,8 @@ class AppState extends ChangeNotifier {
_supabase _supabase
.from('products') .from('products')
.update({'stock': product.stock + item.qty}) .update({'stock': product.stock + item.qty})
.eq('id', product.id), .eq('id', product.id)
.eq('store_id', _currentStoreId),
'Restore stock', 'Restore stock',
); );
} }
@@ -745,7 +752,8 @@ class AppState extends ChangeNotifier {
'paid': newPaid, 'paid': newPaid,
'due_amount': newDue, 'due_amount': newDue,
'payment_status': newStatus, 'payment_status': newStatus,
}).eq('id', saleId), 'change': (newPaid - sale.total).clamp(0, double.infinity),
}).eq('id', saleId).eq('store_id', _currentStoreId),
'Record payment', 'Record payment',
); );
await _loadSales(); await _loadSales();

View File

@@ -8,6 +8,7 @@ import Foundation
import app_links import app_links
import connectivity_plus import connectivity_plus
import mobile_scanner import mobile_scanner
import package_info_plus
import path_provider_foundation import path_provider_foundation
import print_bluetooth_thermal import print_bluetooth_thermal
import share_plus import share_plus
@@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PrintBluetoothThermalPlugin.register(with: registry.registrar(forPlugin: "PrintBluetoothThermalPlugin")) PrintBluetoothThermalPlugin.register(with: registry.registrar(forPlugin: "PrintBluetoothThermalPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))

View File

@@ -440,6 +440,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: passkeys_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -838,7 +854,7 @@ packages:
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -45,6 +45,8 @@ dependencies:
share_plus: ^8.0.0 share_plus: ^8.0.0
print_bluetooth_thermal: 1.2.1 print_bluetooth_thermal: 1.2.1
esc_pos_utils_plus: ^2.0.4 esc_pos_utils_plus: ^2.0.4
uuid: ^4.0.0
package_info_plus: ^8.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -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;