Compare commits

...

205 Commits

Author SHA1 Message Date
Baghaztra
a3e68b8cd0 Update Dockerfile 2025-10-01 17:08:56 +07:00
dhilanradya
6e98ca20e4 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-10-01 14:37:32 +07:00
dhilanradya
d4ecc7f6c1 [fix] perbaikan nampan untuk kasir 2025-10-01 14:31:10 +07:00
Baghaztra
fdb3ac15c6 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-10-01 13:42:05 +07:00
Baghaztra
9cb8155a35 [Update] Docker
Masih belum berjalan
2025-10-01 13:42:03 +07:00
timotiabbauftech
f3f8b7fe04 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-10-01 10:48:04 +07:00
timotiabbauftech
e84a4bdadb [update] alert respon messae 2025-10-01 10:47:53 +07:00
dhilanradya
3052aacb45 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-29 16:56:03 +07:00
dhilanradya
5999ff6359 [update] strukview 2025-09-29 16:55:18 +07:00
Baghaztra
6f7a4df667 [Update] 5 detik 2025-09-29 11:29:52 +07:00
Baghaztra
9323eb2700 [Update] Durasi alert halaman nampan 2025-09-29 11:18:49 +07:00
Baghaztra
9c00f3db7d [Update] Navigation menu active 2025-09-29 10:01:21 +07:00
Baghaztra
2c87edef82 [Update] clenaing console log 2025-09-29 09:37:36 +07:00
Baghaztra
b472c7091c [Update] cleaning console log 2025-09-29 09:35:58 +07:00
Baghaztra
dac6f59018 [Update] Search bersadarkan nama 2025-09-24 15:50:22 +07:00
Baghaztra
be38f618a0 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 20:17:34 +07:00
Baghaztra
13e998eb77 [Feat] Riwayat transaksi 2025-09-19 20:17:31 +07:00
dhilanradya
fcf4473aa3 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 17:05:10 +07:00
dhilanradya
6d1cba0f2a [update] struk klo 1 item 2025-09-19 17:04:21 +07:00
adityaalfarison
ec9898dd73 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 16:21:23 +07:00
adityaalfarison
fc71b974a6 update input produk using camera 2025-09-19 16:21:17 +07:00
dhilanradya
89cc69d789 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 15:51:34 +07:00
dhilanradya
604148e28e [update] strukview 2025-09-19 15:50:27 +07:00
Baghaztra
e0431dfeac [update] notifikasi kasir 5 detik 2025-09-19 13:48:03 +07:00
Baghaztra
f01b2ba34a Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 13:38:42 +07:00
Baghaztra
88d14def41 [Fix] page 2025-09-19 13:38:39 +07:00
timotiabbauftech
982b14e46a Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 13:31:56 +07:00
timotiabbauftech
e80a68fbb7 [update] tata letak tombol 2025-09-19 13:31:45 +07:00
Baghaztra
7ef8f79b6c Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 13:27:00 +07:00
Baghaztra
f71fabdc90 [Update] Filter interval laporan 2025-09-19 13:26:53 +07:00
adityaalfarison
bd07755a0f Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-19 11:20:19 +07:00
timotiabbauftech
e4e98e3f98 [update] validasi duplikat nama produk 2025-09-19 11:14:49 +07:00
Baghaztra
97192bb05a [Refactor] Ubah format laporan 2025-09-19 10:27:47 +07:00
adityaalfarison
edf833e113 preview qrcode di brankas&nampan 2025-09-19 10:15:25 +07:00
adityaalfarison
8ed0ea26cf update stok item di halaman produk tanpa reload page 2025-09-19 09:50:15 +07:00
Baghaztra
88207ea1fc [Fix] Filter nampan 2025-09-19 09:50:10 +07:00
timotiabbauftech
6246800b0c Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-18 17:06:33 +07:00
timotiabbauftech
d9939d4dab [update] border 2025-09-18 17:05:55 +07:00
dhilanradya
43a9c3a8df Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-18 17:04:13 +07:00
dhilanradya
b6c3723fa7 [update] struk overlay dan view 2025-09-18 17:03:02 +07:00
Baghaztra
e584b8e8f8 [Update] QR menampilkan kode item dengan benar 2025-09-18 17:02:01 +07:00
Baghaztra
d8a8622cb5 [Update] Minor bug pas presentasi 2025-09-18 16:33:13 +07:00
Baghaztra
3bdf9001c4 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-18 16:02:49 +07:00
dhilanradya
f4e0392331 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-18 16:02:31 +07:00
Baghaztra
14c94f86e7 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-18 15:59:51 +07:00
Baghaztra
492cc651f9 [feat] cetak laporan (belom) 2025-09-18 15:59:48 +07:00
adityaalfarison
30087c2b7f delete item brankas,nampan 2025-09-18 15:45:16 +07:00
dhilanradya
ce1cd280b9 [create] detail transaksi 2025-09-18 15:04:05 +07:00
dhilanradya
d89f433d91 [update] notifikasi/tanda produk yg stok nya menipis 2025-09-18 13:24:24 +07:00
Baghaztra
0db65d190f Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-17 15:38:29 +07:00
Baghaztra
70c15edc27 [Update] Minor bug fixed 2025-09-17 15:37:29 +07:00
dhilanradya
795edadfe3 [update] penyesuaian logo 2025-09-17 15:21:30 +07:00
Baghaztra
8674ec9828 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-17 13:28:55 +07:00
Baghaztra
85138291b5 [Update] Minor detail fixed 2025-09-17 13:28:46 +07:00
dhilanradya
3d25ff5b13 [update] ui struk + fix input ongkos bikin 2025-09-17 01:45:54 +07:00
dhilanradya
428fedb944 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-15 17:08:20 +07:00
dhilanradya
4ecca7d2c6 [update] ui struk (blm kelar) 2025-09-15 17:07:56 +07:00
Baghaztra
aa64332418 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-15 14:42:05 +07:00
Baghaztra
6adc5c0f98 [Update] Data seeder 2025-09-15 14:42:03 +07:00
adityaalfarison
1834441d78 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-15 11:00:29 +07:00
adityaalfarison
197cb7e628 Fix delete nampan 2025-09-15 11:00:20 +07:00
Baghaztra
f252e53fc3 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-15 10:53:58 +07:00
Baghaztra
bfed0fbd2b [fix] laporan mencatat asal nampan dengan benar 2025-09-15 10:52:40 +07:00
timotiabbauftech
e60498d74e Update CreateAkun.vue 2025-09-13 07:45:11 +07:00
timotiabbauftech
6204acc6aa Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-12 16:59:47 +07:00
timotiabbauftech
c65f0a857b [UPDATE] FIX BUG, 2025-09-12 16:59:38 +07:00
Baghaztra
b028cf25b2 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-12 16:32:13 +07:00
Baghaztra
29a1ebf713 [Update] relasi item 2025-09-12 16:32:10 +07:00
dhilanradya
6b9ec0515a Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-12 16:04:13 +07:00
dhilanradya
286b270bd6 [update] nambahin satuan berat,kadar 2025-09-12 16:04:02 +07:00
adityaalfarison
b78a396a51 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-12 14:23:03 +07:00
adityaalfarison
8e59f8d634 update traylist(scroll nampan) 2025-09-12 14:23:01 +07:00
Baghaztra
e67fb10e37 [Update] Tambah item 2025-09-12 14:15:42 +07:00
Baghaztra
75648b52d9 [Fix] Buat dan edit produk 2025-09-12 14:04:08 +07:00
dhilanradya
4223cad92f [update] ui struk 2025-09-12 13:58:51 +07:00
timotiabbauftech
380ede5ca8 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-12 11:45:24 +07:00
timotiabbauftech
37ad328c5c [update] fix bug 2025-09-12 11:45:19 +07:00
adityaalfarison
f5d0441cd7 update tampilan export excel dan csv 2025-09-12 11:38:15 +07:00
Baghaztra
47a988d078 [update] list transaksi ketinggalan :v 2025-09-12 11:13:18 +07:00
Baghaztra
eaa3318506 [Fix] transaksi 2025-09-12 11:10:13 +07:00
Baghaztra
03d01a1b78 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-12 10:11:50 +07:00
Baghaztra
e1205cf146 [feat] Docker (belum jalan v:) 2025-09-12 10:11:47 +07:00
dhilanradya
be90b771ba Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-11 16:50:30 +07:00
dhilanradya
17892603e8 [feet] struk(blm clear) 2025-09-11 16:50:00 +07:00
adityaalfarison
e454ef2911 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-11 15:15:55 +07:00
adityaalfarison
02fdad7ff5 validasi create akun dan edit akun 2025-09-11 15:15:47 +07:00
Baghaztra
3e96a158e5 [Update] UI Brankas dan nampan 2025-09-11 15:11:20 +07:00
Baghaztra
679ffc504c Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-11 11:45:24 +07:00
Baghaztra
e33805b18e [feat] authorisasi frontend 2025-09-11 11:45:22 +07:00
adityaalfarison
dbd4d46048 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-11 11:42:29 +07:00
adityaalfarison
a1a665bebf brankaslist,traylist update 2025-09-11 11:42:04 +07:00
timotiabbauftech
e04c19c5eb Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-11 11:18:58 +07:00
timotiabbauftech
c1197259e6 Update web.php 2025-09-11 11:18:54 +07:00
timotiabbauftech
251a661032 [update doang di bagian limit] 2025-09-11 11:18:11 +07:00
Baghaztra
4525444505 [Fix] filter laporan 2025-09-11 10:36:35 +07:00
Baghaztra
758f404b0f [Update] validasi user 2025-09-11 09:53:46 +07:00
adityaalfarison
876c5301b3 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-11 09:44:24 +07:00
adityaalfarison
73134f3fd6 brankaslist 2025-09-11 09:44:23 +07:00
Baghaztra
e80c26ac2f [fix] harga jual 2025-09-10 16:44:24 +07:00
Baghaztra
3313ae13c8 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-10 13:33:18 +07:00
Baghaztra
fc21772679 [feat] export detail per nampan dan per produk
Bug: belum bisa filter
2025-09-10 13:32:58 +07:00
timotiabbauftech
156671a21b Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 17:27:35 +07:00
timotiabbauftech
cf8f456fb4 [update] TransaksiController, Transaksi.php, Item.php 2025-09-09 17:27:29 +07:00
dhilanradya
a345dd1229 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 14:30:54 +07:00
dhilanradya
b578faedd0 [update] ui product 2025-09-09 14:29:50 +07:00
Baghaztra
8e6aa4242b Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 11:59:03 +07:00
Baghaztra
d32e659076 [Update] Export ringkasan Laporan 2025-09-09 11:59:00 +07:00
timotiabbauftech
420cf47f20 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 11:34:26 +07:00
timotiabbauftech
634c0683b5 [update] Kasir, KasirForm, KasirTransaksiList Responsive Mobile Desktop 2025-09-09 11:34:19 +07:00
dhilanradya
c28be3706e Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 11:24:34 +07:00
dhilanradya
b9c562d0a2 [update] revisi struk 2025-09-09 11:24:13 +07:00
adityaalfarison
7b1fdc30f6 modal, brankas, nampan 2025-09-09 11:16:56 +07:00
Baghaztra
1cd2aa60d4 [Fix] load produk 2025-09-09 09:59:29 +07:00
Baghaztra
4f880d44e4 [feat] export laporan (pdf) 2025-09-08 18:28:49 +07:00
dhilanradya
7f4b41b904 [fix] ui mobile produk 2025-09-08 17:16:05 +07:00
timotiabbauftech
c6cebf145d Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 14:43:07 +07:00
timotiabbauftech
7083d585f1 Update KasirForm.vue 2025-09-08 14:42:59 +07:00
Baghaztra
10e666a9ce Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 14:29:19 +07:00
Baghaztra
ebb17c2a43 [update] halaman laporan 2025-09-08 14:29:17 +07:00
dhilanradya
86f3e101c8 [fix] perbaikan padding dan title yang tidak serasih 2025-09-08 14:17:42 +07:00
dhilanradya
7766fd8938 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 13:35:38 +07:00
dhilanradya
9b02e00a72 [feat] struk component 2025-09-08 13:34:56 +07:00
timotiabbauftech
ae225ce5c7 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 11:12:26 +07:00
timotiabbauftech
6f87bde474 [update token to luis] 2025-09-08 11:12:18 +07:00
Baghaztra
20c844a98b [feat] kasir menu 2025-09-08 10:33:28 +07:00
Baghaztra
9b2d50ac65 [feat] middleware 2025-09-08 10:21:34 +07:00
Baghaztra
ae4b8a3449 [update] Tutor buat monti 2025-09-08 09:55:05 +07:00
Baghaztra
600c87d9ca Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 09:49:06 +07:00
timotiabbauftech
8e59b1f1f1 [feat RoleMiddleware, update AuthControlller, app.php, BrankasList, TrayList, Login.vue, web.php 2025-09-08 09:48:32 +07:00
Baghaztra
b1babd6c26 [update] laporan 2025-09-08 09:46:04 +07:00
timotiabbauftech
e1a0711082 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-04 14:13:56 +07:00
timotiabbauftech
4afdcada62 (feat AuthControlller, Update User.php, auth.php, web.php] 2025-09-04 14:13:53 +07:00
Baghaztra
b2b34a5f76 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 17:04:40 +07:00
Baghaztra
644d6fb222 [Feat] detail laporan 2025-09-03 17:04:37 +07:00
timotiabbauftech
2cce89b6c4 merge 2025-09-03 14:58:36 +07:00
timotiabbauftech
923f5c5c7f [feat AuthController, update UserController, User.php, EditAkun, Web.php 2025-09-03 14:55:50 +07:00
adityaalfarison
ae259cc273 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 13:54:22 +07:00
adityaalfarison
1f8f11a7ca commit 2025-09-03 13:54:14 +07:00
Baghaztra
bb487a4c09 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 13:53:09 +07:00
Baghaztra
fd328b6e35 [Feat] Ringkasan laporan 2025-09-03 13:53:06 +07:00
adityaalfarison
d58368389e Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 11:49:20 +07:00
adityaalfarison
bdf3a72c15 Login Page, Input password field 2025-09-03 11:49:06 +07:00
timotiabbauftech
982f99ed7b [update] 2025-09-03 11:06:58 +07:00
adityaalfarison
396baa6444 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 09:59:57 +07:00
adityaalfarison
4755dc66fc Halaman Login 2025-09-03 09:59:31 +07:00
timotiabbauftech
26e1ee751e Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 17:14:42 +07:00
timotiabbauftech
bb7d6e7a32 [feat Akun.vue, CreateAkun, EditAkun, Update UserController, Index.js] 2025-09-02 17:14:37 +07:00
dhilanradya
26644df501 rapih rapi tray 2025-09-02 15:53:28 +07:00
timotiabbauftech
fcd7719826 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 14:14:23 +07:00
timotiabbauftech
3174b84c0a Update CreateKategori.vue 2025-09-02 14:14:19 +07:00
timotiabbauftech
11954568ae [update EditSales,Sales,CreateSales, Kategori,EditKategori, CreateKategori] 2025-09-02 14:10:43 +07:00
Baghaztra
a99996940e [Feat] Sistem logic kasir 2025-09-02 11:44:05 +07:00
Baghaztra
3f654c6c7a [fix] bug sales 2025-09-02 10:42:01 +07:00
Baghaztra
ae5507fda4 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 10:38:19 +07:00
Baghaztra
91b4010531 [Fix] bg delete sales 2025-09-02 10:38:16 +07:00
adityaalfarison
55213cee64 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 09:23:27 +07:00
adityaalfarison
bfbe5d69a9 update nampancontroller, traylist,tray,web.php 2025-09-02 09:23:22 +07:00
Timoti313
d51b73c347 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 17:07:51 +07:00
Timoti313
cca9aeaaf0 [feat Sales, CreateSales, EditSales] 2025-09-01 17:05:34 +07:00
dhilanradya
baff04f6a5 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 17:03:32 +07:00
dhilanradya
937f24a5ff [feet] edit produk 2025-09-01 17:02:57 +07:00
Baghaztra
8ab48b4e7d [Update ] Edit warna yang tak sevariabel 2025-09-01 16:50:15 +07:00
dhilanradya
21c96c54c5 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 14:37:31 +07:00
dhilanradya
b991551687 Update mainLayout.vue 2025-09-01 14:27:36 +07:00
Baghaztra
22e91d72b4 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 14:18:35 +07:00
Baghaztra
5b4d7ac6f5 [Fix] Nav dropdown 2025-09-01 14:18:33 +07:00
Timoti313
394b885deb Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 13:45:17 +07:00
Timoti313
cfeae67dd2 [feat Kategori, CreateKategori, EditKategori, [Update Produk, ConfirmDeleteModal]] 2025-09-01 13:45:13 +07:00
Baghaztra
538c96e6b0 [Update] Navigation menu 2025-09-01 11:51:20 +07:00
Timoti313
e615058a51 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 16:10:17 +07:00
Timoti313
7153d79316 [feat,update footer,header ,images, mainLayout]l 2025-08-29 16:10:02 +07:00
Baghaztra
4c4dd5d635 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 15:20:33 +07:00
Baghaztra
2eb29d6dc9 [update] Filter kategori 2025-08-29 15:20:30 +07:00
adityaalfarison
b2f93c4537 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:22:42 +07:00
adityaalfarison
8a0ded4b3e update itemcontroller, traylist DONE 2025-08-29 11:22:18 +07:00
Baghaztra
c7812ea0fb Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:16:31 +07:00
Baghaztra
99fe5322db Create logo.png 2025-08-29 11:16:27 +07:00
Timoti313
fd3565fd64 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:09:15 +07:00
Timoti313
c8d2e10a87 [update KasirTransaksiList, TransaksiController, KasirForm`]
Error sudah teratasi, tinggal membuat tampilan struk
2025-08-29 11:09:11 +07:00
adityaalfarison
87b064850c Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:07:48 +07:00
adityaalfarison
d231ebe909 brankaslist, traylist 2025-08-29 11:07:46 +07:00
Baghaztra
32bab1f01a Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:05:51 +07:00
Baghaztra
d8348f203b [feat] backend kategori 2025-08-29 11:05:46 +07:00
dhilanradya
e96d973b03 [feet] hapus produk 2025-08-29 10:59:49 +07:00
dhilanradya
87d38dffb8 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 09:51:59 +07:00
dhilanradya
5217e2d703 Update Produk.vue 2025-08-29 09:42:36 +07:00
Baghaztra
4e06c25082 [update] membuat item dari halaman produk 2025-08-28 23:56:30 +07:00
Baghaztra
8046360f6e [feat] create item 2025-08-28 22:45:11 +07:00
Timoti313
1a25501579 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 16:21:19 +07:00
Timoti313
1d6bee91e1 [update Kasir, KasirForm, KasirTransaksiList]
Error Data transaksi tidak tampil
2025-08-28 16:20:58 +07:00
Baghaztra
2f72c40788 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 15:13:21 +07:00
Baghaztra
eebabfd919 [update] Input produk 2025-08-28 15:13:19 +07:00
dhilanradya
f0f570be21 Update Produk.vue 2025-08-28 14:35:04 +07:00
Timoti313
4dd3c5188f Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 14:09:58 +07:00
Timoti313
12192c536d [feat Kasir, KasirForm, KasirTransaksiList] 2025-08-28 14:07:56 +07:00
dhilanradya
0bb5b23ead Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:46:16 +07:00
dhilanradya
e5f2c9920b Update index.js 2025-08-28 13:46:07 +07:00
adityaalfarison
773cc1516f Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:11:00 +07:00
adityaalfarison
65923ec59c update dropdown navbar, traylist,tray 2025-08-28 13:10:56 +07:00
dhilanradya
ffe0039391 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:10:03 +07:00
dhilanradya
311605bd5f [update] produk + produkcard 2025-08-28 13:08:33 +07:00
Baghaztra
15917b4c52 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:07:38 +07:00
Baghaztra
71cd60981b [feat] halaman create produk 2025-08-28 13:07:35 +07:00
adityaalfarison
fc5541b8b0 update brankaslist,traylist,tray 2025-08-28 10:28:33 +07:00
Timoti313
7f72921758 Update package-lock.json 2025-08-28 09:29:13 +07:00
123 changed files with 14034 additions and 622 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
node_modules
vendor
.env
Dockerfile
docker-compose.yml
.git
.gitignore
tests
*.log
storage/logs/*

View File

@ -1,8 +1,8 @@
APP_NAME=Laravel
APP_NAME=Abbauf-Kasir
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

34
Dockerfile Normal file
View File

@ -0,0 +1,34 @@
# Stage 1: Build Vue (tetap sama)
FROM node:20 as node_builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Laravel
FROM php:8.3-fpm
RUN apt-get update && apt-get install -y \
git unzip libzip-dev libpng-dev libonig-dev libxml2-dev curl \
&& docker-php-ext-install pdo_mysql zip gd mbstring exif pcntl bcmath \
&& apt-get clean && rm -rf /var/lib/apt/lists/* # Cleanup untuk ukuran kecil
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copy source code
COPY . .
# Copy hasil build Vue
COPY --from=node_builder /app/public/build /var/www/html/public/build
RUN composer install --no-dev --optimize-autoloader
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache # Optimasi cache untuk performa laporan/transaksi
# Set permission dan user non-root
RUN chown -R www-data:www-data /var/www/html
USER www-data
EXPOSE 9000
CMD ["php-fpm"]

172
Documentation/Laporan.md Normal file
View File

@ -0,0 +1,172 @@
# Dokumentasi Refactoring LaporanController
## 📋 Ringkasan Refactoring
File `LaporanController` yang awalnya berukuran **~600 baris** telah dipecah menjadi **6 file** yang lebih terorganisir dan mudah dipelihara:
1. **LaporanController** - Controller utama yang ramping
2. **LaporanService** - Business logic layer
3. **TransaksiRepository** - Data access layer
4. **LaporanHelper** - Utility functions
5. **DetailLaporanRequest** - Validation untuk detail laporan
6. **ExportLaporanRequest** - Validation untuk export
## 🏗️ Struktur Baru
### 1. LaporanController (~80 baris)
- **Tanggung jawab**: Menangani HTTP requests dan responses
- **Fitur**: Error handling, logging, delegasi ke service layer
- **Prinsip**: Single Responsibility - hanya menangani concerns HTTP
### 2. LaporanService (~180 baris)
- **Tanggung jawab**: Business logic dan orchestration
- **Fitur**:
- Caching logic
- Data processing coordination
- Export functionality
- Input validation bisnis
- **Prinsip**: Service layer yang mengkoordinasi antara repository dan helper
### 3. TransaksiRepository (~120 baris)
- **Tanggung jawab**: Data access dan query operations
- **Fitur**:
- Complex database queries
- Data aggregation
- Pagination logic untuk laporan
- **Prinsip**: Repository pattern untuk data abstraction
### 4. LaporanHelper (~180 baris)
- **Tanggung jawab**: Utility functions dan data formatting
- **Fitur**:
- Data formatting (currency, weight)
- Data mapping dan transformation
- Pagination info building
- Filter info building
- **Prinsip**: Helper class untuk fungsi-fungsi utility yang reusable
### 5. DetailLaporanRequest (~60 baris)
- **Tanggung jawab**: Validation rules untuk detail laporan
- **Fitur**:
- Input validation
- Custom error messages
- Data preparation
- **Prinsip**: Form Request untuk clean validation
### 6. ExportLaporanRequest (~40 baris)
- **Tanggung jawab**: Validation rules untuk export
- **Fitur**:
- Export format validation
- Filter validation
- **Prinsip**: Separated concerns untuk different validation needs
## 🎯 Keuntungan Refactoring
### ✅ Maintainability
- **Sebelum**: 1 file besar (~600 baris) sulit untuk debug dan modify
- **Sesudah**: 6 file kecil dengan tanggung jawab yang jelas
### ✅ Testability
- **Sebelum**: Sulit untuk unit test karena semua logic tercampur
- **Sesudah**: Setiap layer dapat di-test secara terpisah
- Service layer dapat di-mock
- Repository dapat di-test dengan database
- Helper functions dapat di-unit test
### ✅ Reusability
- **LaporanHelper** dapat digunakan di controller/service lain
- **TransaksiRepository** dapat digunakan untuk keperluan transaksi lain
- **Form Requests** dapat digunakan di route lain
### ✅ SOLID Principles
- **S** - Single Responsibility: Setiap class punya satu tanggung jawab
- **O** - Open/Closed: Mudah untuk extend tanpa modify existing code
- **L** - Liskov Substitution: Repository dapat di-substitute dengan implementasi lain
- **I** - Interface Segregation: Dependencies yang spesifik
- **D** - Dependency Inversion: Controller depend pada abstraction (Service), bukan concrete class
### ✅ Performance
- Caching logic tetap terjaga di Service layer
- Query optimization tetap di Repository layer
- No performance degradation dari refactoring
## 🔧 Cara Implementasi
### 1. Buat file-file baru:
```
app/
├── Http/
│ ├── Controllers/
│ │ └── LaporanController.php
│ └── Requests/
│ ├── DetailLaporanRequest.php
│ └── ExportLaporanRequest.php
├── Services/
│ └── LaporanService.php
├── Repositories/
│ └── TransaksiRepository.php
└── Helpers/
└── LaporanHelper.php
```
### 2. Register dependencies di Service Provider:
```php
// AppServiceProvider.php
public function register()
{
$this->app->bind(TransaksiRepository::class, TransaksiRepository::class);
$this->app->bind(LaporanHelper::class, LaporanHelper::class);
$this->app->bind(LaporanService::class, LaporanService::class);
}
```
### 3. Update routes (tidak ada perubahan):
```php
// Routes tetap sama, hanya implementasi internal yang berubah
Route::get('/laporan/ringkasan', [LaporanController::class, 'ringkasan']);
Route::get('/laporan/detail-per-produk', [LaporanController::class, 'detailPerProduk']);
Route::get('/laporan/detail-per-nampan', [LaporanController::class, 'detailPerNampan']);
Route::post('/laporan/export', [LaporanController::class, 'exportRingkasan']);
```
## 📊 Perbandingan Ukuran File
| File Original | Baris | File Baru | Baris | Pengurangan |
| --------------------- | ------- | ------------------------ | ------- | ----------- |
| LaporanController.php | ~600 | LaporanController.php | ~80 | 87% |
| | | LaporanService.php | ~180 | |
| | | TransaksiRepository.php | ~120 | |
| | | LaporanHelper.php | ~180 | |
| | | DetailLaporanRequest.php | ~60 | |
| | | ExportLaporanRequest.php | ~40 | |
| **Total** | **600** | **Total** | **660** | **+60** |
_Note: Sedikit penambahan baris karena struktur class yang lebih terorganisir dan dokumentasi yang lebih baik_
## 🚀 Langkah Selanjutnya (Optional)
1. **Interface Implementation**: Buat interface untuk Service dan Repository
2. **Unit Tests**: Tambahkan comprehensive unit tests untuk setiap layer
3. **API Documentation**: Update API documentation
4. **Caching Strategy**: Implement more sophisticated caching dengan Redis
5. **Query Optimization**: Review dan optimize database queries di Repository
## ⚠️ Catatan Penting
- **Backward Compatibility**: API endpoints dan response format tetap sama
- **Dependencies**: Pastikan semua dependencies di-register di Service Provider
- **Testing**: Lakukan thorough testing sebelum deploy ke production
- **Migration**: Bisa dilakukan secara bertahap jika diperlukan

View File

@ -276,6 +276,13 @@ php artisan backup:run
php artisan make:model ProductCategory -m
```
### Production
> Pastikan `.env.production` sudah ada.
```bash
docker compose --env-file .env.production up --build -d
```
---
## 📄 License

View File

@ -0,0 +1,88 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DetailNampanExport implements FromCollection, WithHeadings, WithTitle, WithStyles
{
private $data;
public function __construct($data)
{
$this->data = $data;
}
public function collection()
{
$collection = collect();
// Add individual nampan data
if (isset($this->data['nampan'])) {
foreach ($this->data['nampan'] as $item) {
$collection->push([
$item['nama_nampan'],
$item['jumlah_item_terjual'],
$item['berat_terjual'],
$item['pendapatan'],
]);
}
}
if (isset($this->data['rekap_harian'])) {
$rekap = $this->data['rekap_harian'];
$collection->push([
'REKAP TOTAL',
$rekap['total_item_terjual'],
$rekap['total_berat_terjual'],
$rekap['total_pendapatan'],
]);
}
return $collection;
}
public function headings(): array
{
return [
'Nama Nampan',
'Jumlah Item Terjual',
'Berat Terjual',
'Pendapatan'
];
}
public function title(): string
{
$filterInfo = $this->data['filter'] ?? [];
$tanggal = $filterInfo['tanggal'] ?? 'Unknown';
return "Detail Nampan {$tanggal}";
}
public function styles(Worksheet $sheet)
{
$styles = [
1 => ['font' => ['bold' => true]],
];
if (isset($this->data['rekap_harian'])) {
$lastRow = 1;
if (isset($this->data['nampan'])) {
$lastRow += count($this->data['nampan']);
}
$lastRow++;
$styles[$lastRow] = [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['argb' => 'FFE2E3E5'],
],
];
}
return $styles;
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DetailProdukExport implements FromCollection, WithHeadings, WithTitle, WithStyles
{
private $data;
public function __construct($data)
{
$this->data = $data;
}
public function collection()
{
$collection = collect();
// Add summary row first
if (isset($this->data['rekap_harian'])) {
$rekap = $this->data['rekap_harian'];
$collection->push([
'REKAP TOTAL',
$rekap['total_item_terjual'],
$rekap['total_berat_terjual'],
$rekap['total_pendapatan'],
]);
// Add empty row separator
$collection->push(['', '', '', '']);
}
// Add individual produk data
if (isset($this->data['produk'])) {
foreach ($this->data['produk'] as $item) {
$collection->push([
$item['nama_produk'],
$item['jumlah_item_terjual'],
$item['berat_terjual'],
$item['pendapatan'],
]);
}
}
return $collection;
}
public function headings(): array
{
return [
'Nama Produk',
'Jumlah Item Terjual',
'Berat Terjual',
'Pendapatan'
];
}
public function title(): string
{
$filterInfo = $this->data['filter'] ?? [];
$tanggal = $filterInfo['tanggal'] ?? 'Unknown';
return "Detail Produk {$tanggal}";
}
public function styles(Worksheet $sheet)
{
$styles = [
1 => ['font' => ['bold' => true]],
];
if (isset($this->data['rekap_harian'])) {
$lastRow = 1;
if (isset($this->data['nampan'])) {
$lastRow += count($this->data['nampan']);
}
$lastRow++;
$styles[$lastRow] = [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['argb' => 'FFE2E3E5'],
],
];
}
return $styles;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Exports;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class RingkasanExport implements FromCollection, WithHeadings, WithStyles
{
private $data;
public function __construct(iterable $data)
{
$this->data = $data;
}
public function collection(): Collection
{
$rows = collect();
foreach ($this->data as $item) {
$tanggal = $item['tanggal'] ?? '-';
$totalItem = $item['total_item'] ?? 0;
$totalBerat = $item['total_berat'] ?? '0 g';
$totalPendapatan = $item['total_pendapatan'] ?? 'Rp 0';
// Tambahkan detail sales per baris
foreach ($item['sales'] ?? [] as $sale) {
$rows->push([
'Tanggal' => $tanggal,
'Nama Sales' => $sale['nama'] ?? 'Sales Tidak Dikenal',
'Item Terjual' => $sale['item_terjual'] ?? 0,
'Berat' => $sale['berat'] ?? '-',
'Pendapatan' => $sale['pendapatan'] ?? '-',
]);
}
// Tambahkan baris total
$rows->push([
'Tanggal' => $tanggal,
'Nama Sales' => 'TOTAL',
'Item Terjual' => $totalItem,
'Berat' => $totalBerat,
'Pendapatan' => $totalPendapatan,
]);
$rows->push(['Tanggal' => '', 'Nama Sales' => '', 'Item Terjual' => '', 'Berat' => '', 'Pendapatan' => '']);
}
return $rows;
}
public function headings(): array
{
return [
'Tanggal',
'Nama Sales',
'Item Terjual',
'Berat',
'Pendapatan',
];
}
public function styles(Worksheet $sheet)
{
return [
1 => ['font' => ['bold' => true]],
];
}
}

View File

@ -0,0 +1,212 @@
<?php
namespace App\Helpers;
use App\Models\Nampan;
use App\Models\Sales;
use App\Models\Produk;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class LaporanHelper
{
public const CURRENCY_SYMBOL = 'Rp ';
public const WEIGHT_UNIT = ' g';
public const DEFAULT_DISPLAY = '-';
public function calculateTotals(Collection $data): array
{
// Asumsi $data punya raw numeric (int/float)
$totalPendapatan = $data->sum('pendapatan'); // Raw float
$totalItemTerjual = $data->sum('jumlah_item_terjual'); // Int
$totalBeratTerjual = $data->sum('berat_terjual'); // Float
return [
'total_item_terjual' => $totalItemTerjual,
'total_berat_terjual' => $this->formatWeight($totalBeratTerjual),
'total_pendapatan' => $this->formatCurrency($totalPendapatan),
];
}
public function getAllNampanWithPagination(int $page, int $perPage): LengthAwarePaginator
{
$semuaPosisi = DB::table('item_transaksis')
->select('posisi_asal')
->distinct()
->pluck('posisi_asal')
->sort()
->values();
$offset = ($page - 1) * $perPage;
$itemsForCurrentPage = $semuaPosisi->slice($offset, $perPage);
return new LengthAwarePaginator(
$itemsForCurrentPage,
$semuaPosisi->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
public function mapProductsWithSalesData($paginatedData, Collection $salesData): Collection
{
return $paginatedData->getCollection()->map(function ($item) use ($salesData) {
if ($salesData->has($item->id)) {
$dataTerjual = $salesData->get($item->id);
return [
'nama_produk' => $item->nama,
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual, // Selalu int
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
];
}
return [ // Untuk kosong, return dengan 0 (akan difilter nanti)
'nama_produk' => $item->nama,
'jumlah_item_terjual' => 0,
'berat_terjual' => self::DEFAULT_DISPLAY,
'pendapatan' => self::DEFAULT_DISPLAY,
];
});
}
public function mapNampanWithSalesData($paginatedData, Collection $salesData): Collection
{
return $paginatedData->getCollection()->map(function ($item) use ($salesData) {
if ($salesData->has($item)) {
$dataTerjual = $salesData->get($item);
return [
'nama_nampan' => $item, // sekarang langsung string posisi
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
];
}
return [
'nama_nampan' => $item,
'jumlah_item_terjual' => self::DEFAULT_DISPLAY,
'berat_terjual' => self::DEFAULT_DISPLAY,
'pendapatan' => self::DEFAULT_DISPLAY,
];
});
}
public function buildProdukFilterInfo(Carbon $startDate, Carbon $endDate, array $params): array
{
$filterInfo = [
'periode' => "{$startDate->isoFormat('D MMMM Y')} - {$endDate->isoFormat('D MMMM Y')}",
'nama_sales' => null,
'nampan' => null, // Default null
'nama_pembeli' => $params['nama_pembeli'] ?? null,
];
if (!empty($params['sales_id'])) {
$sales = Sales::find($params['sales_id']);
$filterInfo['nama_sales'] = $sales?->nama;
}
if (isset($params['nampan_id'])) {
if ($params['nampan_id'] === -1) {
$filterInfo['nampan'] = 'Brankas';
} elseif ($params['nampan_id'] > 0) {
$nampan = Nampan::find($params['nampan_id']);
$filterInfo['nampan'] = $nampan?->nama;
} else { // 0: Semua
$filterInfo['nampan'] = 'Semua Nampan';
}
}
return $filterInfo;
}
public function buildNampanFilterInfo(Carbon $startDate, Carbon $endDate, array $params): array
{
$filterInfo = [
'periode' => "{$startDate->isoFormat('D MMMM Y')} - {$endDate->isoFormat('D MMMM Y')}", // FIXED: Range
'nama_sales' => null,
'produk' => null,
'nama_pembeli' => $params['nama_pembeli'] ?? null,
];
if (!empty($params['sales_id'])) {
$sales = Sales::find($params['sales_id']);
$filterInfo['nama_sales'] = $sales?->nama;
}
if (!empty($params['produk_id'])) {
$produk = Produk::find($params['produk_id']);
$filterInfo['produk'] = $produk?->nama;
}
return $filterInfo;
}
public function buildPaginationInfo($paginatedData): array
{
return [
'current_page' => $paginatedData->currentPage(),
'last_page' => $paginatedData->lastPage(),
'per_page' => $paginatedData->perPage(),
'total' => $paginatedData->total(),
'from' => $paginatedData->firstItem(),
'to' => $paginatedData->lastItem(),
];
}
public function hitungDataSales(Collection $transaksisPerSales): array
{
$itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count());
// UBAH BAGIAN INI: Hapus ->item dari path relasi
$beratTerjual = $transaksisPerSales->sum(
fn($t) => $t->itemTransaksi->sum(fn($it) => $it->produk?->berat ?? 0)
);
$pendapatan = $transaksisPerSales->sum('total_harga');
return [
'nama' => $transaksisPerSales->first()->nama_sales,
'item_terjual' => $itemTerjual,
'berat_terjual_raw' => $beratTerjual,
'pendapatan_raw' => $pendapatan,
];
}
public function defaultSalesData(string $namaSales): array
{
return [
'nama' => $namaSales,
'item_terjual' => 0,
'berat_terjual_raw' => 0,
'pendapatan_raw' => 0,
];
}
public function formatSalesDataValues(Collection $salesData): Collection
{
return $salesData->map(function ($sale) {
$sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : self::DEFAULT_DISPLAY;
$sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ?
$this->formatWeight($sale['berat_terjual_raw']) : self::DEFAULT_DISPLAY;
$sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ?
$this->formatCurrency($sale['pendapatan_raw']) : self::DEFAULT_DISPLAY;
unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']);
return $sale;
});
}
public function formatCurrency(float $amount): string
{
return self::CURRENCY_SYMBOL . number_format($amount, 2, ',', '.');
}
public function formatWeight(float $weight): string
{
return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'nama' => 'required',
'password' => 'required',
]);
// cari user berdasarkan nama
$user = User::where('nama', $request->nama)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json([
'message' => 'Nama atau password salah'
], 401);
}
// buat token Sanctum
$token = $user->createToken('auth_token')->plainTextToken;
$redirectUrl = $user->role === 'owner' ? '/brankas' : '/kasir';
return response()->json([
'message' => 'Login berhasil',
'user' => $user,
'token' => $token,
'redirect' => $redirectUrl,
'role' => $user->role
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Logout berhasil'
]);
}
}

View File

@ -10,8 +10,12 @@ class FotoSementaraController extends Controller
{
public function upload(Request $request)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$request->validate([
'id_produk' => 'required|exists:produk,id',
'foto' => 'required|image|mimes:jpg,jpeg,png|max:2048',
]);
@ -19,15 +23,20 @@ class FotoSementaraController extends Controller
$url = asset('storage/' . $path);
$foto = FotoSementara::create([
'id_produk' => $request->id_produk,
'id_user' => $user->id,
'url' => $url,
]);
return response()->json(['message' => 'Foto berhasil disimpan'], 201);
return response()->json($foto, 201);
}
public function hapus($id)
public function hapus(Request $request, int $id)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$foto = FotoSementara::findOrFail($id);
// Extract the relative path from the URL
@ -42,15 +51,25 @@ class FotoSementaraController extends Controller
return response()->json(['message' => 'Foto berhasil dihapus']);
}
public function getAll($user_id)
public function getAll(Request $request)
{
$data = FotoSementara::where('id_user', $user_id);
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$data = FotoSementara::where('id_user', $user->id)->get();
return response()->json($data);
}
public function reset($user_id)
public function reset(Request $request)
{
FotoSementara::where('id_user', $user_id)->delete();
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
FotoSementara::where('id_user', $user->id)->delete();
return response()->json(['message' => 'Foto sementara berhasil direset']);
}
}

View File

@ -23,15 +23,16 @@ class ItemController extends Controller
public function store(Request $request)
{
$validated = $request->validate([
'id_produk' => 'required|in:produks.id',
'id_nampan' => 'nullable|in:nampans.id'
'id_produk' => 'required',
'id_nampan' => 'nullable'
],[
'id_produk' => 'Id produk tidak valid.',
'id_nampan' => 'Id nampan tidak valid'
]);
$item = Item::create($validated);
$item->load('nampan');
return response()->json([
'message' => 'Item berhasil dibuat',
'data' => $item
@ -53,8 +54,8 @@ class ItemController extends Controller
public function update(Request $request, int $id)
{
$validated = $request->validate([
'id_produk' => 'required|in:produks.id',
'id_nampan' => 'nullable|in:nampans.id'
'id_produk' => 'required|exists:produks,id',
'id_nampan' => 'nullable|exists:nampans,id'
],[
'id_produk' => 'Id produk tidak valid.',
'id_nampan' => 'Id nampan tidak valid'
@ -82,7 +83,7 @@ class ItemController extends Controller
// custom methods
public function brankasItem(){
$items = Item::with('produk.foto','nampan')->whereNull('id_nampan')->belumTerjual()->get();
$items = Item::with('produk.foto','nampan')->whereNull('id_nampan')->get();
return response()->json($items);
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers;
use App\Models\Kategori;
use Illuminate\Http\Request;
class KategoriController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return response()->json(
Kategori::withCount('produk')->get()
);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'nama' => 'required|string|max:50',
],
[
'nama' => 'Nama kategori harus diisi.'
]);
Kategori::create($validated);
return response()->json([
'message' => 'Kategori berhasil dibuat'
],201);
}
/**
* Display the specified resource.
*/
public function show(int $id)
{
return response()->json(
Kategori::with('items.produk.foto')->find($id)
);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, int $id)
{
$validated = $request->validate([
'nama' => 'required|string|max:50',
],
[
'nama' => 'Nama Kategori harus diisi.'
]);
$Kategori = Kategori::findOrFail($id);
$Kategori->update($validated);
return response()->json([
'message' => 'Kategori berhasil diupdate'
],200);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(int $id)
{
Kategori::findOrFail($id)->delete();
return response()->json([
'message' => 'Kategori berhasil dihapus'
], 200);
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers;
use App\Services\LaporanService;
use App\Http\Requests\DetailLaporanRequest;
use App\Http\Requests\ExportLaporanRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class LaporanController extends Controller
{
private LaporanService $laporanService;
public function __construct(LaporanService $laporanService)
{
$this->laporanService = $laporanService;
}
/**
* Endpoint untuk ringkasan laporan dengan caching
*/
public function ringkasan(Request $request)
{
try {
$filter = $request->query('filter', 'bulan');
$page = (int) $request->query('page', 1);
// Validasi filter
if (!in_array($filter, ['hari', 'bulan'])) {
return response()->json(['error' => 'Filter harus "hari" atau "bulan"'], 400);
}
$data = $this->laporanService->getRingkasan($filter, $page);
return response()->json($data);
} catch (\Exception $e) {
Log::error('Error in ringkasan method: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data'], 500);
}
}
/**
* Detail laporan per produk
*/
public function detailPerProduk(DetailLaporanRequest $request)
{
try {
$data = $this->laporanService->getDetailPerProduk($request->validated());
return response()->json($data);
} catch (\Exception $e) {
Log::error('Error in detail PerProduk method: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data produk'], 500);
}
}
/**
* Detail laporan per nampan
*/
public function detailPerNampan(DetailLaporanRequest $request)
{
try {
$data = $this->laporanService->getDetailPerNampan($request->validated());
return response()->json($data);
} catch (\Exception $e) {
Log::error('Error in detailPerNampan method: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data nampan' . $e->getMessage()], 500);
}
}
/**
* Export laporan ringkasan
*/
public function exportRingkasan(ExportLaporanRequest $request)
{
try {
return $this->laporanService->exportRingkasan($request->validated());
} catch (\Exception $e) {
Log::error('Error in exportRingkasan method: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
}
}
public function exportDetailNampan(Request $request)
{
try {
$validated = $request->validate([
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'format' => 'required|string|in:pdf,xlsx,csv',
'page' => 'required|integer|min:1',
'sales_id' => 'nullable|integer|exists:sales,id',
'produk_id' => 'nullable|integer|exists:produks,id',
'nama_pembeli' => 'nullable|string|max:255',
]);
return $this->laporanService->exportPerNampan($validated);
} catch (\Exception $e) {
Log::error('Error in export per nampan: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
}
}
public function exportDetailProduk(Request $request)
{
try {
$validated = $request->validate([
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'format' => 'required|string|in:pdf,xlsx,csv',
'page' => 'required|integer|min:1',
'sales_id' => 'nullable|integer|exists:sales,id',
'nampan_id' => 'nullable|integer',
'nama_pembeli' => 'nullable|string|max:255',
]);
return $this->laporanService->exportPerProduk($validated);
} catch (\Exception $e) {
Log::error('Error in export per produk: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
}
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Nampan;
use App\Models\Item;
use Illuminate\Http\Request;
class NampanController extends Controller
@ -13,7 +14,7 @@ class NampanController extends Controller
public function index()
{
return response()->json(
Nampan::withCount('items')->get()
Nampan::with('items.produk.foto', 'items.produk.kategori')->withCount('items')->get()
);
}
@ -23,10 +24,12 @@ class NampanController extends Controller
public function store(Request $request)
{
$validated = $request->validate([
'nama' => 'required|string|max:100',
'nama' => 'required|string|max:10|unique:nampans,nama',
],
[
'nama' => 'Nama nampan harus diisi.'
'nama.required' => 'Nama nampan harus diisi.',
'nama.unique' => 'Nampan dengan nama yang sama sudah ada.',
'nama.max' => 'Nama nampan maksimal 10 karakter.'
]);
Nampan::create($validated);
@ -43,7 +46,7 @@ class NampanController extends Controller
public function show(int $id)
{
return response()->json(
Nampan::with('items')->find($id)
Nampan::with('items.produk.foto')->find($id)
);
}
@ -53,7 +56,7 @@ class NampanController extends Controller
public function update(Request $request, int $id)
{
$validated = $request->validate([
'nama' => 'required|string|max:100',
'nama' => 'required|string|max:10|unique:nampans,nama,'.$id,
],
[
'nama' => 'Nama nampan harus diisi.'
@ -85,4 +88,14 @@ class NampanController extends Controller
'message' => 'Nampan berhasil dihapus'
], 204);
}
public function kosongkan()
{
Item::query()->update(['id_nampan' => null]);
return response()->json([
'message' => 'Semua nampan berhasil dikosongkan'
], 200);
}
}

View File

@ -17,7 +17,7 @@ class ProdukController extends Controller
public function index()
{
return response()->json(
Produk::withCount('items')->with('foto')->get()
Produk::withCount('items')->with('foto', 'kategori')->get()
);
}
@ -26,39 +26,36 @@ class ProdukController extends Controller
*/
public function store(Request $request)
{
$validated = $request->validate([
'nama' => 'required|string|max:100',
'kategori' => 'required|in:cincin,gelang,kalung,anting',
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$validated = $request->validate(
[
'nama' => 'required|string|max:100|unique:produks,nama',
'id_kategori' => 'required|exists:kategoris,id',
'berat' => 'required|numeric',
'kadar' => 'required|integer',
'harga_per_gram' => 'required|numeric',
'harga_jual' => 'required|numeric',
'id_user' => 'nullable|exists:users,id', // untuk mengambil foto sementara
],
[
'nama.required' => 'Nama produk harus diisi.',
'kategori.in' => 'Kategori harus salah satu dari cincin, gelang, kalung, atau anting.',
'nama.unique' => 'Nama produk sudah digunakan.',
'id_kategori' => 'Kategori tidak valid.',
'berat.required' => 'Berat harus diisi.',
'kadar.required' => 'Kadar harus diisi',
'harga_per_gram.required' => 'Harga per gram harus diisi',
'harga_jual.required' => 'Harga jual harus diisi'
]);
'kadar.required' => 'Kadar harus diisi.',
'harga_per_gram.required' => 'Harga per gram harus diisi.',
'harga_jual.required' => 'Harga jual harus diisi.'
]
);
DB::beginTransaction();
try {
// Create produk
$produk = Produk::create([
'nama' => $validated['nama'],
'kategori' => $validated['kategori'],
'berat' => $validated['berat'],
'kadar' => $validated['kadar'],
'harga_per_gram' => $validated['harga_per_gram'],
'harga_jual' => $validated['harga_jual'],
]);
$produk = Produk::create($validated);
// Pindahkan foto sementara ke foto permanen jika ada
if (isset($validated['id_user'])) {
$fotoSementara = FotoSementara::where('id_user', $validated['id_user'])->get();
$fotoSementara = FotoSementara::where('id_user', $user->id)->get();
foreach ($fotoSementara as $fs) {
Foto::create([
@ -66,10 +63,8 @@ class ProdukController extends Controller
'url' => $fs->url
]);
// Hapus foto sementara setelah dipindah
$fs->delete();
}
}
DB::commit();
@ -77,7 +72,6 @@ class ProdukController extends Controller
'message' => 'Produk berhasil dibuat',
'data' => $produk->load('foto')
], 201);
} catch (\Exception $e) {
DB::rollback();
return response()->json([
@ -92,7 +86,28 @@ class ProdukController extends Controller
*/
public function show(int $id)
{
$produk = Produk::with('foto', 'items')->findOrFail($id);
$produk = Produk::with('foto', 'items', 'kategori')->findOrFail($id);
return response()->json($produk);
}
/**
* Get the specified resource to edit.
*/
public function edit(Request $request, int $id)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$produk = Produk::with('foto', 'kategori')->findOrFail($id);
$foto_sementara = [];
foreach ($produk->foto as $foto) {
$foto_sementara[] = FotoSementara::create([
'id_user' => $user->id,
'url' => $foto->url
]);
}
return response()->json($produk);
}
@ -101,24 +116,30 @@ class ProdukController extends Controller
*/
public function update(Request $request, int $id)
{
$validated = $request->validate([
'nama' => 'required|string|max:100',
'kategori' => 'required|in:cincin,gelang,kalung,anting',
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$validated = $request->validate(
[
'nama' => 'required|string|max:100|unique:produks,nama,' . $id,
'id_kategori' => 'required|exists:kategoris,id',
'berat' => 'required|numeric',
'kadar' => 'required|integer',
'harga_per_gram' => 'required|numeric',
'harga_jual' => 'required|numeric',
'id_user' => 'nullable|exists:users,id', // untuk mengambil foto sementara baru
'hapus_foto_lama' => 'nullable|boolean', // flag untuk menghapus foto lama
],
[
'nama.required' => 'Nama produk harus diisi.',
'kategori.in' => 'Kategori harus salah satu dari cincin, gelang, kalung, atau anting.',
'nama.unique' => 'Nama produk sudah digunakan.',
'id_kategori' => 'Kategori tidak valid.',
'berat.required' => 'Berat harus diisi.',
'kadar.required' => 'Kadar harus diisi',
'harga_per_gram.required' => 'Harga per gram harus diisi',
'harga_jual.required' => 'Harga jual harus diisi'
]);
'harga_jual.required' => 'Harga jual harus diisi',
]
);
DB::beginTransaction();
try {
@ -127,28 +148,28 @@ class ProdukController extends Controller
// Update data produk
$produk->update([
'nama' => $validated['nama'],
'kategori' => $validated['kategori'],
'id_kategori' => $validated['id_kategori'],
'berat' => $validated['berat'],
'kadar' => $validated['kadar'],
'harga_per_gram' => $validated['harga_per_gram'],
'harga_jual' => $validated['harga_jual'],
]);
// Hapus foto lama jika diminta
if (isset($validated['hapus_foto_lama']) && $validated['hapus_foto_lama']) {
// Hapus foto lama
foreach ($produk->foto as $foto) {
// Hapus file fisik
// Hapus file fisik jika memungkinkan
try {
$relativePath = str_replace(asset('storage') . '/', '', $foto->url);
if (Storage::disk('public')->exists($relativePath)) {
Storage::disk('public')->delete($relativePath);
}
} catch (\Exception $e) {
// Maklum Pak, soalnya kadang url aja, ga ada file fisiknya #Bagas
}
$foto->delete();
}
}
// Tambahkan foto baru dari foto sementara jika ada
if (isset($validated['id_user'])) {
$fotoSementara = FotoSementara::where('id_user', $validated['id_user'])->get();
$fotoSementara = FotoSementara::where('id_user', $user->id)->get();
foreach ($fotoSementara as $fs) {
Foto::create([
@ -156,10 +177,8 @@ class ProdukController extends Controller
'url' => $fs->url
]);
// Hapus foto sementara setelah dipindah
$fs->delete();
}
}
DB::commit();
@ -167,7 +186,6 @@ class ProdukController extends Controller
'message' => 'Produk berhasil diubah',
'data' => $produk->load('foto')
], 200);
} catch (\Exception $e) {
DB::rollback();
return response()->json([
@ -195,7 +213,7 @@ class ProdukController extends Controller
$foto->delete();
}
// Hapus produk (soft delete)
$produk->items()->delete();
$produk->delete();
DB::commit();
@ -203,7 +221,6 @@ class ProdukController extends Controller
return response()->json([
'message' => 'Produk berhasil dihapus.'
], 200);
} catch (\Exception $e) {
DB::rollback();
return response()->json([

View File

@ -82,7 +82,9 @@ class SalesController extends Controller
*/
public function destroy(int $id)
{
Sales::findOrFail($id)->delete();
$sales = Sales::findOrFail($id);
$sales->transaksi()->update(['id_sales' => null]);
$sales->delete();
return response()->json([
'message' => 'Sales berhasil dihapus'
], 200);

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\Transaksi;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
class StrukController extends Controller
{
public function cetak(int $id)
{
try {
$data = Transaksi::with(['itemTransaksi.produk.foto', 'sales'])
->find($id);
if (!$data) {
return response()->json(['error'=>'Transaksi tidak ditemukan'], 404);
}
// Debug: Let's see what data structure we have
// dd([
// 'transaksi' => $data->toArray(),
// 'item_count' => $data->itemTransaksi->count(),
// 'has_sales' => $data->sales ? true : false,
// ]);
// After debugging, uncomment this:
$pdf = Pdf::loadView('exports.struk', $data->toArray())
->setPaper([0, 0, 1224 * 0.75, 528 * 0.75], 'landscape')
->setOptions([
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => true,
'defaultFont' => 'DejaVu Sans'
]);
$filename = 'Struk_' . $data->kode_transaksi . '.pdf';
return $pdf->download($filename);
} catch (\Exception $e) {
return response()->json([
'error' => 'Debug Error',
'message' => $e->getMessage(),
'line' => $e->getLine(),
'file' => $e->getFile()
], 500);
}
}
}

View File

@ -5,79 +5,170 @@ namespace App\Http\Controllers;
use App\Models\Transaksi;
use App\Models\ItemTransaksi;
use App\Models\Item;
use App\Models\Sales;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TransaksiController extends Controller
{
// List semua transaksi
public function index()
public function index(Request $request)
{
$transaksi = Transaksi::with(['kasir', 'sales', 'items.item.produk'])->get();
return response()->json($transaksi);
$limit = $request->query('limit', 10);
$page = $request->query('page', 1);
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$search = $request->query('search');
$query = Transaksi::with(['kasir', 'sales', 'itemTransaksi.produk']);
// Filter berdasarkan interval tanggal
if ($startDate && $endDate) {
$query->whereBetween('created_at', [
Carbon::parse($startDate)->startOfDay(),
Carbon::parse($endDate)->endOfDay()
]);
}
// Default: hanya transaksi hari ini
elseif (!$startDate && !$endDate) {
$today = Carbon::today();
$query->whereDate('created_at', $today);
}
// Detail transaksi by ID
// Search berdasarkan kode transaksi atau nama pelanggan
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('kode_transaksi', 'like', '%' . $search . '%')
->orWhere('nama_pembeli', 'like', '%' . $search . '%');
});
}
$query->latest();
$transaksi = $query->paginate($limit, ['*'], 'page', $page);
// Transform data
$transaksi->getCollection()->transform(function ($transaksi) {
$transaksi->total_items = $transaksi->itemTransaksi->count();
$transaksi->tanggal = $transaksi->created_at->format('d/m/Y H:i');
$transaksi->pendapatan = $transaksi->total_harga ?? 0;
return $transaksi;
});
return response()->json([
'data' => $transaksi->items(),
'pagination' => [
'current_page' => $transaksi->currentPage(),
'last_page' => $transaksi->lastPage(),
'per_page' => $transaksi->perPage(),
'total' => $transaksi->total(),
'from' => $transaksi->firstItem(),
'to' => $transaksi->lastItem(),
]
]);
}
// Detail transaksi
public function show($id)
{
$transaksi = Transaksi::with(['kasir', 'sales', 'items.item.produk'])->findOrFail($id);
$transaksi = Transaksi::with([
'kasir',
'sales',
'itemTransaksi.produk',
'itemTransaksi' => function ($query) {
$query->orderBy('created_at', 'asc');
}
])->findOrFail($id);
$transaksi->total_items = $transaksi->itemTransaksi->count();
$transaksi->tanggal = $transaksi->created_at->format('d/m/Y H:i');
$transaksi->pendapatan = $transaksi->total_harga ?? 0;
return response()->json($transaksi);
}
// Membuat transaksi baru
public function store(Request $request)
{
$kasir = $request->user();
if (!$kasir) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'id_kasir' => 'required|exists:akun,id',
'id_sales' => 'nullable|exists:sales,id',
'nama_sales' => 'nullable|string',
'no_hp' => 'nullable|string',
'alamat' => 'nullable|string',
'ongkos_bikin' => 'nullable|numeric',
'id_sales' => 'required|exists:sales,id',
'nama_pembeli' => 'required|string',
'no_hp' => 'required|string',
'alamat' => 'required|string',
'ongkos_bikin' => 'nullable|numeric|min:0',
'total_harga' => 'required|numeric',
'items' => 'required|array',
'items.*.id_item' => 'required|exists:item,id',
'items.*.kode_item' => 'required|exists:items,id|numeric',
'items.*.harga_deal' => 'required|numeric',
]);
DB::beginTransaction();
try {
$sales = Sales::find($request->id_sales);
$transaksi = Transaksi::create([
'id_kasir' => $request->id_kasir,
'kode_transaksi' => 'belum pak',
'id_kasir' => $kasir->id,
'id_sales' => $request->id_sales,
'nama_sales' => $request->nama_sales,
'nama_sales' => $sales->nama ?? 'N/A',
'nama_pembeli' => $request->nama_pembeli,
'no_hp' => $request->no_hp,
'alamat' => $request->alamat,
'ongkos_bikin' => $request->ongkos_bikin,
'ongkos_bikin' => $request->ongkos_bikin ?? 0,
'total_harga' => $request->total_harga,
]);
foreach ($request->items as $it) {
// TODO: ubah saat transaksi pake kode_item
// $item = Item::where('kode_item', $it['kode_item'])->first();
// if (!$item) {
// throw new \Exception("Item dengan kode_item {$it['kode_item']} tidak ditemukan.");
// }
$item = Item::where('id',$it['kode_item'])->with('produk')->first();
ItemTransaksi::create([
'id_transaksi' => $transaksi->id,
'id_item' => $it['id_item'],
'id_produk' => $item->produk->id,
'harga_deal' => $it['harga_deal'],
'posisi_asal' => $it['posisi'],
]);
Item::where('id', $it['id_item'])->update(['is_sold' => true]);
$item->forceDelete();
}
DB::commit();
return response()->json($transaksi->load('items'), 201);
return response()->json(
$transaksi->load(['itemTransaksi.produk.foto', 'kasir', 'sales']),
201
);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['error' => $e->getMessage()], 500);
return response()->json([
'error' => $e->getMessage(),
'trace' => $e->getTrace()
], 500);
}
}
// Update transaksi
public function update(Request $request, $id)
{
$transaksi = Transaksi::findOrFail($id);
$transaksi->update($request->only([
'id_sales', 'nama_sales', 'no_hp', 'alamat', 'ongkos_bikin', 'total_harga'
'id_sales',
'nama_sales',
'no_hp',
'alamat',
'ongkos_bikin',
'total_harga'
]));
return response()->json($transaksi);

View File

@ -19,14 +19,21 @@ class UserController extends Controller
public function store(Request $request)
{
$request->validate([
'nama' => 'required|nama|unique:users',
'nama' => 'required|string|unique:users',
'password' => 'required|min:6',
'role' => 'required|in:owner,kasir',
], [
'nama.require' => 'Nama wajib diisi',
'nama.unique' => 'Nama sudah digunakan',
'password.require' => 'Password wajib diisi',
'password.min' => 'Password minimal 6 karakter',
'role.require' => 'Role wajib diisi',
'role.in' => 'Role harus owner atau kasir',
]);
User::create([
'nama' => $request->nama,
'password' => bcrypt($request->password),
'password' => $request->password,
'role' => $request->role,
]);
@ -41,22 +48,32 @@ class UserController extends Controller
$user = User::findOrFail($id);
$request->validate([
'nama' => 'required|nama|unique:users,nama,' . $id,
'password' => 'required|min:6',
'nama' => 'required|string|unique:users,nama,' . $id,
'password' => 'nullable|min:6',
'role' => 'required|in:owner,kasir',
], [
'nama.require' => 'Nama wajib diisi',
'nama.unique' => 'Nama sudah digunakan',
'password.min' => 'Password minimal 6 karakter',
'role.require' => 'Role wajib diisi',
'role.in' => 'Role harus owner atau kasir',
]);
$user->update([
$data = [
'nama' => $request->nama,
'password' => $request->password,
'role' => $request->role,
]);
];
return response()->json([
'message' => 'User berhasil diupdate'
],200);
if ($request->filled('password')) {
$data['password'] = $request->password;
}
$user->update($data);
return response()->json(['message' => 'User berhasil diupdate', 'user' => $user], 200);
}
public function destroy($id)
{
$user = User::findOrFail($id);

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoleMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, ...$roles): Response
{
// cek apakah user login
if (!$request->user()) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
// cek role user
if (!in_array($request->user()->role, $roles)) {
return response()->json(['message' => 'Forbidden'], 403);
}
return $next($request);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DetailLaporanRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'sales_id' => 'nullable|integer|exists:sales,id',
'nampan_id' => 'nullable|integer',
'produk_id' => 'nullable|integer|exists:produks,id',
'nama_pembeli' => 'nullable|string|max:255',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'end_date.after_or_equal' => 'Tanggal akhir harus sama atau setelah tanggal mulai.',
'sales_id.exists' => 'Sales tidak ditemukan',
'produk_id.exists' => 'Produk tidak ditemukan',
'nama_pembeli.max' => 'Nama pembeli maksimal 255 karakter',
'page.min' => 'Page minimal 1',
'per_page.min' => 'Per page minimal 1',
'per_page.max' => 'Per page maksimal 100',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'page' => $this->query('page', 1),
'per_page' => $this->query('per_page', 15),
]);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ExportLaporanRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'filter' => 'required|in:hari,bulan',
'format' => 'required|in:pdf,xlsx,csv',
'page' => 'nullable',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'filter.required' => 'Filter harus diisi',
'filter.in' => 'Filter harus berupa "hari" atau "bulan"',
'format.required' => 'Format export harus diisi',
'format.in' => 'Format export harus berupa "pdf", "xlsx", atau "csv"',
];
}
}

View File

@ -15,6 +15,8 @@ class Foto extends Model
'url',
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
public function produk()
{
return $this->belongsTo(Produk::class, 'id_produk');

View File

@ -10,4 +10,6 @@ class FotoSementara extends Model
'id_user',
'url',
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
}

View File

@ -1,38 +1,56 @@
<?php
namespace App\Models;
use App\Models\itemTransaksi;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Item extends Model
{
use HasFactory;
protected $table = 'items';
protected $fillable = [
'id_produk',
'id_nampan',
'is_sold',
'kode_item',
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
// ✅ Auto-generate kode_item setiap kali create
protected static function boot()
{
parent::boot();
static::creating(function ($item) {
$prefix = 'TMJC';
$date = now()->format('Ymd');
// Cari item terakhir yg dibuat hari ini
$lastItem = self::whereDate('created_at', now()->toDateString())
->orderBy('id', 'desc')
->first();
$number = 1;
if ($lastItem && $lastItem->kode_item) {
// Ambil 4 digit terakhir dari kode_item
$lastNumber = intval(substr($lastItem->kode_item, -4));
$number = $lastNumber + 1;
}
// Format: ITM202509090001
$item->kode_item = $prefix . $date . str_pad($number, 4, '0', STR_PAD_LEFT);
});
}
public function produk()
{
return $this->belongsTo(Produk::class, 'id_produk');
}
public function scopeBelumTerjual($query)
{
return $query->where('is_sold', false);
}
public function nampan()
{
return $this->belongsTo(Nampan::class, 'id_nampan');
}
public function itemTransaksi()
{
return $this->hasMany(ItemTransaksi::class, 'id_item');
}
}

View File

@ -12,17 +12,20 @@ class ItemTransaksi extends Model
protected $fillable = [
'id_transaksi',
'id_item',
'harga_deal'
'id_produk',
'harga_deal',
'posisi_asal'
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
public function transaksi()
{
return $this->belongsTo(Transaksi::class, 'id_transaksi');
}
public function item()
public function produk()
{
return $this->belongsTo(Item::class, 'id_item');
return $this->belongsTo(Produk::class, 'id_produk');
}
}

22
app/Models/Kategori.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Kategori extends Model
{
/** @use HasFactory<\Database\Factories\KategoriFactory> */
use HasFactory, SoftDeletes;
protected $fillable = ['nama'];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
public function produk()
{
return $this->hasMany(Produk::class, 'id_kategori');
}
}

View File

@ -13,9 +13,19 @@ class Nampan extends Model
protected $fillable = [
'nama'
];
protected $appends = ['berat_total'];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
public function items()
{
return $this->hasMany(Item::class, 'id_nampan');
}
public function getBeratTotalAttribute()
{
return $this->items()
->join('produks', 'items.id_produk', '=', 'produks.id')
->sum('produks.berat');
}
}

View File

@ -12,13 +12,15 @@ class Produk extends Model
protected $fillable = [
'nama',
'kategori',
'id_kategori',
'berat',
'kadar',
'harga_per_gram',
'harga_jual',
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
public function items()
{
return $this->hasMany(Item::class, 'id_produk');
@ -28,4 +30,9 @@ class Produk extends Model
{
return $this->hasMany(Foto::class, 'id_produk');
}
public function kategori()
{
return $this->belongsTo(Kategori::class, 'id_kategori');
}
}

View File

@ -16,6 +16,8 @@ class Sales extends Model
'alamat'
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
public function transaksi()
{
return $this->hasMany(Transaksi::class, 'id_sales');

View File

@ -9,10 +9,13 @@ class Transaksi extends Model
{
/** @use HasFactory<\Database\Factories\TransaksiFactory> */
use HasFactory;
protected $fillable = [
'kode_transaksi',
'id_kasir',
'id_sales',
'nama_sales',
'nama_pembeli',
'no_hp',
'alamat',
'ongkos_bikin',
@ -20,6 +23,24 @@ class Transaksi extends Model
'created_at',
];
protected $hidden = ['updated_at', 'deleted_at'];
protected static function boot()
{
parent::boot();
static::created(function ($transaksi) {
if (!$transaksi->kode_transaksi || $transaksi->kode_transaksi === 'belum pak') {
$prefix = "TRS";
$date = $transaksi->created_at->format('Ymd');
$number = str_pad($transaksi->id, 4, '0', STR_PAD_LEFT);
$transaksi->kode_transaksi = $prefix . $date . $number;
$transaksi->save();
}
});
}
public function kasir()
{
return $this->belongsTo(User::class, 'id_kasir');
@ -34,14 +55,4 @@ class Transaksi extends Model
{
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
}
public function items()
{
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
}
public function foto ()
{
return $this->hasMany(Foto::class, 'id_produk');
}
}

View File

@ -6,11 +6,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
@ -45,4 +47,9 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function getAuthIdentifierName()
{
return 'id';
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Repositories;
use App\Models\Transaksi;
use App\Helpers\LaporanHelper;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Support\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
class TransaksiRepository
{
private const DAILY_PER_PAGE = 7;
private const MONTHLY_PER_PAGE = 12;
private const PAGINATION_DAYS_LIMIT = 365;
private LaporanHelper $helper;
public function __construct(LaporanHelper $helper)
{
$this->helper = $helper;
}
public function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
{
$perPage = self::DAILY_PER_PAGE;
if ($limitPagination) {
$endDate = Carbon::today()->subDays(($page - 1) * $perPage);
$startDate = $endDate->copy()->subDays($perPage - 1);
$totalHariUntukPaginasi = self::PAGINATION_DAYS_LIMIT;
} else {
$endDate = Carbon::today();
$startDate = $endDate->copy()->subYear()->addDay();
$totalHariUntukPaginasi = $endDate->diffInDays($startDate) + 1;
}
$transaksis = Transaksi::with(['itemTransaksi.produk'])
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->orderBy('created_at', 'desc')
->get();
$transaksisByDay = $transaksis->groupBy(function ($transaksi) {
return Carbon::parse($transaksi->created_at)->format('Y-m-d');
});
$period = CarbonPeriod::create($startDate, $endDate);
$laporan = [];
foreach ($period as $date) {
$dateString = $date->format('Y-m-d');
$tanggalFormatted = $date->isoFormat('dddd, D MMMM Y');
if (isset($transaksisByDay[$dateString])) {
$transaksisPerTanggal = $transaksisByDay[$dateString];
$salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales')
->map(fn($transaksisPerSales) => $this->helper->hitungDataSales($transaksisPerSales));
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
return $salesDataTransaksi->get($namaSales) ?? $this->helper->defaultSalesData($namaSales);
});
$totalItem = $fullSalesData->sum('item_terjual');
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
$laporan[$dateString] = [
'tanggal' => $tanggalFormatted,
'total_item_terjual' => $totalItem > 0 ? $totalItem : LaporanHelper::DEFAULT_DISPLAY,
'total_berat' => $totalBerat > 0 ? $this->helper->formatWeight($totalBerat) : LaporanHelper::DEFAULT_DISPLAY,
'total_pendapatan' => $totalPendapatan > 0 ? $this->helper->formatCurrency($totalPendapatan) : LaporanHelper::DEFAULT_DISPLAY,
'sales' => $this->helper->formatSalesDataValues($fullSalesData)->values(),
];
} else {
$laporan[$dateString] = [
'tanggal' => $tanggalFormatted,
'total_item_terjual' => LaporanHelper::DEFAULT_DISPLAY,
'total_berat' => LaporanHelper::DEFAULT_DISPLAY,
'total_pendapatan' => LaporanHelper::DEFAULT_DISPLAY,
'sales' => [],
];
}
}
if ($limitPagination) {
return new LengthAwarePaginator(
array_reverse(array_values($laporan)),
$totalHariUntukPaginasi,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
return collect(array_reverse(array_values($laporan)));
}
public function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
{
$perPage = self::MONTHLY_PER_PAGE;
$transaksis = Transaksi::with(['itemTransaksi.produk'])
->orderBy('created_at', 'desc')
->get();
$laporan = $transaksis->groupBy(function ($transaksi) {
return Carbon::parse($transaksi->created_at)->format('F Y');
})->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) {
$salesDataTransaksi = $transaksisPerTanggal
->groupBy('nama_sales')
->map(fn($transaksisPerSales) => $this->helper->hitungDataSales($transaksisPerSales));
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
return $salesDataTransaksi->get($namaSales) ?? $this->helper->defaultSalesData($namaSales);
});
$totalItem = $fullSalesData->sum('item_terjual');
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
return [
'tanggal' => $tanggal,
'total_item_terjual' => $totalItem > 0 ? $totalItem : LaporanHelper::DEFAULT_DISPLAY,
'total_berat' => $totalBerat > 0 ? $this->helper->formatWeight($totalBerat) : LaporanHelper::DEFAULT_DISPLAY,
'total_pendapatan' => $totalPendapatan > 0 ? $this->helper->formatCurrency($totalPendapatan) : LaporanHelper::DEFAULT_DISPLAY,
'sales' => $this->helper->formatSalesDataValues($fullSalesData)->values(),
];
});
if ($limitPagination) {
return new LengthAwarePaginator(
$laporan->forPage($page, $perPage)->values(),
$laporan->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
return $laporan->values();
}
}

View File

@ -0,0 +1,479 @@
<?php
namespace App\Services;
use App\Models\ItemTransaksi;
use App\Models\Produk;
use App\Models\Transaksi;
use App\Models\Sales;
use App\Models\Nampan;
use App\Repositories\TransaksiRepository;
use App\Helpers\LaporanHelper;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Maatwebsite\Excel\Facades\Excel;
use Barryvdh\DomPDF\Facade\Pdf;
use App\Exports\RingkasanExport;
use App\Exports\DetailProdukExport;
use App\Exports\DetailNampanExport;
class LaporanService
{
private const CACHE_TTL = 300; // 5 menit
private const DEFAULT_PER_PAGE = 15;
private const MAX_PER_PAGE = 100;
private const DAILY_PER_PAGE = 7;
private const MONTHLY_PER_PAGE = 12;
private const PAGINATION_DAYS_LIMIT = 365;
private TransaksiRepository $transaksiRepo;
private LaporanHelper $helper;
public function __construct(TransaksiRepository $transaksiRepo, LaporanHelper $helper)
{
$this->transaksiRepo = $transaksiRepo;
$this->helper = $helper;
}
public function getRingkasan(string $filter, int $page)
{
$cacheKey = "laporan_ringkasan_{$filter}_page_{$page}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($filter, $page) {
$allSalesNames = $this->getAllSalesNames();
if ($filter === 'hari') {
return $this->processLaporanHarian($allSalesNames, $page, true);
}
return $this->processLaporanBulanan($allSalesNames, $page, true);
});
}
/**
* Get sales detail aggregated by product (NO PAGINATION - all data).
*
* @param array $params Filter parameters (tanggal, sales_id, nampan_id, nama_pembeli)
* @return array Report data structure
* @throws \Exception
*/
public function getDetailPerProduk(array $params)
{
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
// TAMBAH: Validasi range max 30 hari
if ($startDate->diffInDays($endDate) > 30) {
throw new \InvalidArgumentException('Interval tanggal maksimal 30 hari.');
}
// FIXED: Skip pagination params untuk data utama
$page = $params['page'] ?? 1;
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
// --- Step 1: Totals ---
$totalsQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
$this->applyFilters($totalsQuery, $params);
$totalsResult = $totalsQuery->select(
DB::raw('COUNT(item_transaksis.id) as total_item_terjual'),
DB::raw('COALESCE(SUM(produks.berat), 0) as total_berat_terjual'),
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as total_pendapatan')
)->first();
$rekapInterval = [
'total_item_terjual' => (int) $totalsResult->total_item_terjual,
'total_berat_terjual' => $this->helper->formatWeight($totalsResult->total_berat_terjual),
'total_pendapatan' => $this->helper->formatCurrency($totalsResult->total_pendapatan),
];
// --- Step 2: Subquery for all products ---
$salesSubQuery = $this->buildBaseItemQueryForRange($startDate, $endDate)
->select(
'produks.id as id_produk',
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
)
->groupBy('produks.id');
$this->applyFilters($salesSubQuery, $params);
// --- Step 3: All products (NO PAGINATION) ---
$semuaProduk = Produk::select(
'produks.id',
'produks.nama as nama_produk',
'sales_data.jumlah_item_terjual',
'sales_data.berat_terjual',
'sales_data.pendapatan'
)
->leftJoinSub($salesSubQuery, 'sales_data', function ($join) {
$join->on('produks.id', '=', 'sales_data.id_produk');
})
->orderBy('produks.nama')
->get(); // FIXED: get() instead of paginate()
// --- Step 4: Map & filter ---
$detailItem = $semuaProduk->map(function ($item) {
return [
'nama_produk' => $item->nama_produk,
'jumlah_item_terjual' => $item->jumlah_item_terjual ? (int) $item->jumlah_item_terjual : 0,
'berat_terjual' => $item->berat_terjual ? $this->helper->formatWeight($item->berat_terjual) : '-',
'pendapatan' => $item->pendapatan ? $this->helper->formatCurrency($item->pendapatan) : '-',
];
})->filter(function ($item) {
return $item['jumlah_item_terjual'] > 0;
});
// FIXED: Simple collection without pagination
$filteredCollection = $detailItem->values();
// --- Step 5: Response ---
$filterInfo = $this->helper->buildProdukFilterInfo($startDate, $endDate, $params);
return [
'filter' => $filterInfo,
'rekap_interval' => $rekapInterval,
'produk' => $filteredCollection,
'pagination' => [
'current_page' => 1,
'from' => 1,
'last_page' => 1,
'per_page' => $filteredCollection->count(),
'to' => $filteredCollection->count(),
'total' => $filteredCollection->count(),
'has_more_pages' => false,
],
];
}
private function buildBaseItemQueryForRange(Carbon $startDate, Carbon $endDate)
{
return ItemTransaksi::query()
->join('produks', 'item_transaksis.id_produk', '=', 'produks.id')
->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id')
->whereBetween('transaksis.created_at', [$startDate, $endDate]);
}
/**
* Get sales detail aggregated by nampan (NO PAGINATION - all data).
*
* @param array $params Filter parameters (tanggal, sales_id, produk_id, nama_pembeli)
* @return array Report data structure
* @throws \Exception
*/
public function getDetailPerNampan(array $params)
{
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
if ($startDate->diffInDays($endDate) > 30) {
throw new \InvalidArgumentException('Interval tanggal maksimal 30 hari.');
}
$page = $params['page'] ?? 1;
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
$nampanTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
$this->applyNampanFilters($nampanTerjualQuery, $params);
$nampanTerjual = $nampanTerjualQuery
->select(
DB::raw('COALESCE(item_transaksis.posisi_asal, "Brankas") as nama_nampan'),
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
)
->groupBy('nama_nampan')
->get()
->keyBy('nama_nampan');
// FIXED: calculateTotals sum raw (bukan formatted string)
$nampanTerjualRaw = $nampanTerjual->map(function ($item) {
return [
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
'berat_terjual' => $item->berat_terjual,
'pendapatan' => $item->pendapatan,
];
});
$totals = $this->helper->calculateTotals($nampanTerjualRaw);
// FIXED: Get all nampan without pagination
$semuaNampan = $this->helper->getAllNampanWithPagination(1, PHP_INT_MAX); // Skip pagination
$detailItem = $this->helper->mapNampanWithSalesData($semuaNampan, $nampanTerjual)
->filter(function ($item) {
return $item['jumlah_item_terjual'] > 0;
});
// FIXED: Simple collection without pagination
$filteredCollection = $detailItem->values();
$filterInfo = $this->helper->buildNampanFilterInfo($startDate, $endDate, $params);
return [
'filter' => $filterInfo,
'rekap_interval' => $totals,
'nampan' => $filteredCollection,
'pagination' => [
'current_page' => 1,
'from' => 1,
'last_page' => 1,
'per_page' => $filteredCollection->count(),
'to' => $filteredCollection->count(),
'total' => $filteredCollection->count(),
'has_more_pages' => false,
],
];
}
public function exportRingkasan(array $params)
{
$filter = $params['filter'];
$format = $params['format'];
$page = $params['page'] ?? 1;
$allSalesNames = $this->getAllSalesNames();
if ($filter === 'hari') {
$data = $this->processLaporanHarian($allSalesNames, $page, true);
} else {
$data = $this->processLaporanBulanan($allSalesNames, $page, true);
}
$fileName = "laporan_ringkasan_{$filter}_" . Carbon::now()->format('Ymd') . ".{$format}";
if ($format === 'pdf') {
$viewData = method_exists($data, 'items') ? $data->items() : $data;
$pdf = PDF::loadView('exports.ringkasan_pdf', [
'data' => $viewData,
'filter' => $filter
]);
$pdf->setPaper('a4', 'potrait');
return $pdf->download($fileName);
}
return Excel::download(new RingkasanExport($data, $page), $fileName);
}
public function exportPerProduk(array $params)
{
$format = $params['format'];
$allParams = $params;
unset($allParams['page'], $allParams['per_page']);
$data = $this->getDetailPerProdukForExport($allParams);
$startDate = Carbon::parse($params['start_date'])->format('Ymd');
$endDate = Carbon::parse($params['end_date'])->format('Ymd');
$fileName = "laporan_per_produk_{$startDate}_to_{$endDate}.{$format}";
if ($format === 'pdf') {
$pdf = PDF::loadView('exports.perproduk_pdf', [
'data' => $data,
'title' => 'Laporan Detail Per Produk'
]);
$pdf->setPaper('a4', 'portrait');
return $pdf->download($fileName);
}
return Excel::download(new DetailProdukExport($data), $fileName);
}
public function exportPerNampan(array $params)
{
$format = $params['format'];
$allParams = $params;
unset($allParams['page'], $allParams['per_page']);
$data = $this->getDetailPerNampanForExport($allParams);
$startDate = Carbon::parse($params['start_date'])->format('Ymd');
$endDate = Carbon::parse($params['end_date'])->format('Ymd');
$fileName = "laporan_per_nampan_{$startDate}_to_{$endDate}.{$format}";
if ($format === 'pdf') {
$pdf = PDF::loadView('exports.pernampan_pdf', [
'data' => $data,
'title' => 'Laporan Detail Per Nampan'
]);
$pdf->setPaper('a4', 'portrait');
return $pdf->download($fileName);
}
return Excel::download(new DetailNampanExport($data), $fileName);
}
private function getDetailPerProdukForExport(array $params)
{
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
$produkTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
$this->applyFilters($produkTerjualQuery, $params);
$produkTerjual = $produkTerjualQuery
->select(
'produks.id as id_produk',
'produks.nama as nama_produk',
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
)
->groupBy('produks.id', 'produks.nama')
->get()
->keyBy('id_produk');
// FIXED: calculateTotals sum raw
$produkTerjualRaw = $produkTerjual->map(function ($item) {
return [
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
'berat_terjual' => $item->berat_terjual,
'pendapatan' => $item->pendapatan,
];
});
$totals = $this->helper->calculateTotals($produkTerjualRaw);
$semuaProduk = Produk::select('id', 'nama')->orderBy('nama')->get();
$detailItem = collect($semuaProduk)->map(function ($item) use ($produkTerjual) {
if ($produkTerjual->has($item->id)) {
$dataTerjual = $produkTerjual->get($item->id);
return [
'nama_produk' => $item->nama,
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
'berat_terjual' => $this->helper->formatWeight($dataTerjual->berat_terjual),
'pendapatan' => $this->helper->formatCurrency($dataTerjual->pendapatan),
];
}
return null;
})->filter();
$filterInfo = $this->helper->buildProdukFilterInfo($startDate, $endDate, $params);
return [
'filter' => $filterInfo,
'rekap_interval' => $totals,
'produk' => $detailItem->values(),
];
}
private function getDetailPerNampanForExport(array $params)
{
$startDate = Carbon::parse($params['start_date'])->startOfDay();
$endDate = Carbon::parse($params['end_date'])->endOfDay();
$nampanTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
$this->applyNampanFilters($nampanTerjualQuery, $params);
$nampanTerjual = $nampanTerjualQuery
->select(
'item_transaksis.posisi_asal as posisi_asal',
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
)
->groupBy('item_transaksis.posisi_asal')
->get()
->keyBy('posisi_asal');
// FIXED: Sum raw
$nampanTerjualRaw = $nampanTerjual->map(function ($item) {
return [
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
'berat_terjual' => $item->berat_terjual,
'pendapatan' => $item->pendapatan,
];
});
$totals = $this->helper->calculateTotals($nampanTerjualRaw);
$semuaPosisi = DB::table('item_transaksis')
->whereBetween('created_at', [$startDate, $endDate])
->select('posisi_asal')
->distinct()
->pluck('posisi_asal')
->sort()
->values();
$detailItem = $semuaPosisi->map(function ($posisi) use ($nampanTerjual) {
if ($nampanTerjual->has($posisi)) {
$dataTerjual = $nampanTerjual->get($posisi);
return [
'nama_nampan' => $posisi,
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
'berat_terjual' => $this->helper->formatWeight($dataTerjual->berat_terjual),
'pendapatan' => $this->helper->formatCurrency($dataTerjual->pendapatan),
];
}
return null;
})->filter();
$filterInfo = $this->helper->buildNampanFilterInfo($startDate, $endDate, $params);
return [
'filter' => $filterInfo,
'rekap_interval' => $totals,
'nampan' => $detailItem->values(),
];
}
private function getAllSalesNames(): Collection
{
return Cache::remember('all_sales_names', self::CACHE_TTL, function () {
return Transaksi::select('nama_sales')->distinct()->pluck('nama_sales');
});
}
private function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
{
return $this->transaksiRepo->processLaporanHarian($allSalesNames, $page, $limitPagination);
}
private function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
{
return $this->transaksiRepo->processLaporanBulanan($allSalesNames, $page, $limitPagination);
}
private function applyFilters($query, array $params): void
{
if (!empty($params['sales_id'])) {
$query->join('sales', 'transaksis.id_sales', '=', 'sales.id')
->where('sales.id', $params['sales_id']);
}
if (isset($params['nampan_id'])) {
$nampanId = (int) $params['nampan_id'];
if ($nampanId === -1) {
$query->where('item_transaksis.posisi_asal', 'Brankas');
} elseif ($nampanId > 0) {
$query->join('nampans', function ($join) use ($nampanId) {
$join->on('item_transaksis.posisi_asal', '=', 'nampans.nama')
->where('nampans.id', $nampanId);
});
}
}
if (!empty($params['nama_pembeli'])) {
$query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%");
}
}
private function applyNampanFilters($query, array $params): void
{
if (!empty($params['sales_id'])) {
$query->join('sales', 'transaksis.id_sales', '=', 'sales.id')
->where('sales.id', $params['sales_id']);
}
if (!empty($params['produk_id'])) {
$query->where('produks.id', $params['produk_id']);
}
if (!empty($params['nama_pembeli'])) {
$query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%");
}
}
}

View File

@ -15,6 +15,10 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->validateCsrfTokens(except: [
'api/*'
]);
$middleware->alias([
'role' => \App\Http\Middleware\RoleMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@ -7,9 +7,11 @@
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1"
"laravel/tinker": "^2.10.1",
"maatwebsite/excel": "^3.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",

960
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,11 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'sanctum',
'provider' => 'users',
],
],
/*

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Produk;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -17,9 +18,8 @@ class ItemFactory extends Factory
public function definition(): array
{
return [
'id_produk' => \App\Models\Produk::factory(),
'id_produk' => Produk::inRandomOrder()->first()->id,
'id_nampan' => null,
'is_sold' => false,
];
}
}

View File

@ -3,6 +3,7 @@
namespace Database\Factories;
use App\Models\Item;
use App\Models\Produk;
use App\Models\Transaksi;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -20,7 +21,7 @@ class ItemTransaksiFactory extends Factory
{
return [
'id_transaksi' => Transaksi::factory(),
'id_item' => Item::factory(),
'id_produk' => Produk::factory(),
'harga_deal' => $this->faker->randomFloat(2, 100000, 5000000),
'created_at' => now(),
];

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Kategori>
*/
class KategoriFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'nama' => $this->faker->word(),
];
}
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Kategori;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -16,11 +17,16 @@ class ProdukFactory extends Factory
*/
public function definition(): array
{
$kategori = Kategori::inRandomOrder()->first();
$harga_per_gram = $this->faker->numberBetween(80, 120) * 10000;
$berat = $this->faker->randomFloat(2, 1, 10);
return [
'nama' => $this->faker->words(3, true),
'kategori' => $this->faker->randomElement(['cincin', 'gelang', 'kalung', 'anting']),
'nama' => $kategori->nama . ' ' . $this->faker->words(mt_rand(1, 2), true),
'id_kategori' => $kategori->id,
'berat' => $berat,
'kadar' => $this->faker->numberBetween(10, 24),
'harga_per_gram' => $harga_per_gram,

View File

@ -4,33 +4,46 @@ namespace Database\Factories;
use App\Models\Sales;
use App\Models\User;
use App\Models\Transaksi;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Transaksi>
*/
class TransaksiFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
protected $model = Transaksi::class;
public function definition(): array
{
$sales = Sales::inRandomOrder()->first();
$kasir = User::inRandomOrder()->first();
$date = $this->faker->dateTimeBetween('-3 months');
$ongkos_bikin = $this->faker->numberBetween(8, 12) * 10000;
return [
'id_kasir' => $kasir?->id,
'id_sales' => $sales?->id,
'nama_sales' => $sales?->nama ?? $this->faker->name(),
'nama_sales' => $sales?->nama,
'kode_transaksi' => 'bwabwa' . $this->faker->unique()->numberBetween(1, 9999), // temporary, will be updated in configure()
'nama_pembeli' => $this->faker->name(),
'no_hp' => $this->faker->phoneNumber(),
'alamat' => $this->faker->address(),
'ongkos_bikin' => $this->faker->randomFloat(2, 0, 1000000),
'total_harga' => $this->faker->randomFloat(2, 100000, 5000000),
'created_at' => now(),
'ongkos_bikin' => $ongkos_bikin,
'total_harga' => $ongkos_bikin,
'created_at' => $date,
'updated_at' => $date,
];
}
public function configure()
{
return $this->afterCreating(function (Transaksi $transaksi) {
// generate kode transaksi TRS202509090001
$prefix = "TRS";
$date = $transaksi->created_at->format('Ymd');
$number = str_pad($transaksi->id, 4, '0', STR_PAD_LEFT);
$transaksi->kode_transaksi = $prefix . $date . $number;
$transaksi->save();
});
}
}

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('kategoris', function (Blueprint $table) {
$table->id();
$table->string('nama', 100);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('kategoris');
}
};

View File

@ -14,7 +14,7 @@ return new class extends Migration
Schema::create('produks', function (Blueprint $table) {
$table->id();
$table->string('nama', 100);
$table->enum('kategori', ['cincin', 'gelang', 'kalung', 'anting']);
$table->foreignId('id_kategori')->constrained('kategoris');
$table->float('berat');
$table->integer('kadar');
$table->double('harga_per_gram');

View File

@ -15,7 +15,6 @@ return new class extends Migration
$table->id();
$table->foreignId('id_produk')->constrained('produks')->cascadeOnDelete();
$table->foreignId('id_nampan')->nullable()->constrained('nampans');
$table->boolean('is_sold')->default(false);
$table->timestamps();
});
}

View File

@ -16,6 +16,7 @@ return new class extends Migration
$table->foreignId('id_kasir')->constrained('users');
$table->foreignId('id_sales')->nullable()->constrained('sales');
$table->string('nama_sales', 100);
$table->string('nama_pembeli', 100);
$table->string('no_hp', 20);
$table->string('alamat', 100);
$table->double('ongkos_bikin')->nullable();

View File

@ -14,8 +14,9 @@ return new class extends Migration
Schema::create('item_transaksis', function (Blueprint $table) {
$table->id();
$table->foreignId('id_transaksi')->constrained('transaksis')->onDelete('cascade');
$table->foreignId('id_item')->constrained('items');
$table->foreignId('id_produk')->constrained('produks');
$table->double('harga_deal');
$table->string('posisi_asal', 100);
$table->timestamps();
});
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::table('items', function (Blueprint $table) {
$table->string('kode_item')->unique()->after('id');
});
}
public function down()
{
Schema::table('items', function (Blueprint $table) {
$table->dropColumn('kode_item');
});
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::table('transaksis', function (Blueprint $table) {
$table->string('kode_transaksi')->unique()->after('id');
});
}
public function down()
{
Schema::table('transaksis', function (Blueprint $table) {
$table->dropColumn('kode_transaksi');
});
}
};

View File

@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\Item;
use App\Models\Kategori;
use App\Models\Nampan;
use App\Models\Produk;
use App\Models\Sales;
@ -19,48 +20,87 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
User::factory()->create([
'nama' => 'Test User',
'nama' => 'andre',
'role' => 'owner',
'password' => bcrypt('123123123'),
'password' => bcrypt('123123'),
]);
User::factory()->create([
'nama' => 'luis',
'role' => 'kasir',
'password' => bcrypt('123123'),
]);
User::factory(2)->create();
Sales::factory(5)->create();
$kodeNampan = ['A', 'B'];
foreach ($kodeNampan as $kode) {
for ($i=0; $i < 4; $i++) {
for ($i=0; $i < 30; $i++) {
if ($i != 12) {
Nampan::factory()->create([
'nama' => $kode . ($i + 1) // A1, A2, ... B4
'nama' => 'A' . ($i + 1)
]);
}
}
Produk::factory(10)->create()->each(function ($produk) {
// setiap produk punya 1-3 foto
$jumlah_foto = rand(1, 3);
$fotoData = [];
for ($i = 0; $i < $jumlah_foto; $i++) {
$fotoData[] = [
'url' => 'https://random-image-pepebigotes.vercel.app/api/random-image'
];
}
$produk->foto()->createMany($fotoData);
$jumlah_item = rand(1, 20);
Item::factory($jumlah_item)->create([
'id_produk' => $produk->id,
'is_sold' => false,
$kategoriList = ['Cincin', 'Gelang Rantai', 'Gelang Bulat', 'Kalung', 'Liontin', 'Anting', 'Giwang'];
foreach ($kategoriList as $kategori) {
Kategori::factory()->create([
'nama' => $kategori
]);
});
}
// 30% peluang item masuk nampan, sisanya di brankas
// Produk::factory(10)->create()->each(function ($produk) {
// // setiap produk punya 1-3 foto
// $jumlah_foto = rand(1, 3);
// $fotoData = [];
// for ($i = 0; $i < $jumlah_foto; $i++) {
// $fotoData[] = [
// // 'url' => 'https://random-image-pepebigotes.vercel.app/api/random-image'
// 'url' => 'https://static.promediateknologi.id/crop/0x0:0x0/0x0/webp/photo/p2/255/2024/12/10/Screenshot_2024-12-10-11-50-18-88_1c337646f29875672b5a61192b9010f9-1-1282380831.jpg'
// ];
// }
// $produk->foto()->createMany($fotoData);
// $jumlah_item = rand(1, 20);
// Item::factory($jumlah_item)->create([
// 'id_produk' => $produk->id,
// ]);
// });
$produk1 = Produk::factory()->create([
'nama'=>'Gelang serut daun shimmer mp (mas putih)',
'id_kategori'=>Kategori::find(2),
'berat'=>1.4,
'kadar'=>8,
'harga_per_gram'=>900000,
'harga_jual'=>1260000,
]);
$produk1->foto()->create([
'id_produk'=>$produk1->id,
'url'=>'https://i.imgur.com/eGYHzvw.jpeg'
]);
$produk2 = Produk::factory()->create([
'nama'=>'Gelang rantai 5 buah clover merah',
'id_kategori'=>Kategori::find(2),
'berat'=>3.6,
'kadar'=>8,
'harga_per_gram'=>850000,
'harga_jual'=>3060000,
]);
$produk2->foto()->create([
'id_produk'=>$produk2->id,
'url'=>'https://i.imgur.com/UjQzYoE.jpeg'
]);
Item::factory(500)->create();
// 75% peluang item masuk nampan, sisanya di brankas
$nampans = Nampan::all()->pluck('id')->toArray();
$jumlahNampan = count($nampans);
$counter = 0;
foreach (Item::all() as $item) {
if (rand(1, 100) <= 30) {
if (rand(1, 100) <= 75) {
$item->update([
'id_nampan' => $nampans[$counter % $jumlahNampan],
]);
@ -68,17 +108,21 @@ class DatabaseSeeder extends Seeder
}
}
Transaksi::factory(20)->create()->each(function ($transaksi) {
$jumlah_item = rand(1, 5);
$items = Item::where('is_sold', false)->inRandomOrder()->limit($jumlah_item)->get();
Transaksi::factory(250)->create()->each(function ($transaksi) {
$jumlah_item = rand(1, 2);
$items = Item::with('produk')->inRandomOrder()->limit($jumlah_item)->get();
if ($items->isEmpty()) return;
$total_harga = $transaksi->total_harga;
foreach ($items as $item) {
$transaksi->itemTransaksi()->create([
'id_item' => $item->id,
'id_produk' => $item->produk->id,
'harga_deal' => $item->produk->harga_jual,
'posisi_asal' => $item->id_nampan ? $item->nampan->nama : 'Brankas',
]);
$item->update(['is_sold' => true]);
$item->delete();
$total_harga += $item->produk->harga_jual;
}
$transaksi->update(['total_harga' => $total_harga]);
});
}
}

56
docker-compose.yml Normal file
View File

@ -0,0 +1,56 @@
services:
laravel:
build:
context: .
dockerfile: Dockerfile
container_name: laravel_app_prod
volumes:
- ./storage:/var/www/html/storage
ports:
- "9000"
depends_on:
- mysql
environment:
APP_ENV: production
APP_DEBUG: false
APP_KEY: ${APP_KEY}
DB_CONNECTION: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_DATABASE: ${DB_DATABASE}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
nginx:
image: nginx:alpine
container_name: nginx_prod
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./storage:/var/www/html/storage:ro
depends_on:
- laravel
mysql:
image: mysql:8
container_name: mysql_db_prod
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:alpine
container_name: redis_prod
ports:
- "6379:6379"
volumes:
mysql_data:

21
nginx.conf Normal file
View File

@ -0,0 +1,21 @@
server {
listen 80;
index index.php index.html;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
root /var/www/html/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass laravel:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

1540
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,16 +4,17 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
"dev": "concurrently \"php artisan serve\" \"vite\""
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^6.0.1",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"concurrently": "^9.2.1",
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.4"
"vite": "^7.0.4",
"vite-plugin-vue-devtools": "^8.0.1"
},
"dependencies": {
"vue": "^3.5.19",

BIN
public/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@ -1,3 +1,4 @@
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css');
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@ -5,20 +6,33 @@
@source '../**/*.blade.php';
@source '../**/*.js';
@import url('https://fonts.googleapis.com/css2?family=Platypi:wght@400;500;600;700&display=swap');
html, body {
font-family: "Platypi", sans-serif;
}
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
@theme {
/* @theme {
--color-A: #F8F0E5;
--color-B: #EADBC8;
--color-C: #DAC0A3;
--color-D: #0F2C59;
--color-D: #024768;
} */
@theme {
--color-A: #EBF1F5;
--color-B: #AFE5FF;
--color-C: #77C7EE;
--color-D: #024768;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-5px);
}
20%, 40%, 60%, 80% {
transform: translateX(5px);
}
}

BIN
resources/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -1,5 +1,5 @@
<template>
<div id="app">
<div>
<router-view />
</div>
</template>

View File

@ -1,35 +1,175 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="item in filteredItems"
:key="item.id"
class="flex justify-between items-center border rounded-lg p-3 shadow-sm hover:shadow-md transition"
>
<!-- Gambar -->
<div v-if="loading" class="flex justify-center items-center h-screen">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
<span class="ml-2 text-gray-600">Memuat data...</span>
</div>
<div v-else>
<!-- Alert Section -->
<div class="mb-4" v-if="alert">
<div v-if="alert.error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
<strong class="font-bold">Error!</strong>
<span class="block sm:inline">{{ alert.error }}</span>
</div>
<div v-if="alert.success" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
<strong class="font-bold">Success!</strong>
<span class="block sm:inline">{{ alert.success }}</span>
</div>
</div>
<!-- Statistik Brankas -->
<div class="bg-A border border-C rounded-xl p-4 mb-6">
<div class="flex flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<img
:src="item.image"
alt="Product Image"
class="w-12 h-12 object-contain"
/>
<!-- Info produk -->
<div class="p-2 bg-A rounded-lg">
<i class="fas fa-archive text-D"></i>
</div>
<div class="flex flex-col sm:flex-row sm:gap-6 text-sm text-gray-600">
<span>Total Item di brankas: {{ filteredItems.length }}</span>
</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-D">{{ totalWeight }}g</div>
<div class="text-sm text-gray-500">Total Berat</div>
</div>
</div>
</div>
<!-- Daftar Item -->
<div v-if="filteredItems.length === 0" class="text-center text-gray-500 py-[120px]">
{{ props.search ? 'Item tidak ditemukan.' : 'Brankas kosong.' }}
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="item in filteredItems" :key="item.id"
class="flex justify-between items-center border border-C rounded-lg p-3 shadow-sm hover:shadow-md transition cursor-pointer"
@click="openMovePopup(item)">
<!-- Gambar & Info Produk -->
<div class="flex items-center gap-3">
<img v-if="item.produk?.foto?.length" :src="item.produk?.foto[0].url"
class="size-12 object-cover rounded"
@error="handleImageError" />
<div class="size-12 bg-gray-200 rounded flex items-center justify-center" v-else>
<i class="fas fa-image text-gray-400"></i>
</div>
<div>
<p class="font-semibold">{{ item.produk.nama }}</p>
<p class="text-sm text-gray-500">{{ item.produk.id }}</p>
<p class="font-semibold text-D">{{ item.produk?.nama }}</p>
<p class="text-sm text-gray-500 font-semibold">{{ item.kode_item }}</p>
</div>
</div>
<!-- Berat -->
<span class="font-medium">{{ item.berat }}g</span>
<span class="font-medium text-D">{{ item.produk?.berat }}g</span>
</div>
</div>
<!-- Modal Pindah Nampan -->
<div v-if="isPopupVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
<!-- QR Code -->
<div class="flex justify-center mb-4">
<div class="p-2 border border-C rounded-lg">
<img :src="qrCodeUrl" alt="QR Code" class="size-36" />
</div>
</div>
<!-- Info Produk -->
<div class="text-center text-D font-bold text-lg mb-1">
{{ selectedItem?.kode_item }}
</div>
<div class="text-center text-gray-700 font-medium mb-1">
{{ selectedItem?.produk?.nama }}
</div>
<div class="text-center text-gray-500 text-sm mb-4">
{{ selectedItem?.produk?.kategori }}
</div>
<!-- Tombol Cetak -->
<div class="flex justify-center mb-4">
<button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition">
<i class="fas fa-print mr-2"></i>Cetak
</button>
</div>
<!-- Dropdown pilih nampan -->
<div class="mb-4">
<label for="tray-select" class="block text-sm font-medium text-D mb-2">
Pindah ke Nampan
</label>
<select id="tray-select" v-model="selectedTrayId"
class="w-full px-3 py-2 border border-C rounded-md shadow-sm focus:border-D focus:ring focus:ring-D/20 focus:ring-opacity-50">
<option value="" disabled>Pilih Nampan</option>
<option v-for="tray in trays" :key="tray.id" :value="tray.id">
{{ tray.nama }}
</option>
</select>
<p v-if="errorMove" class="text-red-500 text-sm mt-1">{{ errorMove }}</p>
</div>
<!-- Tombol -->
<!-- Tombol -->
<div class="flex justify-end gap-2">
<button @click="closePopup" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
Batal
</button>
<button @click="showDeleteConfirm = true"
class="px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600 transition flex items-center">
<i class="fas fa-trash mr-2"></i>Hapus
</button>
<button @click="saveMove" :disabled="!selectedTrayId || isMoving"
class="px-4 py-2 rounded text-D transition flex items-center"
:class="(selectedTrayId && !isMoving) ? 'bg-C hover:bg-C/80' : 'bg-gray-400 cursor-not-allowed'">
<div v-if="isMoving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{{ isMoving ? 'Memindahkan...' : 'Pindahkan' }}
</button>
</div>
</div>
</div>
<!-- Modal Konfirmasi Hapus -->
<ConfirmDeleteModal
:isOpen="showDeleteConfirm"
title="Konfirmasi Hapus Item"
message="Apakah kamu yakin ingin menghapus item ini?"
confirmText="Ya, Hapus"
cancelText="Batal"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
<!-- Confirm Modal untuk aksi berbahaya (jika diperlukan di masa depan) -->
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
<div class="bg-white rounded-xl shadow-lg max-w-md w-full p-6 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
<div class="flex items-center mb-4">
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-red-100 rounded-full flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="ml-3">
<h3 class="text-lg font-medium text-D">{{ confirmModalTitle }}</h3>
</div>
</div>
<div class="mb-4">
<p class="text-sm text-gray-500" v-html="confirmModalMessage"></p>
</div>
<div class="flex justify-end gap-2">
<button @click="closeConfirmModal" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
{{ cancelText }}
</button>
<button @click="handleConfirmAction" class="px-4 py-2 rounded bg-red-500 hover:bg-red-600 text-white transition">
{{ confirmText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import axios from "axios";
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
const props = defineProps({
search: {
@ -39,26 +179,249 @@ const props = defineProps({
});
const items = ref([]);
const trays = ref([]);
const loading = ref(true);
const error = ref(null);
const alert = ref(null);
const timer = ref(null);
onMounted(async () => {
try {
const res = await axios.get("/api/item"); // ganti sesuai URL backend
items.value = res.data; // pastikan backend return array of items
console.log(res.data);
// State modal pindah
const isPopupVisible = ref(false);
const selectedItem = ref(null);
const selectedTrayId = ref("");
const errorMove = ref("");
const isMoving = ref(false);
} catch (err) {
error.value = err.message || "Gagal mengambil data";
} finally {
loading.value = false;
const showDeleteConfirm = ref(false);
// State modal konfirmasi
const isConfirmModalVisible = ref(false);
const confirmModalTitle = ref("");
const confirmModalMessage = ref("");
const confirmText = ref("Ya, Konfirmasi");
const cancelText = ref("Batal");
// QR Code generator
const qrCodeUrl = computed(() => {
if (selectedItem.value) {
const data = selectedItem.value.kode_item;
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
}
return "";
});
// Computed untuk statistik
const totalWeight = computed(() => {
const total = filteredItems.value.reduce((sum, item) => {
return sum + (item?.produk?.berat || 0);
}, 0);
return total.toFixed(2);
});
const filteredItems = computed(() => {
if (!props.search) return items.value;
return items.value.filter((item) =>
item.produk?.nama?.toLowerCase().includes(props.search.toLowerCase())
item.produk?.nama?.toLowerCase().includes(props.search.toLowerCase()) ||
item.kode_item?.toLowerCase().includes(props.search.toLowerCase())
);
});
// Fungsi modal pindah
const openMovePopup = (item) => {
selectedItem.value = item;
selectedTrayId.value = "";
errorMove.value = "";
isPopupVisible.value = true;
};
const closePopup = () => {
isPopupVisible.value = false;
selectedItem.value = null;
selectedTrayId.value = "";
errorMove.value = "";
isMoving.value = false;
};
const confirmDelete = async () => {
if (!selectedItem.value) return;
try {
// Panggil API hapus item
await axios.delete(`/api/item/${selectedItem.value.id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
// Tampilkan alert sukses
alert.value = { success: `Item ${selectedItem.value.kode_item} berhasil dihapus.` };
// Refresh data
await refreshData();
// Tutup modal & popup
showDeleteConfirm.value = false;
closePopup();
// Auto hide alert
clearTimeout(timer.value);
timer.value = setTimeout(() => { alert.value = null; }, 3000);
} catch (err) {
console.error("Gagal menghapus item:", err.response?.data || err);
alert.value = { error: err.response?.data?.message || "Gagal menghapus item. Silakan coba lagi." };
// Auto hide alert error
clearTimeout(timer.value);
timer.value = setTimeout(() => { alert.value = null; }, 5000);
}
};
const cancelDelete = () => {
showDeleteConfirm.value = false;
};
const saveMove = async () => {
if (!selectedTrayId.value || !selectedItem.value || isMoving.value) return;
errorMove.value = "";
isMoving.value = true;
try {
await axios.put(
`/api/item/${selectedItem.value.id}`,
{
id_nampan: selectedTrayId.value,
id_produk: selectedItem.value.id_produk,
},
{
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
}
);
// Tampilkan alert sukses
const trayName = trays.value.find(t => t.id === selectedTrayId.value)?.nama;
alert.value = { success: `Item berhasil dipindahkan ke nampan "${trayName}"` };
await refreshData();
closePopup();
// Auto hide alert
clearTimeout(timer.value);
timer.value = setTimeout(() => { alert.value = null; }, 3000);
} catch (err) {
console.error("Gagal memindahkan item:", err.response?.data || err);
errorMove.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
} finally {
isMoving.value = false;
}
};
// Fungsi modal konfirmasi
const closeConfirmModal = () => {
isConfirmModalVisible.value = false;
confirmModalTitle.value = "";
confirmModalMessage.value = "";
confirmText.value = "Ya, Konfirmasi";
cancelText.value = "Batal";
};
const handleConfirmAction = async () => {
// Implementasi aksi konfirmasi jika diperlukan
closeConfirmModal();
};
// Fungsi utilitas
const printQR = () => {
if (qrCodeUrl.value) {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>Print QR Code - ${selectedItem.value.kode_item}</title>
<style>
@page {
size: 60mm 50mm;
margin: 1mm;
}
body {
font-family: Arial, sans-serif;
text-align: center;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.qr-container {
text-align: center;
}
.qr-img {
width: 40mm;
height: 40mm;
margin-bottom: 2mm;
}
.item-info {
font-size: 14pt;
font-weight: bold;
}
</style>
</head>
<body>
<div class="qr-container">
<img class="qr-img" src="${qrCodeUrl.value}" alt="QR Code"
onload="window.print()" />
<div class="item-info">
${selectedItem.value.kode_item}
</div>
</div>
</body>
</html>
`);
printWindow.document.close();
}
};
const handleImageError = (event) => {
event.target.style.display = 'none';
};
// Ambil data
const refreshData = async () => {
try {
const [itemRes, trayRes] = await Promise.all([
axios.get("/api/item", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
}),
axios.get("/api/nampan", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
}),
]);
// Filter hanya item yang ada di brankas (id_nampan = null atau tidak ada)
items.value = itemRes.data.filter(item => !item.id_nampan);
trays.value = trayRes.data;
} catch (err) {
console.error("Error fetching data:", err);
alert.value = { error: err.response?.data?.message || "Gagal mengambil data" };
clearTimeout(timer.value);
timer.value = setTimeout(() => { alert.value = null; }, 5000);
} finally {
loading.value = false;
}
};
onMounted(refreshData);
</script>
<style scoped>
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-fadeIn {
animation: fadeIn 0.25s ease-out forwards;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 transform transition-all">
<h2 class="text-xl font-bold text-gray-800 mb-3 text-center">
{{ title }}
</h2>
<p class="text-gray-600 text-sm mb-6 text-center leading-relaxed" v-html="message"></p>
<div class="flex justify-center gap-3">
<button @click="$emit('cancel')"
class="px-5 py-2 rounded-lg font-semibold border border-gray-300 text-gray-700 hover:bg-gray-100 transition">
{{ cancelText }}
</button>
<button @click="$emit('confirm')"
class="px-5 py-2 rounded-lg font-semibold bg-red-500 text-white hover:bg-red-600 transition">
{{ confirmText }}
</button>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
isOpen: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
confirmText: {
type: String,
default: 'Ya, Konfirmasi',
},
cancelText: {
type: String,
default: 'Batal',
},
});
// Mendefinisikan events yang dapat di-emit oleh komponen
defineEmits(['confirm', 'cancel']);
</script>
<style scoped>
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-fadeIn {
animation: fadeIn 0.25s ease-out forwards;
}
</style>

View File

@ -0,0 +1,152 @@
<template>
<div
class="fixed inset-0 flex items-center justify-center bg-black/65 z-50"
>
<div class="bg-white rounded-lg p-6 w-96 shadow-lg">
<h2 class="text-lg font-bold mb-4">Tambah Akun</h2>
<form @submit.prevent="createAkun" class="space-y-3">
<!-- Nama -->
<div>
<label for="nama" class="block text-sm font-medium">Nama</label>
<InputField
v-model="form.nama"
id="nama"
type="text"
:required="true"
@input="clearError('nama')"
/>
<p v-if="errors.nama" class="text-red-500 text-sm">{{ errors.nama }}</p>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium">Password</label>
<InputPassword
v-model="form.password"
id="password"
type="password"
:required="true"
@input="clearError('password')"
/>
<p v-if="errors.password" class="text-red-500 text-sm">{{ errors.password }}</p>
</div>
<!-- Role -->
<div>
<label for="peran" class="block text-sm font-medium">Peran</label>
<InputSelect
v-model="form.role"
:options="[
{ value: 'owner', label: 'Owner' },
{ value: 'kasir', label: 'Kasir' },
]"
placeholder="-- Pilih Peran --"
@change="clearError('role')"
/>
<p v-if="errors.role" class="text-red-500 text-sm">{{ errors.role }}</p>
</div>
<!-- Tombol -->
<div class="flex justify-end gap-2 mt-4">
<button
type="button"
@click="$emit('close')"
class="bg-gray-300 hover:bg-gray-400 px-4 py-2 rounded"
>
Batal
</button>
<button
type="submit"
class="bg-C hover:bg-C/80 text-white px-4 py-2 rounded"
>
Simpan
</button>
</div>
</form>
<!-- Error global -->
<p v-if="errorMessage" class="text-red-500 text-sm mt-3">
{{ errorMessage }}
</p>
</div>
</div>
</template>
<script>
import axios from "axios";
import InputField from "@/components/InputField.vue";
import InputSelect from "@/components/InputSelect.vue";
import InputPassword from "./InputPassword.vue";
export default {
name: "CreateAkun",
components: { InputField, InputSelect, InputPassword },
data() {
return {
form: { nama: "", password: "", role: "" },
errors: { nama: "", password: "", role: "" },
errorMessage: "",
};
},
methods: {
clearError(field) {
this.errors[field] = "";
this.errorMessage = "";
},
validateForm() {
let valid = true;
this.errors = { nama: "", password: "", role: "" };
if (!this.form.nama) {
this.errors.nama = "Nama wajib diisi";
valid = false;
}
if (!this.form.password) {
this.errors.password = "Password wajib diisi";
valid = false;
} else if (this.form.password.length < 6) {
this.errors.password = "Password minimal 6 karakter";
valid = false;
}
if (!this.form.role) {
this.errors.role = "Role wajib dipilih";
valid = false;
} else if (!["owner", "kasir"].includes(this.form.role)) {
this.errors.role = "Role harus owner atau kasir";
valid = false;
}
return valid;
},
async createAkun() {
if (!this.validateForm()) return;
try {
await axios.post("/api/user", this.form, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
// reset form
this.form = { nama: "", password: "", role: "" };
this.$emit("refresh");
this.$emit("close");
} catch (err) {
if (err.response?.status === 422 && err.response.data.errors) {
// tampilkan error validasi backend
const backendErrors = err.response.data.errors;
Object.keys(backendErrors).forEach((key) => {
this.errors[key] = backendErrors[key][0];
});
} else {
this.errorMessage =
err.response?.data?.message || "Gagal menambah akun.";
}
console.error("Gagal tambah akun:", err);
}
},
},
};
</script>

View File

@ -0,0 +1,243 @@
<template>
<Modal :active="isOpen" size="md" @close="handleClose" clickOutside="false">
<div class="p-6">
<div v-if="!success">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Item {{ product?.nama }}</h3>
<div class="mb-4">
<label class="block text-gray-700 mb-2">Pilih Nampan</label>
<InputSelect v-model="selectedNampan" :options="positionListOptions" :disabled="loading" />
</div>
<div class="flex justify-end gap-3">
<button @click="handleClose" :disabled="loading"
class="px-4 py-2 text-white bg-gray-400 hover:bg-gray-500 rounded-lg transition-colors disabled:opacity-50">
Batal
</button>
<button @click="createItem" :disabled="loading"
class="px-4 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors disabled:bg-A disabled:cursor-not-allowed flex items-center gap-2">
<svg v-if="loading" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path class="opacity-75" fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
{{ loading ? 'Membuat...' : 'Buat Item' }}
</button>
</div>
</div>
<!-- Success State -->
<div v-else>
<div class="text-center">
<h4 class="text-lg font-semibold text-gray-900 mb-2">Item Berhasil Dibuat!</h4>
<!-- QR Code -->
<div class="flex justify-center mb-4">
<div class="p-2 border border-gray-300 rounded-lg">
<img :src="qrCodeUrl" alt="QR Code" class="w-36 h-36" />
</div>
</div>
<!-- Item Info -->
<div class="text-center text-gray-700 font-medium mb-1">
{{ createdItem?.kode_item }}
</div>
<div class="text-center text-gray-500 text-sm mb-6">
{{ product?.nama }} - {{ createdItem?.nampan?.nama || 'Brankas' }}
</div>
<div class="flex flex-row justify-between gap-3">
<button @click="handleClose"
class="flex-1 px-6 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-lg transition-colors">
Selesai
</button>
<button @click="printItem"
class="flex-1 px-6 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors">
<i class="fas fa-print mr-1"></i>Print
</button>
<button @click="addNewItem"
class="flex-1 px-6 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors">
Buat Lagi
</button>
</div>
</div>
</div>
</div>
</Modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import axios from 'axios';
import Modal from './Modal.vue';
import InputSelect from './InputSelect.vue';
// Props
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
product: {
type: Object,
default: null
}
});
// Emits
const emit = defineEmits(['close','itemAdded']);
// State
const selectedNampan = ref('');
const nampanList = ref([]);
const positionListOptions = ref([
{ value: '', label: 'Brankas', selected: true },
])
const success = ref(false);
const loading = ref(false);
const createdItem = ref(null);
// QR Code generator - berdasarkan logika dari brankas list
const qrCodeUrl = computed(() => {
if (createdItem.value && props.product) {
const itemId = createdItem.value.id || createdItem.value.kode_item;
const productName = props.product.nama.replace(/\s/g, "");
const data = `ITM-${itemId}-${productName}`;
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
}
return "";
});
// Methods
const loadNampanList = async () => {
try {
const response = await axios.get('/api/nampan', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});;
nampanList.value = response.data;
positionListOptions.value = [
{ value: '', label: 'Brankas', selected: !selectedNampan.value },
...nampanList.value.map(n => ({
value: n.id,
label: `${n.nama} (${n.items_count} items)`,
selected: n.id === selectedNampan.value
}))
];
} catch (error) {
console.error('Error loading nampan list:', error);
}
};
const createItem = async () => {
if (!props.product) return;
loading.value = true;
try {
const payload = {
id_produk: props.product.id
};
if (selectedNampan.value) {
payload.id_nampan = selectedNampan.value;
}
const response = await axios.post('/api/item', payload, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
success.value = true;
createdItem.value = response.data.data;
// console.log('Item created:', createdItem);
emit('itemAdded'); // 🔔 penting
loadNampanList();
} catch (error) {
console.error('Error creating item:', error);
alert('Gagal membuat item: ' + (error.response?.data?.message || error.message));
} finally {
loading.value = false;
}
};
const addNewItem = () => {
success.value = false;
selectedNampan.value = '';
createdItem.value = null;
};
// Fungsi print berdasarkan logika dari brankas list
const printItem = () => {
if (qrCodeUrl.value && createdItem.value && props.product) {
const printWindow = window.open('', '_blank');
const itemCode = createdItem.value.kode_item || createdItem.value.id;
printWindow.document.write(`
<html>
<head>
<title>Print QR Code - ${itemCode}</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 20px;
}
.qr-container {
border: 2px solid #ccc;
padding: 20px;
display: inline-block;
margin: 20px;
}
.item-info {
margin-top: 10px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="qr-container">
<img src="${qrCodeUrl.value}" alt="QR Code" style="width: 200px; height: 200px;" />
<div class="item-info">
<div style="font-weight: bold; margin-bottom: 5px;">${itemCode}</div>
<div>${props.product.nama}</div>
<div style="color: #666; margin-top: 5px;">${props.product.berat}g</div>
</div>
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
};
const handleClose = () => {
// Reset state
selectedNampan.value = '';
success.value = false;
loading.value = false;
createdItem.value = null;
emit('close');
};
// Watchers
watch(() => props.isOpen, (newValue) => {
if (newValue) {
selectedNampan.value = '';
success.value = false;
loading.value = false;
createdItem.value = null;
loadNampanList();
}
});
</script>

View File

@ -0,0 +1,82 @@
<template>
<div v-if="isOpen" class="fixed inset-0 flex items-center justify-center bg-black/65 z-50">
<div class="bg-white rounded-lg shadow-lg w-96 p-6 relative">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-gray-800">
{{ product ? 'Edit Kategori' : 'Tambah Kategori Baru' }}
</h2>
<button @click="emit('close')" class="text-gray-400 hover:text-gray-600"></button>
</div>
<!-- Form -->
<div>
<label class="block text-sm font-medium text-gray-700">Nama Kategori</label>
<InputField
v-model="form.nama"
type="text"
placeholder="Masukkan nama kategori"
/>
</div>
<!-- Buttons -->
<div class="flex justify-end gap-2 mt-4">
<button
@click="emit('close')"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
>
Batal
</button>
<button
@click="saveKategori"
:disabled="!form.nama"
class="px-4 py-2 bg-C text-black rounded hover:bg-B"
>
Simpan
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
import InputField from './InputField.vue'
const props = defineProps({
isOpen: Boolean,
product: Object
})
const emit = defineEmits(['close'])
const form = ref({ nama: '' })
// Sync kalau ubah kategori
watch(() => props.product, (val) => {
form.value.nama = val ? val.nama : ''
}, { immediate: true })
const saveKategori = async () => {
try {
if (props.product) {
await axios.put(`/api/kategori/${props.product.id}`, form.value, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
} else {
await axios.post('/api/kategori', form.value, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
}
emit('close') // tutup modal
} catch (err) {
console.error(err)
alert('Gagal menyimpan kategori')
}
}
</script>

View File

@ -0,0 +1,86 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/65 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg shadow-lg w-full max-w-lg p-6 relative">
<h2 class="text-xl font-bold mb-4">Tambah Sales</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Nama -->
<div>
<label class="block text-sm font-medium text-gray-700">Nama Sales</label>
<InputField v-model="form.nama" type="text" placeholder="Masukkan nama sales" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">No HP</label>
<InputField v-model="form.no_hp" type="text" placeholder="Masukkan nomor HP" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Alamat</label>
<textarea
v-model="form.alamat"
placeholder="Masukkan alamat"
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2"
required
></textarea>
</div>
<div class="flex justify-end gap-2 mt-6">
<button
type="button"
@click="$emit('close')"
class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400"
>
Batal
</button>
<button
type="submit"
class="px-4 py-2 bg-C text-D rounded hover:bg-C/80"
>
Simpan
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from "vue"
import axios from "axios"
import InputField from "./InputField.vue"
const props = defineProps({
isOpen: Boolean,
})
const emit = defineEmits(["close", "saved"])
const form = ref({
nama: "",
no_hp: "",
alamat: "",
})
const resetForm = () => {
form.value = { nama: "", no_hp: "", alamat: "" }
}
const handleSubmit = async () => {
try {
await axios.post("/api/sales", form.value, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
resetForm()
emit("saved")
emit("close")
} catch (error) {
console.error("Error creating sales:", error)
}
}
</script>

View File

@ -0,0 +1,206 @@
<template>
<div class="relative" ref="datePickerRef">
<!-- Input Display -->
<div class="flex gap-2 items-center">
<div class="flex-1">
<label v-if="label" class="text-D/80 block text-sm font-medium mb-1">{{ label }}</label>
<div
@click="toggleCalendar"
class="w-full px-3 py-2 bg-A text-D border border-B rounded-md cursor-pointer hover:border-C focus-within:border-C focus-within:ring focus-within:ring-D focus-within:ring-opacity-50 transition-colors"
>
<div class="flex items-center justify-between">
<span v-if="displayText" class="text-sm">{{ displayText }}</span>
<span v-else class="text-sm text-D/60">{{ placeholder }}</span>
<i class="fas fa-calendar-alt text-D/60"></i>
</div>
</div>
<div v-if="errorMessage" class="text-red-500 text-xs mt-1">{{ errorMessage }}</div>
</div>
</div>
<!-- Calendar Popup (inline, no teleport) -->
<div
v-if="showCalendar"
ref="popupRef"
class="absolute z-[9999] bg-A border border-C rounded-lg shadow-xl p-4 min-w-[300px] mt-2"
:class="popupPositionClass"
>
<!-- Manual Date Inputs -->
<div class="mb-4">
<div class="text-sm font-medium text-D mb-2">Pilih Manual</div>
<div class="flex gap-3 items-center">
<div class="flex-1">
<label class="text-xs text-D/80 block mb-1">Dari</label>
<input
type="date"
v-model="tempStartDate"
@input="validateDates"
:max="maxDate"
class="w-full text-xs px-2 py-2 bg-A text-D border border-B rounded focus:border-C focus:outline-none transition-colors"
/>
</div>
<span class="text-D/50 text-sm">s/d</span>
<div class="flex-1">
<label class="text-xs text-D/80 block mb-1">Sampai</label>
<input
type="date"
v-model="tempEndDate"
@input="validateDates"
:min="tempStartDate"
:max="maxDate"
class="w-full text-xs px-2 py-2 bg-A text-D border border-B rounded focus:border-C focus:outline-none transition-colors"
/>
</div>
</div>
<!-- Range Info -->
<div v-if="tempStartDate && tempEndDate" class="text-xs text-D/60 mt-2">
{{ rangeDaysText }} ({{ formatDisplayDate(tempStartDate) }} - {{ formatDisplayDate(tempEndDate) }})
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center pt-3 border-t border-C">
<button
@click="clearDates"
class="px-3 py-1 text-xs text-D/60 hover:text-D transition-colors"
>
Bersihkan
</button>
<div class="flex gap-2">
<button
@click="cancel"
class="px-4 py-1 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 rounded transition-colors"
>
Batal
</button>
<button
@click="confirm"
:disabled="!isValidRange"
class="px-4 py-1 text-xs bg-C hover:bg-C/80 text-D rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Terapkan
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
const props = defineProps({
modelValue: { type: Object, default: () => ({ start: '', end: '' }) },
label: { type: String, default: 'Pilih Periode' },
placeholder: { type: String, default: 'Pilih rentang tanggal' },
maxDays: { type: Number, default: 31 },
position: { type: String, default: 'left', validator: (v) => ['left', 'right'].includes(v) }
})
const emit = defineEmits(['update:modelValue', 'change'])
const datePickerRef = ref(null)
const showCalendar = ref(false)
const tempStartDate = ref('')
const tempEndDate = ref('')
const errorMessage = ref('')
const maxDate = computed(() => new Date().toISOString().split('T')[0])
const displayText = computed(() => {
if (props.modelValue.start && props.modelValue.end) {
const startFormatted = formatDisplayDate(props.modelValue.start)
const endFormatted = formatDisplayDate(props.modelValue.end)
return props.modelValue.start === props.modelValue.end
? startFormatted
: `${startFormatted} - ${endFormatted}`
}
return ''
})
const isValidRange = computed(() => {
if (!tempStartDate.value || !tempEndDate.value) return false
const start = new Date(tempStartDate.value)
const end = new Date(tempEndDate.value)
if (start > end) return false
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
return diffDays <= props.maxDays
})
const rangeDaysText = computed(() => {
if (!tempStartDate.value || !tempEndDate.value) return ''
const start = new Date(tempStartDate.value)
const end = new Date(tempEndDate.value)
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
return diffDays > props.maxDays
? `⚠️ Maksimal ${props.maxDays} hari`
: `${diffDays} hari`
})
const popupPositionClass = computed(() => props.position === 'right' ? 'right-0' : 'left-0')
const formatDisplayDate = (dateString) => {
const date = new Date(dateString + 'T00:00:00')
return date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' })
}
const toggleCalendar = () => {
showCalendar.value = !showCalendar.value
if (showCalendar.value) {
tempStartDate.value = props.modelValue.start
tempEndDate.value = props.modelValue.end
errorMessage.value = ''
}
}
const validateDates = () => {
errorMessage.value = ''
if (!tempStartDate.value || !tempEndDate.value) return
const start = new Date(tempStartDate.value)
const end = new Date(tempEndDate.value)
if (start > end) {
errorMessage.value = 'Tanggal akhir harus setelah tanggal mulai'
return
}
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (diffDays > props.maxDays) {
errorMessage.value = `Maksimal ${props.maxDays} hari`
}
}
const clearDates = () => {
tempStartDate.value = ''
tempEndDate.value = ''
errorMessage.value = ''
}
const cancel = () => {
showCalendar.value = false
errorMessage.value = ''
}
const confirm = () => {
if (isValidRange.value) {
const newValue = { start: tempStartDate.value, end: tempEndDate.value }
emit('update:modelValue', newValue)
emit('change', newValue)
showCalendar.value = false
}
}
const handleClickOutside = (e) => {
if (datePickerRef.value && !datePickerRef.value.contains(e.target)) {
showCalendar.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
watch(() => props.modelValue, (newValue) => {
if (newValue.start !== tempStartDate.value || newValue.end !== tempEndDate.value) {
tempStartDate.value = newValue.start
tempEndDate.value = newValue.end
}
}, { deep: true })
</script>

View File

@ -0,0 +1,491 @@
<template>
<div class="my-6">
<hr class="border-B mb-5" />
<!-- Filter Section -->
<div class="flex flex-row my-3 gap-1 md:gap-5 lg:gap-8">
<div class="mb-3 w-full">
<DatePicker
v-model="dateRange"
label="Filter Tanggal"
placeholder="Pilih rentang tanggal"
:max-days="31"
@change="handleDateChange"
/>
</div>
<div class="mb-3 w-full">
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
<InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" />
</div>
<div class="mb-3 w-full">
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
<InputField placeholder="Nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
</div>
<div class="mb-3 w-full">
<label class="text-D/80" for="pilihProduk">Filter Produk:</label>
<InputSelect :options="opsiProduk" v-model="produkDipilih" id="pilihProduk" />
</div>
</div>
<!-- Export Section -->
<div class="flex flex-row items-center justify-between mt-5 gap-3">
<!-- Summary Cards -->
<div v-if="loading">
<div class="flex items-center justify-center w-full h-30">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
<span class="ml-2 text-gray-600">Memuat data...</span>
</div>
</div>
<div class="flex gap-4" v-else-if="data?.rekap_interval">
<div class="bg-A p-3 rounded-md border border-C">
<div class="text-xs text-D/60">Total Item</div>
<div class="font-semibold text-D">{{ data.rekap_interval.total_item_terjual }}</div>
</div>
<div class="bg-A p-3 rounded-md border border-C">
<div class="text-xs text-D/60">Total Berat</div>
<div class="font-semibold text-D">{{ data.rekap_interval.total_berat_terjual }}</div>
</div>
<div class="bg-A p-3 rounded-md border border-C">
<div class="text-xs text-D/60">Total Pendapatan</div>
<div class="font-semibold text-D">{{ data.rekap_interval.total_pendapatan }}</div>
</div>
</div>
<div v-else></div>
<!-- Export Dropdown -->
<div class="relative w-40" ref="exportDropdownRef">
<button v-if="loadingExport" type="button"
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
</button>
<button v-else @click="isExportOpen = !isExportOpen" type="button"
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
<i class="fas fa-chevron-down"></i>
</button>
<div v-if="isExportOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C right-0">
<ul class="py-1">
<li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)"
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
{{ option.label }}
</li>
</ul>
</div>
</div>
</div>
<!-- Table Section -->
<div class="mt-5 overflow-x-auto">
<table class="w-full border-collapse border border-C rounded-md">
<thead>
<tr class="bg-C text-D rounded-t-md">
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('nama_nampan')"
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
<span>Nama Nampan</span>
<i :class="getSortIcon('nama_nampan')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('jumlah_item_terjual')"
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
<span>Item Terjual</span>
<i :class="getSortIcon('jumlah_item_terjual')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('berat_terjual')"
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
<span>Total Berat</span>
<i :class="getSortIcon('berat_terjual')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('pendapatan')"
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
<span>Total Pendapatan</span>
<i :class="getSortIcon('pendapatan')" class="ml-2"></i>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="4" class="p-4">
<div class="flex items-center justify-center w-full h-30">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
<span class="ml-2 text-gray-600">Memuat data...</span>
</div>
</td>
</tr>
<tr v-else-if="sortedNampan.length == 0">
<td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
</tr>
<template v-else v-for="item in sortedNampan" :key="item.nama_nampan">
<tr class="text-center border-y border-C hover:bg-A">
<td class="border-x border-C px-3 py-2 text-left">{{ item.nama_nampan }}</td>
<td class="border-x border-C px-3 py-2">{{ item.jumlah_item_terjual }}</td>
<td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td>
<td class="border-x border-C px-3 py-2">
<div class="flex justify-center">
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle"
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
{{ item.pendapatan }}
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Pagination -->
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
Sebelumnya
</button>
<span class="text-sm text-D">
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
</span>
<button @click="goToPage(pagination.current_page + 1)"
:disabled="(pagination.current_page === pagination.last_page) || loading"
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
Berikutnya
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
import InputSelect from './InputSelect.vue';
import InputField from './InputField.vue';
import DatePicker from './DatePicker.vue';
import axios from 'axios';
// --- State ---
const isExportOpen = ref(false);
const exportDropdownRef = ref(null);
const exportOptions = ref([
{ value: 'pdf', label: 'Pdf' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'csv', label: 'Csv' }
]);
const exportFormat = ref(null);
const dateRange = ref({ start: '', end: '' });
const data = ref(null);
const loading = ref(false);
const loadingExport = ref(false);
const sortBy = ref(null);
const sortOrder = ref('asc');
const pagination = ref({
current_page: 1,
last_page: 1,
total: 0,
});
const pendapatanWidth = ref(0);
const pendapatanElements = ref([]);
const salesDipilih = ref(0);
const opsiSales = ref([
{ label: 'Semua Sales', value: 0 },
]);
const produkDipilih = ref(0);
const opsiProduk = ref([
{ label: 'Semua Produk', value: 0 },
]);
const namaPembeli = ref(null);
// --- Computed ---
const nampan = computed(() => data.value?.nampan || []);
const sortedNampan = computed(() => {
if (!sortBy.value || !nampan.value.length) {
return nampan.value;
}
const sorted = [...nampan.value].sort((a, b) => {
let aValue = a[sortBy.value];
let bValue = b[sortBy.value];
// Handle different data types
if (sortBy.value === 'nama_nampan') {
// String comparison
aValue = aValue?.toString().toLowerCase() || '';
bValue = bValue?.toString().toLowerCase() || '';
} else if (sortBy.value === 'jumlah_item_terjual') {
// Numeric comparison
aValue = parseInt(aValue) || 0;
bValue = parseInt(bValue) || 0;
} else if (sortBy.value === 'berat_terjual') {
// Handle weight values (remove unit if exists)
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
} else if (sortBy.value === 'pendapatan') {
// Handle currency values (remove currency symbols and commas)
if (aValue === '-') aValue = 0;
if (bValue === '-') bValue = 0;
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
}
if (sortOrder.value === 'asc') {
if (typeof aValue === 'string') {
return aValue.localeCompare(bValue);
}
return aValue - bValue;
} else {
if (typeof aValue === 'string') {
return bValue.localeCompare(aValue);
}
return bValue - aValue;
}
});
return sorted;
});
const selectedExportLabel = computed(() => {
return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan';
});
const pendapatanStyle = computed(() => ({
minWidth: `${pendapatanWidth.value}px`,
padding: '0.5rem 0.75rem'
}));
// --- Watchers ---
watch(nampan, async (newValue) => {
if (newValue && newValue.length > 0) {
await nextTick();
pendapatanElements.value = [];
let maxWidth = 0;
await nextTick();
pendapatanElements.value.forEach(el => {
if (el && el.scrollWidth > maxWidth) {
maxWidth = el.scrollWidth;
}
});
pendapatanWidth.value = maxWidth;
}
}, { deep: true });
// --- Methods ---
const handleSort = (column) => {
if (sortBy.value === column) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
} else {
sortBy.value = column;
sortOrder.value = 'asc';
}
};
const getSortIcon = (column) => {
if (sortBy.value !== column) {
return 'fas fa-sort text-D/40'; // Default sort icon
}
if (sortOrder.value === 'asc') {
return 'fas fa-sort-up text-D'; // Ascending
} else {
return 'fas fa-sort-down text-D'; // Descending
}
};
const handleDateChange = (newDateRange) => {
// console.log('Date range changed:', newDateRange);
// Reset pagination when date changes
pagination.value.current_page = 1;
fetchData(1);
};
const fetchSales = async () => {
try {
const response = await axios.get('/api/sales', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const salesData = response.data;
opsiSales.value = [
{ label: 'Semua Sales', value: 0 },
...salesData.map(sales => ({
label: sales.nama,
value: sales.id,
}))
];
} catch (error) {
console.error('Gagal mengambil data sales:', error);
}
};
const fetchProduk = async () => {
try {
const response = await axios.get('/api/produk', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const produkData = response.data;
opsiProduk.value = [
{ label: 'Semua Produk', value: 0 },
...produkData.map(produk => ({
label: produk.nama,
value: produk.id,
}))
];
} catch (error) {
console.error('Gagal mengambil data produk:', error);
}
};
const fetchData = async (page = 1) => {
if (!dateRange.value.start || !dateRange.value.end) return;
loading.value = true;
pendapatanElements.value = [];
let queryParams = `start_date=${dateRange.value.start}&end_date=${dateRange.value.end}&page=${page}`;
if (salesDipilih.value != 0 ) queryParams += `&sales_id=${salesDipilih.value}`;
if (produkDipilih.value != 0) queryParams += `&produk_id=${produkDipilih.value}`;
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
try {
const response = await axios.get(`/api/laporan/detail-per-nampan?${queryParams}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
}
});
data.value = response.data;
// Handle pagination data if provided by backend
if (response.data.pagination) {
pagination.value = {
current_page: response.data.pagination.current_page,
last_page: response.data.pagination.last_page,
total: response.data.pagination.total,
};
} else {
// Reset pagination if no pagination data
pagination.value = {
current_page: 1,
last_page: 1,
total: response.data.nampan ? response.data.nampan.length : 0,
};
}
// console.log('Data laporan nampan berhasil diambil:', data.value);
} catch (error) {
console.error('Gagal mengambil data laporan nampan');
data.value = null;
pagination.value = {
current_page: 1,
last_page: 1,
total: 0,
};
} finally {
loading.value = false;
}
};
const goToPage = (page) => {
if (page >= 1 && page <= pagination.value.last_page) {
pagination.value.current_page = page;
fetchData(page);
}
};
const selectExport = async (option) => {
if (!dateRange.value.start || !dateRange.value.end) {
alert('Silakan pilih rentang tanggal terlebih dahulu');
return;
}
exportFormat.value = option.value;
isExportOpen.value = false;
loadingExport.value = true;
try {
const response = await axios.get(`/api/laporan/export/detail-pernampan`, {
params: {
start_date: dateRange.value.start,
end_date: dateRange.value.end,
format: exportFormat.value,
page: pagination.value.current_page,
sales_id: salesDipilih.value != 0 ? salesDipilih.value : null,
produk_id: produkDipilih.value != 0 ? produkDipilih.value : null,
nama_pembeli: namaPembeli.value || null,
},
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = `laporan_${dateRange.value.start}_to_${dateRange.value.end}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e) {
console.error("Gagal mengekspor laporan:", e);
alert('Gagal mengekspor laporan. Silakan coba lagi.');
} finally {
loadingExport.value = false;
}
};
const closeDropdownsOnClickOutside = (event) => {
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
isExportOpen.value = false;
}
};
// --- Lifecycle Hooks ---
onMounted(() => {
// Set default date range to today
const today = new Date().toISOString().split('T')[0];
dateRange.value = { start: today, end: today };
fetchSales();
fetchProduk();
document.addEventListener('click', closeDropdownsOnClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', closeDropdownsOnClickOutside);
});
// Watch for filter changes (except date range which has its own handler)
watch([salesDipilih, produkDipilih, namaPembeli], () => {
if (dateRange.value.start && dateRange.value.end) {
pagination.value.current_page = 1; // Reset to first page when filters change
fetchData(1);
}
});
// Watch for date range changes
watch(dateRange, (newDateRange) => {
if (newDateRange.start && newDateRange.end) {
pagination.value.current_page = 1;
fetchData(1);
}
}, { deep: true, immediate: true });
</script>

View File

@ -0,0 +1,493 @@
<template>
<div class="my-6">
<hr class="border-B mb-5" />
<!-- Filter Section -->
<div class="flex flex-row my-3 gap-1 md:gap-5 lg:gap-8">
<div class="mb-3 w-full">
<DatePicker
v-model="dateRange"
label="Filter Tanggal"
placeholder="Pilih rentang tanggal"
:max-days="31"
@change="handleDateChange"
/>
</div>
<div class="mb-3 w-full min-w-fit">
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
<InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" />
</div>
<div class="mb-3 w-full min-w-fit">
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
<InputField placeholder="Cari nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
</div>
<div class="mb-3 w-full min-w-fit">
<label class="text-D/80" for="pilihNampan">Filter Nampan:</label>
<InputSelect :options="opsiNampan" v-model="nampanDipilih" id="pilihNampan" />
</div>
</div>
<!-- Export Section -->
<div class="flex flex-row items-center justify-between mt-5 gap-3">
<!-- Summary Cards -->
<div v-if="loading">
<div class="flex items-center justify-center w-full h-30">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
<span class="ml-2 text-gray-600">Memuat data...</span>
</div>
</div>
<div class="flex gap-4" v-if="data?.rekap_interval">
<div class="bg-A p-3 rounded-md border border-C">
<div class="text-xs text-D/60">Total Item</div>
<div class="font-semibold text-D">{{ data.rekap_interval.total_item_terjual }}</div>
</div>
<div class="bg-A p-3 rounded-md border border-C">
<div class="text-xs text-D/60">Total Berat</div>
<div class="font-semibold text-D">{{ data.rekap_interval.total_berat_terjual }}</div>
</div>
<div class="bg-A p-3 rounded-md border border-C">
<div class="text-xs text-D/60">Total Pendapatan</div>
<div class="font-semibold text-D">{{ data.rekap_interval.total_pendapatan }}</div>
</div>
</div>
<div v-else></div>
<!-- Export Dropdown -->
<div class="relative w-40" ref="exportDropdownRef">
<button v-if="loadingExport" type="button"
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
</button>
<button v-else @click="isExportOpen = !isExportOpen" type="button"
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
<i class="fas fa-chevron-down"></i>
</button>
<div v-if="isExportOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C right-0">
<ul class="py-1">
<li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)"
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
{{ option.label }}
</li>
</ul>
</div>
</div>
</div>
<!-- Table Section -->
<div class="mt-5 overflow-x-auto">
<table class="w-full border-collapse border border-C rounded-md">
<thead>
<tr class="bg-C text-D rounded-t-md">
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('nama_produk')"
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
<span>Nama Produk</span>
<i :class="getSortIcon('nama_produk')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('jumlah_item_terjual')"
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
<span>Item Terjual</span>
<i :class="getSortIcon('jumlah_item_terjual')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('berat_terjual')"
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
<span>Total Berat</span>
<i :class="getSortIcon('berat_terjual')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3">
<button @click="handleSort('pendapatan')"
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
<span>Total Pendapatan</span>
<i :class="getSortIcon('pendapatan')" class="ml-2"></i>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="4" class="p-4">
<div class="flex items-center justify-center w-full h-30">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
<span class="ml-2 text-gray-600">Memuat data...</span>
</div>
</td>
</tr>
<tr v-else-if="!sortedProduk.length">
<td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
</tr>
<template v-else v-for="item in sortedProduk" :key="item.nama_produk">
<tr class="text-center border-y border-C hover:bg-A">
<td class="border-x border-C px-3 py-2 text-left">{{ item.nama_produk }}</td>
<td class="border-x border-C px-3 py-2">{{ item.jumlah_item_terjual }}</td>
<td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td>
<td class="border-x border-C px-3 py-2">
<div class="flex justify-center">
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle"
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
{{ item.pendapatan }}
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Pagination -->
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
Sebelumnya
</button>
<span class="text-sm text-D">
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
</span>
<button @click="goToPage(pagination.current_page + 1)"
:disabled="(pagination.current_page === pagination.last_page) || loading"
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
Berikutnya
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
import InputSelect from './InputSelect.vue';
import InputField from './InputField.vue';
import DatePicker from './DatePicker.vue';
import axios from 'axios';
// --- State ---
const isExportOpen = ref(false);
const exportDropdownRef = ref(null);
const exportOptions = ref([
{ value: 'pdf', label: 'Pdf' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'csv', label: 'Csv' }
]);
const exportFormat = ref(null);
const dateRange = ref({ start: '', end: '' });
const data = ref(null);
const loading = ref(false);
const loadingExport = ref(false);
// Sorting state
const sortBy = ref(null);
const sortOrder = ref('asc');
const pagination = ref({
current_page: 1,
last_page: 1,
total: 0,
});
const pendapatanWidth = ref(0);
const pendapatanElements = ref([]);
const salesDipilih = ref(0);
const opsiSales = ref([
{ label: 'Semua Sales', value: 0 },
]);
const nampanDipilih = ref(0);
const opsiNampan = ref([
{ label: 'Semua Nampan', value: 0 },
]);
const namaPembeli = ref(null);
// --- Computed ---
const produk = computed(() => data.value?.produk || []);
const sortedProduk = computed(() => {
if (!sortBy.value || !produk.value.length) {
return produk.value;
}
const sorted = [...produk.value].sort((a, b) => {
let aValue = a[sortBy.value];
let bValue = b[sortBy.value];
// Handle different data types
if (sortBy.value === 'nama_produk') {
// String comparison
aValue = aValue?.toString().toLowerCase() || '';
bValue = bValue?.toString().toLowerCase() || '';
} else if (sortBy.value === 'jumlah_item_terjual') {
// Numeric comparison
aValue = parseInt(aValue) || 0;
bValue = parseInt(bValue) || 0;
} else if (sortBy.value === 'berat_terjual') {
// Handle weight values (remove unit if exists)
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
} else if (sortBy.value === 'pendapatan') {
// Handle currency values (remove currency symbols and commas)
if (aValue === '-') aValue = 0;
if (bValue === '-') bValue = 0;
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
}
if (sortOrder.value === 'asc') {
if (typeof aValue === 'string') {
return aValue.localeCompare(bValue);
}
return aValue - bValue;
} else {
if (typeof aValue === 'string') {
return bValue.localeCompare(aValue);
}
return bValue - aValue;
}
});
return sorted;
});
const selectedExportLabel = computed(() => {
return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan';
});
const pendapatanStyle = computed(() => ({
minWidth: `${pendapatanWidth.value}px`,
padding: '0.5rem 0.75rem'
}));
// --- Watchers ---
watch(produk, async (newValue) => {
if (newValue && newValue.length > 0) {
await nextTick();
pendapatanElements.value = [];
let maxWidth = 0;
await nextTick();
pendapatanElements.value.forEach(el => {
if (el && el.scrollWidth > maxWidth) {
maxWidth = el.scrollWidth;
}
});
pendapatanWidth.value = maxWidth;
}
}, { deep: true });
// --- Methods ---
const handleSort = (column) => {
if (sortBy.value === column) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
} else {
sortBy.value = column;
sortOrder.value = 'asc';
}
};
const getSortIcon = (column) => {
if (sortBy.value !== column) {
return 'fas fa-sort text-D/40'; // Default sort icon
}
if (sortOrder.value === 'asc') {
return 'fas fa-sort-up text-D'; // Ascending
} else {
return 'fas fa-sort-down text-D'; // Descending
}
};
const handleDateChange = (newDateRange) => {
// console.log('Date range changed:', newDateRange);
// Reset pagination when date changes
pagination.value.current_page = 1;
fetchData(1);
};
const fetchSales = async () => {
try {
const response = await axios.get('/api/sales', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const salesData = response.data;
opsiSales.value = [
{ label: 'Semua Sales', value: 0 },
...salesData.map(sales => ({
label: sales.nama,
value: sales.id,
}))
];
} catch (error) {
console.error('Gagal mengambil data sales:', error);
}
};
const fetchNampan = async () => {
try {
const response = await axios.get('/api/nampan', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const nampanData = response.data;
opsiNampan.value = [
{ label: 'Semua Nampan', value: 0 },
{ label: 'Brankas', value: -1 },
...nampanData.map(nampan => ({
label: nampan.nama,
value: nampan.id,
}))
];
} catch (error) {
console.error('Gagal mengambil data nampan:', error);
}
};
const fetchData = async (page = 1) => {
if (!dateRange.value.start || !dateRange.value.end) return;
loading.value = true;
pendapatanElements.value = [];
let queryParams = `start_date=${dateRange.value.start}&end_date=${dateRange.value.end}&page=${page}`;
if (salesDipilih.value != 0 ) queryParams += `&sales_id=${salesDipilih.value}`;
if (nampanDipilih.value != 0) queryParams += `&nampan_id=${nampanDipilih.value}`;
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
try {
const response = await axios.get(`/api/laporan/detail-per-produk?${queryParams}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
}
});
data.value = response.data;
// Handle pagination data if provided by backend
if (response.data.pagination) {
pagination.value = {
current_page: response.data.pagination.current_page,
last_page: response.data.pagination.last_page,
total: response.data.pagination.total,
};
} else {
// Reset pagination if no pagination data
pagination.value = {
current_page: 1,
last_page: 1,
total: response.data.produk ? response.data.produk.length : 0,
};
}
// console.log('Data laporan produk berhasil diambil:', data.value);
} catch (error) {
console.error('Gagal mengambil data laporan produk:', error);
data.value = null;
pagination.value = {
current_page: 1,
last_page: 1,
total: 0,
};
} finally {
loading.value = false;
}
};
const goToPage = (page) => {
if (page >= 1 && page <= pagination.value.last_page) {
pagination.value.current_page = page;
fetchData(page);
}
};
const selectExport = async (option) => {
if (!dateRange.value.start || !dateRange.value.end) {
alert('Silakan pilih rentang tanggal terlebih dahulu');
return;
}
exportFormat.value = option.value;
isExportOpen.value = false;
loadingExport.value = true;
try {
const response = await axios.get('/api/laporan/export/detail-perproduk', {
params: {
start_date: dateRange.value.start,
end_date: dateRange.value.end,
format: exportFormat.value,
page: pagination.value.current_page,
sales_id: salesDipilih.value != 0 ? salesDipilih.value : null,
nampan_id: nampanDipilih.value != 0 ? nampanDipilih.value : null,
nama_pembeli: namaPembeli.value || null,
},
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = `laporan_per_produk_${dateRange.value.start}_to_${dateRange.value.end}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e) {
console.error("Gagal mengekspor laporan per produk:", e);
alert('Gagal mengekspor laporan. Silakan coba lagi.');
} finally {
loadingExport.value = false;
}
};
const closeDropdownsOnClickOutside = (event) => {
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
isExportOpen.value = false;
}
};
// --- Lifecycle Hooks ---
onMounted(() => {
// Set default date range to today
const today = new Date().toISOString().split('T')[0];
dateRange.value = { start: today, end: today };
fetchSales();
fetchNampan();
document.addEventListener('click', closeDropdownsOnClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', closeDropdownsOnClickOutside);
});
// Watch for filter changes (except date range which has its own handler)
watch([salesDipilih, nampanDipilih, namaPembeli], () => {
if (dateRange.value.start && dateRange.value.end) {
pagination.value.current_page = 1; // Reset to first page when filters change
fetchData(1);
}
});
// Watch for date range changes
watch(dateRange, (newDateRange) => {
if (newDateRange.start && newDateRange.end) {
pagination.value.current_page = 1;
fetchData(1);
}
}, { deep: true, immediate: true });
</script>

View File

@ -0,0 +1,210 @@
<template>
<div class="fixed inset-0 flex items-center justify-center bg-black/65 z-50">
<div class="bg-white rounded-lg p-6 w-96 shadow-lg">
<h2 class="text-lg font-bold mb-4">Edit Akun</h2>
<form @submit.prevent="updateAkun" class="space-y-3">
<!-- Nama -->
<div>
<label for="nama" class="block text-sm font-medium">Nama</label>
<InputField
v-model="form.nama"
id="nama"
type="text"
:required="true"
@input="clearError('nama')"
/>
<p v-if="errors.nama" class="text-red-500 text-sm">{{ errors.nama }}</p>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium">Password</label>
<InputPassword
v-model="form.password"
id="password"
type="password"
:required="false"
@input="clearError('password')"
/>
<p class="text-sm">Kosongkan jika tidak ingin ubah password</p>
<p v-if="errors.password" class="text-red-500 text-sm">{{ errors.password }}</p>
</div>
<!-- Confirm Password -->
<div v-if="form.password">
<label for="confirmPassword" class="block text-sm font-medium">Konfirmasi Password</label>
<InputPassword
v-model="form.confirmPassword"
id="confirmPassword"
type="password"
:required="false"
@input="clearError('confirmPassword')"
/>
<p v-if="errors.confirmPassword" class="text-red-500 text-sm">
{{ errors.confirmPassword }}
</p>
</div>
<!-- Role -->
<div>
<label for="role" class="block text-sm font-medium">Peran</label>
<!-- 🔒 Kalau akun sendiri tampil readonly -->
<template v-if="isEditingSelf">
<p class="mt-1 px-3 py-2 border rounded bg-gray-100 text-gray-700">
{{ form.role === 'owner' ? 'Owner' : 'Kasir' }}
</p>
</template>
<!-- 🔓 Kalau akun lain bisa diubah -->
<template v-else>
<InputSelect
v-model="form.role"
:options="[
{ value: 'owner', label: 'Owner' },
{ value: 'kasir', label: 'Kasir' }
]"
placeholder="-- Pilih Peran --"
@change="clearError('role')"
/>
<p v-if="errors.role" class="text-red-500 text-sm">{{ errors.role }}</p>
</template>
</div>
<!-- Tombol -->
<div class="flex justify-end gap-2 mt-4">
<button
type="button"
@click="$emit('close')"
class="bg-gray-300 hover:bg-gray-400 px-4 py-2 rounded"
>
Batal
</button>
<button
type="submit"
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!isFormValid"
>
Ubah
</button>
</div>
</form>
<!-- Error global -->
<p v-if="errorMessage" class="text-red-500 text-sm mt-3">
{{ errorMessage }}
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import axios from "axios";
import InputField from "@/components/InputField.vue";
import InputSelect from "@/components/InputSelect.vue";
import InputPassword from "./InputPassword.vue";
const props = defineProps({
akun: {
type: Object,
required: true,
},
});
const emit = defineEmits(["refresh", "close"]);
const form = ref({
nama: props.akun?.nama || "",
password: "",
confirmPassword: "",
role: props.akun?.role || "",
});
const errors = ref({ nama: "", password: "", confirmPassword: "", role: "" });
const errorMessage = ref("");
const loggedInId = ref(localStorage.getItem("userId")); // 🔥 ambil dari localStorage
const isFormValid = computed(() => {
if (form.value.password && form.value.password !== form.value.confirmPassword) {
return false;
}
return (
form.value.nama.trim() &&
form.value.role &&
!errors.value.nama &&
!errors.value.password &&
!errors.value.confirmPassword &&
!errors.value.role
);
});
// 🔥 ini cek apakah akun yang diedit adalah akun sendiri
const isEditingSelf = computed(() => {
return String(props.akun.id) === String(loggedInId.value);
});
const clearError = (field) => {
errors.value[field] = "";
errorMessage.value = "";
};
const validateForm = () => {
let valid = true;
errors.value = { nama: "", password: "", confirmPassword: "", role: "" };
if (!form.value.nama) {
errors.value.nama = "Nama wajib diisi";
valid = false;
}
if (form.value.password && form.value.password.length < 6) {
errors.value.password = "Password minimal 6 karakter";
valid = false;
}
if (form.value.password && form.value.password !== form.value.confirmPassword) {
errors.value.confirmPassword = "Konfirmasi password tidak cocok";
valid = false;
}
if (!form.value.role) {
errors.value.role = "Role wajib dipilih";
valid = false;
}
return valid;
};
const updateAkun = async () => {
if (!validateForm()) return;
try {
const payload = { ...form.value };
if (!payload.password) delete payload.password;
delete payload.confirmPassword;
await axios.put(`/api/user/${props.akun.id}`, payload, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
emit("refresh");
emit("close");
} catch (err) {
if (err.response?.status === 422 && err.response.data.errors) {
const backendErrors = err.response.data.errors;
Object.keys(backendErrors).forEach((key) => {
errors.value[key] = backendErrors[key][0];
});
} else {
errorMessage.value = err.response?.data?.message || "Gagal update akun.";
}
console.error("Gagal update akun:", err);
}
};
onMounted(() => {
// console.log("Akun.id:", props.akun.id);
// console.log("LoggedInId:", loggedInId.value);
// console.log("isEditingSelf:", isEditingSelf.value);
});
</script>

View File

@ -0,0 +1,66 @@
<template>
<div class="fixed inset-0 flex items-center justify-center bg-black/65 z-50">
<div class="bg-white rounded-lg shadow-lg w-[400px] p-6 relative">
<!-- Tombol close -->
<button @click="$emit('close')" class="absolute top-3 right-3 text-gray-600 hover:text-black">
</button>
<!-- Judul -->
<h2 class="text-xl font-bold text-center text-D mb-4">Edit Kategori</h2>
<!-- Input Nama Kategori -->
<div>
<label for="editKategori" class="block text-sm font-medium text-D mb-1">Nama Kategori</label>
<InputField
v-model="editNamaKategori"
type="text"
id="editKategori"
placeholder="Masukkan nama kategori"
/>
</div>
<!-- Tombol Aksi -->
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400">
Batal
</button>
<button @click="updateKategori" class="px-4 py-2 bg-B text-D rounded-md hover:bg-A">
Ubah
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
import InputField from "./InputField.vue";
const props = defineProps({
kategori: { type: Object, required: true },
});
const editNamaKategori = ref("");
watch(
() => props.kategori,
(newVal) => {
if (newVal) {
editNamaKategori.value = newVal.nama;
}
},
{ immediate: true }
);
const updateKategori = () => {
if (editNamaKategori.value.trim() === "") {
alert("Nama kategori tidak boleh kosong!");
return;
}
// Emit hasil update ke parent
emit("update", { ...props.kategori, nama: editNamaKategori.value });
};
</script>

View File

@ -0,0 +1,78 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/65 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg shadow-lg w-full max-w-lg p-6 relative">
<h2 class="text-xl font-bold mb-4">Ubah Sales</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Nama Sales</label>
<InputField v-model="form.nama" type="text" class="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-[#c6a77d] focus:outline-none" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">No HP</label>
<InputField v-model="form.no_hp" type="text" class="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-[#c6a77d] focus:outline-none" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Alamat</label>
<textarea
v-model="form.alamat"
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2"
required
></textarea>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" @click="$emit('close')" class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">Batal</button>
<button type="submit" class="px-4 py-2 bg-C text-D rounded hover:bg-C">Ubah</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
import axios from "axios";
import InputField from "./InputField.vue";
const props = defineProps({
isOpen: Boolean,
sales: Object,
});
const emit = defineEmits(["close"]);
const form = ref({
nama: "",
no_hp: "",
alamat: "",
});
watch(
() => props.sales,
(val) => {
if (val) {
form.value = { ...val };
}
},
{ immediate: true }
);
const handleSubmit = async () => {
try {
await axios.put(`/api/sales/${props.sales.id}`, form.value, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});;
emit("close");
} catch (error) {
console.error("Error updating sales:", error);
}
};
</script>

View File

@ -0,0 +1,26 @@
<template>
<footer class="bg-B py-4 px-6 flex flex-col md:flex-row items-center justify-between">
<!-- Left: Logo -->
<div class="flex items-center gap-2">
<img :src="logo" alt="Logo" class="h-10">
</div>
<!-- Center: Copyright -->
<div class="text-sm text-D font-medium text-center">
Abbauf Tech © 2025 Semua hak dilindungi
</div>
<!-- Right: Social Icons -->
<div class="flex items-center gap-4 text-D mt-2 md:mt-0">
<a href="#" class="hover:text-sky-600"><i class="fab fa-facebook"></i></a>
<a href="#" class="hover:text-sky-600"><i class="fab fa-twitter"></i></a>
<a href="#" class="hover:text-sky-600"><i class="fab fa-instagram"></i></a>
<a href="#" class="hover:text-sky-600"><i class="fab fa-youtube"></i></a>
<a href="#" class="hover:text-sky-600"><i class="fab fa-vk"></i></a>
</div>
</footer>
</template>
<script setup>
import logo from '@/../images/logo.png'
</script>

View File

@ -1,17 +0,0 @@
<script setup>
const items = ['Manajemen Produk', 'Kasir', 'Laporan', 'Akun'];
</script>
<template>
<div class="h-25 shadow-lg shadow-D rounded-b-md">
<div class="bg-D h-5 rounded-b-md shadow-lg">
<div class="h-15"></div>
<div class="w-full px-50 flex justify-between items-center h-5">
<router-link to="/" v-for="item in items"
class="text-center text-lg text-D hover:underline cursor-pointer">
{{ item }}
</router-link>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,28 @@
<template>
<input
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:placeholder="placeholder"
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:outline-none focus:ring-D focus:ring-opacity-50 p-2"
/>
</template>
<script setup>
const props = defineProps({
type: {
type: String,
default: 'text',
},
modelValue: {
type: [String, Number],
default: '',
},
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
</script>

View File

@ -0,0 +1,46 @@
<template>
<div class="relative mb-1">
<input
:type="showPassword ? 'text' : 'password'"
:value="modelValue"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm
bg-A text-D border-B focus:border-C
focus:ring focus:ring-D focus:ring-opacity-50 p-2 pr-10"
/>
<!-- Tombol show/hide password -->
<button
type="button"
@click="togglePassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
>
<i v-if="showPassword" class="fas fa-eye"></i>
<i v-else class="fas fa-eye-slash"></i>
</button>
</div>
</template>
<script setup>
import { ref } from "vue";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "Password",
},
});
const emit = defineEmits(["update:modelValue"]);
const showPassword = ref(false);
const togglePassword = () => {
showPassword.value = !showPassword.value;
};
</script>

View File

@ -0,0 +1,31 @@
<template>
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2"
>
<option value="" :disabled="!modelValue && placeholder" v-if="placeholder" class="hover:bg-C text-D">{{ placeholder }}</option>
<option v-for="option in options" :key="option.value" :selected="option.selected" :value="option.value" class="hover:bg-C text-D">
{{ option.label }}
</option>
</select>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
placeholder: {
type: String,
default: '',
},
options: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
</script>

View File

@ -0,0 +1,306 @@
<template>
<ConfirmDeleteModal v-if="showDeleteModal" :isOpen="showDeleteModal" title="Konfirmasi"
message="Yakin ingin menghapus item ini?" @confirm="hapusPesanan" @cancel="closeDeleteModal" />
<!-- ==== TAMBAHAN: Struk Overlay ==== -->
<StrukOverlay v-if="showStruk" :isOpen="showStruk" :pesanan="pesanan" :total="total" @close="closeStruk" />
<!-- ==== END TAMBAHAN ==== -->
<div class="p-2 sm:p-4">
<!-- Grid Form & Total -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- Input Form -->
<div class="order-2 md:order-1 flex flex-col gap-4">
<!-- Input Kode Item -->
<div>
<label class="block text-sm font-medium text-D">Kode Item *</label>
<div class="flex flex-row justify-between mt-1 w-full rounded-md bg-A shadow-sm sm:text-sm border-B">
<input type="text" v-model="kodeItem" @keyup.enter="inputItem" placeholder="Scan atau masukkan kode item"
class="bg-A focus:outline-none focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full rounded-l-md" />
<button v-if="!loadingItem" @click="inputItem" class="px-3 bg-D hover:bg-D/80 text-A rounded-r-md">
<i class="fas fa-arrow-right"></i>
</button>
<div v-else class="flex items-center justify-center px-3">
<div class="rounded-full h-5 w-5 border-b-2 border-A flex items-center justify-center">
<i class="fas fa-spinner"></i>
</div>
</div>
</div>
</div>
<!-- Input Harga Jual -->
<div>
<label class="block text-sm font-medium text-D">Harga Jual</label>
<input
type="text"
v-model="hargaJualFormatted"
@input="formatHargaInput"
@keypress="onlyNumbers"
placeholder="Masukkan Harga Jual"
class="bg-A focus:outline-none focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full rounded-md border border-B shadow-sm sm:text-sm"
/>
</div>
<!-- Tombol Aksi -->
<div class="flex flex-col sm:flex-row justify-between gap-2">
<button @click="tambahItem"
class="w-full sm:w-auto px-4 py-2 rounded-md bg-C text-D font-medium hover:bg-C/80 transition">
Tambah Item
</button>
<button @click="konfirmasiPenjualan"
class="w-full sm:w-auto px-6 py-2 rounded-md bg-D text-A font-semibold hover:bg-D/80 transition">
Lanjut
</button>
</div>
</div>
<!-- Total -->
<div class="order-1 md:order-2 flex flex-col md:flex-row md:items-center md:justify-center gap-1">
<div class="text-left md:text-start">
<span class="block text-gray-600 font-medium">Total:</span>
<span class="text-2xl sm:text-3xl font-bold text-D">
Rp{{ total.toLocaleString() }},-
</span>
</div>
</div>
</div>
<!-- Error & Info -->
<div class="mb-4">
<p v-if="error" :class="{ 'animate-shake': error }" class="text-sm text-red-600 mt-1">
{{ error }}
</p>
<p v-if="info" class="text-sm text-C mt-1">{{ info }}</p>
</div>
<!-- Table Responsive -->
<div class="overflow-x-auto">
<table class="w-full border border-B rounded-lg overflow-hidden text-xs sm:text-sm">
<thead class="bg-A text-D">
<tr>
<th class="border border-B p-2 w-8">No</th>
<th class="border border-B p-2">Nama Produk</th>
<th class="border border-B p-2">Posisi</th>
<th class="border border-B p-2">Harga</th>
<th class="border border-B p-2 w-10"></th>
</tr>
</thead>
<tbody>
<tr v-if="pesanan.length == 0" class="text-center text-D/70">
<td colspan="5" class="h-16 border border-B text-xs sm:text-sm">
Belum ada item dipesan
</td>
</tr>
<tr v-else v-for="(item, index) in pesanan" :key="index" class="hover:bg-gray-50 text-center">
<td class="border border-B p-2">{{ index + 1 }}</td>
<td class="border border-B p-2 text-left truncate max-w-[120px] sm:max-w-none">
{{ item.produk.nama }}
</td>
<td class="border border-B p-2 truncate max-w-[80px]">
{{ item.nampan ? item.nampan.nama : "Brankas" }}
</td>
<td class="border border-B p-2 whitespace-nowrap">
Rp{{ item.harga_deal.toLocaleString() }}
</td>
<td class="border border-B p-2 text-center">
<button @click="openDeleteModal(index)" class="text-red-500 hover:text-red-700">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import InputField from "./InputField.vue";
import axios from "axios";
import ConfirmDeleteModal from "./ConfirmDeleteModal.vue";
import StrukOverlay from "./StrukOverlay.vue";
const kodeItem = ref("");
const info = ref("");
const error = ref("");
const hargaJual = ref(null);
const hargaJualFormatted = ref("");
const item = ref(null);
const loadingItem = ref(false);
const pesanan = ref([]);
const showDeleteModal = ref(false)
const deleteIndex = ref(null)
const showStruk = ref(false);
let errorTimeout = null;
let infoTimeout = null;
// Format angka dengan pemisah ribuan
const formatNumber = (num) => {
if (!num) return "";
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
};
// Menghapus format dan mengambil angka asli
const unformatNumber = (str) => {
if (!str) return null;
const cleaned = str.replace(/\./g, "");
const number = parseInt(cleaned);
return isNaN(number) ? null : number;
};
// Handler untuk format input harga
const formatHargaInput = (event) => {
const value = event.target.value;
// Hapus semua karakter selain angka
const cleanValue = value.replace(/\D/g, "");
if (cleanValue) {
// Format dengan pemisah ribuan
const formatted = formatNumber(cleanValue);
hargaJualFormatted.value = formatted;
hargaJual.value = parseInt(cleanValue);
} else {
hargaJualFormatted.value = "";
hargaJual.value = null;
}
};
// Hanya izinkan angka saat mengetik
const onlyNumbers = (event) => {
const char = String.fromCharCode(event.which);
if (!/[0-9]/.test(char)) {
event.preventDefault();
}
};
const inputItem = async () => {
if (!kodeItem.value) return;
info.value = "";
error.value = "";
clearTimeout(infoTimeout);
clearTimeout(errorTimeout);
loadingItem.value = true;
try {
const response = await axios.get(`/api/item/${kodeItem.value}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
item.value = response.data;
hargaJual.value = item.value.produk.harga_jual;
// Format harga untuk tampilan
hargaJualFormatted.value = formatNumber(item.value.produk.harga_jual);
// console.log(item.value);
if (item.value.is_sold) {
throw new Error("Item sudah terjual");
}
if (pesanan.value.some((p) => p.id === item.value.id)) {
throw new Error("Item sedang dipesan");
}
info.value = `Item dipilih: ${item.value.produk.nama} dari ${item.value.nampan ? 'Nampan ' + item.value.nampan.nama : "Brankas"}`;
infoTimeout = setTimeout(() => {
info.value = "";
}, 5000);
} catch (err) {
error.value = "Item tidak ditemukan";
info.value = "";
hargaJual.value = null;
hargaJualFormatted.value = "";
item.value = null;
errorTimeout = setTimeout(() => {
error.value = "";
}, 5000);
} finally {
loadingItem.value = false;
}
};
const tambahItem = () => {
if (!item.value || !hargaJual.value) {
error.value = "Scan atau masukkan kode item untuk dijual.";
if (kodeItem.value) {
error.value =
"Masukkan harga jual, atau input dari kode item lagi.";
}
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
error.value = "";
}, 5000);
return;
}
// harga deal
item.value.kode_item = Number(kodeItem.value);
item.value.harga_deal = Number(hargaJual.value);
item.value.posisi = item.value.nampan ? item.value.nampan.nama : "Brankas";
pesanan.value.push(item.value);
// Reset input fields
kodeItem.value = "";
hargaJual.value = null;
hargaJualFormatted.value = "";
item.value = null;
info.value = "";
clearTimeout(infoTimeout);
};
const openDeleteModal = (index) => {
deleteIndex.value = index
showDeleteModal.value = true
}
const closeDeleteModal = () => {
showDeleteModal.value = false
deleteIndex.value = null
}
const hapusPesanan = () => {
if (deleteIndex.value !== null) {
pesanan.value.splice(deleteIndex.value, 1)
}
closeDeleteModal()
}
// ==== MODIFIKASI: konfirmasiPenjualan sekarang menampilkan struk ====
const konfirmasiPenjualan = () => {
if (pesanan.value.length === 0) {
error.value = "Belum ada item yang dipesan.";
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
error.value = "";
}, 5000);
return;
}
// Tampilkan struk overlay
showStruk.value = true;
};
// ==== END MODIFIKASI ====
// ==== TAMBAHAN: Fungsi untuk menutup struk ====
const closeStruk = () => {
showStruk.value = false;
};
// ==== END TAMBAHAN ====
const total = computed(() => {
let sum = 0;
pesanan.value.forEach((item) => {
sum += item.harga_deal;
});
return sum;
});
</script>

View File

@ -0,0 +1,222 @@
<template>
<div class="space-y-3">
<!-- Summary Card -->
<div class="mt-3 bg-A border border-C rounded-lg p-3">
<div class="flex items-center justify-between text-sm">
<span class="text-D font-medium">Transaksi Hari Ini</span>
<span class="text-D-700 font-semibold">
Rp{{ totalPendapatan.toLocaleString() }}
</span>
</div>
<div class="flex items-center justify-between text-xs text-D mt-1">
<span>{{ transaksi.length }} transaksi</span>
<span>{{ totalItems }} item terjual</span>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<span class="ml-2 text-gray-600 text-sm">Memuat transaksi hari ini...</span>
</div>
<!-- Table -->
<div v-else-if="transaksi.length > 0" class="overflow-x-auto">
<table class="w-full min-w-[500px] border border-gray-200 rounded-lg text-sm">
<thead class="bg-C text-D text-center">
<tr>
<th class="border border-gray-200 p-2">Waktu</th>
<th class="border border-gray-200 p-2">Kode Transaksi</th>
<th class="border border-gray-200 p-2">Total</th>
<th class="border border-gray-200 p-2">Aksi</th>
</tr>
</thead>
<tbody>
<tr v-for="trx in transaksi" :key="trx.id" class="hover:bg-gray-50 border-b border-gray-100">
<td class="border border-gray-200 p-2">
<div class="text-xs space-y-1">
<div class="font-medium text-gray-600">{{ formatTime(trx.created_at) }}</div>
</div>
</td>
<td class="border border-gray-200 p-2">
{{ trx.kode_transaksi }}
</td>
<td class="border border-gray-200 p-2 text-right">
<span class="text-sm">
Rp{{ (trx.pendapatan || 0).toLocaleString() }}
</span>
</td>
<td class="border border-gray-200 p-2 text-center">
<button
@click="lihatDetail(trx)"
class="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-xs whitespace-nowrap"
:disabled="isDetailLoading && selectedTransaksi.id === trx.id"
>
<span v-if="isDetailLoading && selectedTransaksi.id === trx.id">
<i class="fas fa-spinner fa-spin mr-1"></i>
Loading...
</span>
<span v-else>Lihat Detail</span>
</button>
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
</div>
<div v-if="pagination" class="mt-2 p-2 bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-center justify-between text-xs text-gray-600">
<div class="flex items-center gap-2">
<span>Menampilkan {{ pagination.from }} - {{ pagination.to }} dari {{ pagination.total }} transaksi hari ini</span>
</div>
<div class="flex items-center gap-1">
<button
@click="$emit('page-change', pagination.current_page - 1)"
:disabled="pagination.current_page === 1"
class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<span class="px-2 text-gray-700">
{{ pagination.current_page }} / {{ pagination.last_page }}
</span>
<button
@click="$emit('page-change', pagination.current_page + 1)"
:disabled="pagination.current_page === pagination.last_page"
class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="!loading" class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
<div class="text-gray-500 space-y-3">
<svg class="w-16 h-16 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900">Belum ada transaksi</p>
<p class="text-xs text-gray-500">Hari ini masih sepi...</p>
</div>
</div>
</div>
<!-- Modal Detail Transaksi -->
<StrukView
:is-open="isDetailOpen"
:transaksi="selectedTransaksi"
@close="closeDetail"
/>
<!-- Loading Detail Modal -->
<div v-if="isDetailLoading" class="fixed inset-0 bg-black/75 flex items-center justify-center z-[10000]">
<div class="bg-white p-6 rounded-lg flex items-center gap-3 shadow-xl max-w-sm w-full mx-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<span class="text-gray-700 text-sm">Memuat detail transaksi...</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import axios from 'axios'
import StrukView from './StrukView.vue'
const props = defineProps({
transaksi: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
pagination: {
type: Object,
default: null
}
})
const emit = defineEmits(['page-change'])
// Modal state
const isDetailOpen = ref(false)
const selectedTransaksi = ref({})
const isDetailLoading = ref(false)
// Computed properties
const totalPendapatan = computed(() => {
return props.transaksi.reduce((total, trx) => total + (trx.pendapatan || 0), 0)
})
const totalItems = computed(() => {
return props.transaksi.reduce((total, trx) => total + (trx.total_items || 0), 0)
})
// Format functions
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('id-ID', {
weekday: 'short',
day: 'numeric',
month: 'short'
})
}
const formatTime = (dateString) => {
return new Date(dateString).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
})
}
// Lihat detail transaksi
const lihatDetail = async (trx) => {
try {
isDetailLoading.value = true
// console.log('Fetching detail untuk transaksi:', trx.kode_transaksi)
const response = await axios.get(`/api/transaksi/${trx.id}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
})
// console.log('Response detail transaksi:', response.data)
selectedTransaksi.value = response.data
isDetailOpen.value = true
} catch (error) {
console.error('Error fetching transaksi detail:', error)
let errorMessage = 'Gagal memuat detail transaksi'
if (error.response) {
errorMessage += `: ${error.response.status} - ${error.response.data?.message || error.response.statusText}`
} else if (error.request) {
errorMessage += ': Tidak ada respon dari server'
} else {
errorMessage += `: ${error.message}`
}
alert(errorMessage)
} finally {
isDetailLoading.value = false
}
}
// Tutup modal detail
const closeDetail = () => {
isDetailOpen.value = false
selectedTransaksi.value = {}
}
</script>

View File

@ -0,0 +1,96 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="active"
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
@click="handleOverlayClick"
>
<div
class="bg-white rounded-lg shadow-2xl max-h-[90vh] overflow-y-auto relative"
:class="sizeClass"
@click.stop
>
<slot></slot>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, watch, onBeforeUnmount } from 'vue'
const props = defineProps({
active: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', 'full'].includes(value)
},
clickOutside: {
type: [Boolean, String],
default: true
}
})
const emit = defineEmits(['close'])
const sizeClass = computed(() => {
const sizes = {
xs: 'w-full max-w-xs',
sm: 'w-full max-w-sm',
md: 'w-full max-w-md',
lg: 'w-full max-w-lg',
xl: 'w-full max-w-xl',
'2xl': 'w-full max-w-2xl',
'3xl': 'w-full max-w-3xl',
'4xl': 'w-full max-w-4xl',
full: 'w-[95vw] h-[95vh] max-w-none max-h-none'
}
return sizes[props.size] || sizes.md
})
const handleOverlayClick = () => {
if (clickOutside.value) {
emit('close')
}
}
watch(() => props.active, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .bg-white,
.modal-leave-to .bg-white {
transform: scale(0.95) translateY(-20px);
}
.modal-enter-active .bg-white,
.modal-leave-active .bg-white {
transition: transform 0.3s ease;
}
</style>

View File

@ -0,0 +1,96 @@
<script setup>
import { inject } from "vue";
import { useRoute } from "vue-router";
const {
logo,
items,
openDropdownIndex,
toggleDropdown,
logout
} = inject('navigationData');
const route = useRoute();
// Function to check if a menu item or its subItems are active
const isMenuActive = (item) => {
if (item.route) {
return route.path === item.route;
}
if (item.subItems) {
return item.subItems.some(sub => route.path === sub.route);
}
return false;
};
</script>
<template>
<!-- Desktop Navbar -->
<div class="hidden md:block shadow-lg shadow-D rounded-b-md">
<div class="bg-D h-5 rounded-b-md shadow-lg"></div>
<div class="relative rounded-b-md shadow-lg">
<!-- Logo Row -->
<div class="flex justify-center items-center">
<img :src="logo" alt="Logo" class="h-12 w-auto" />
</div>
<!-- Menu Row -->
<div class="px-8 pb-4">
<div class="flex justify-around items-center gap-4">
<template v-for="(item, index) in items" :key="index">
<div v-if="item.subItems" class="relative flex-1">
<button
@click="toggleDropdown(index)"
:class="[
'w-full text-center text-lg text-D hover:underline cursor-pointer flex items-center justify-center gap-2 transition-colors duration-200 py-2',
{ 'underline underline-offset-4': isMenuActive(item) }
]">
{{ item.label }}
<svg :class="{ 'rotate-180': openDropdownIndex === index }"
class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-if="openDropdownIndex === index"
class="absolute mt-2 w-full mx-4 bg-white border rounded-md shadow-lg z-50">
<ul>
<li v-for="(sub, subIndex) in item.subItems" :key="subIndex"
class="hover:bg-A transition-colors duration-200">
<router-link
:to="sub.route"
@click="openDropdownIndex = null"
:class="[
'block w-full h-full px-4 py-2 text-D',
{ 'underline underline-offset-4': route.path === sub.route }
]">
{{ sub.label }}
</router-link>
</li>
</ul>
</div>
</div>
<router-link
v-else
:to="item.route"
:class="[
'flex-1 text-center text-lg text-D hover:underline cursor-pointer transition-colors duration-200 py-2',
{ 'underline underline-offset-4': isMenuActive(item) }
]">
{{ item.label }}
</router-link>
</template>
</div>
</div>
<div class="absolute top-4 right-8">
<button @click="logout"
class="text-center font-bold text-lg text-red-400 hover:underline hover:text-red-600 cursor-pointer transition-colors duration-200">
Logout
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,112 @@
<script setup>
import { inject } from "vue";
import { useRoute } from "vue-router";
const {
logo,
items,
isMobileMenuOpen,
openDropdownIndex,
toggleDropdown,
toggleMobileMenu,
closeMobileMenu,
logout
} = inject('navigationData');
const route = useRoute();
// Function to check if a menu item or its subItems are active
const isMenuActive = (item) => {
if (item.route) {
return route.path === item.route;
}
if (item.subItems) {
return item.subItems.some(sub => route.path === sub.route);
}
return false;
};
</script>
<template>
<div class="md:hidden">
<div class="bg-D h-5 shadow-lg"></div>
<button @click="toggleMobileMenu"
:class="{ 'hidden': isMobileMenuOpen, 'block': !isMobileMenuOpen }"
class="fixed top-4 left-4 text-D bg-C hover:bg-B transition-colors duration-200 p-0.5 rounded-sm z-[9999]">
<svg class="w-7 h-7" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div :class="{ 'translate-x-0': isMobileMenuOpen, '-translate-x-full': !isMobileMenuOpen }"
class="fixed inset-y-0 left-0 w-64 bg-A transform transition-transform duration-300 ease-in-out z-50 shadow-xl">
<div class="px-4 py-3 flex justify-between items-center border-b border-B">
<img :src="logo" alt="Logo" class="h-8 w-auto" />
<button @click="closeMobileMenu" class="text-D hover:text-red-500 transition-colors duration-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav class="py-4">
<template v-for="(item, index) in items" :key="index">
<div v-if="item.subItems" class="px-4 py-2">
<button @click="toggleDropdown(index)"
:class="[
'w-full flex justify-between items-center text-left text-lg text-D hover:bg-B rounded-md px-3 py-2 transition-colors duration-200',
{ 'bg-C': isMenuActive(item) }
]">
<span>{{ item.label }}</span>
<svg :class="{ 'rotate-180': openDropdownIndex === index }" class="w-4 h-4 transition-transform duration-200"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<transition
enter-active-class="transition-all ease-in-out duration-300"
enter-from-class="transform opacity-0 max-h-0"
enter-to-class="transform opacity-100 max-h-96"
leave-active-class="transition-all ease-in-out duration-200"
leave-from-class="transform opacity-100 max-h-96"
leave-to-class="transform opacity-0 max-h-0"
>
<div v-if="openDropdownIndex === index" class="mt-2 ml-4 space-y-1 overflow-hidden">
<router-link v-for="(sub, subIndex) in item.subItems" :key="subIndex" :to="sub.route"
@click="closeMobileMenu"
:class="[
'block px-3 py-2 text-D hover:bg-B rounded-md transition-colors duration-200',
{ 'bg-C': route.path === sub.route }
]">
{{ sub.label }}
</router-link>
</div>
</transition>
</div>
<div v-else class="px-4">
<router-link :to="item.route" @click="closeMobileMenu"
:class="[
'block px-3 py-2 text-lg text-D hover:bg-B rounded-md transition-colors duration-200',
{ 'bg-C': isMenuActive(item) }
]">
{{ item.label }}
</router-link>
</div>
</template>
</nav>
<div class="absolute bottom-0 w-full px-4 py-3 bg-A border-t border-B">
<button @click="logout"
class="block w-full text-left px-3 py-2 text-lg font-bold text-red-400 hover:text-white hover:bg-red-400 rounded-md transition-colors duration-200">
Logout
</button>
</div>
</div>
<div v-if="isMobileMenuOpen" @click="closeMobileMenu" class="fixed inset-0 bg-black/75 z-40"></div>
</div>
</template>

View File

@ -0,0 +1,109 @@
<script setup>
import { ref, provide, computed } from "vue";
import NavDesktop from "./NavDesktop.vue";
import NavMobile from "./NavMobile.vue";
import logo from "../../images/logo.png";
import axios from "axios";
const isOpen = ref(false);
const isMobileMenuOpen = ref(false);
const openDropdownIndex = ref(null);
const baseItems = [
{
label: "Manajemen Produk",
subItems: [
{ label: "Produk", route: "/produk" },
{ label: "Nampan", route: "/nampan" },
{ label: "Brankas", route: "/brankas" },
{ label: "Kategori", route: "/kategori" },
{ label: "Sales", route: "/sales" },
]
},
{ label: "Kasir", route: "/kasir" },
{ label: "Laporan", route: "/laporan" },
{ label: "Akun", route: "/akun" },
];
const role = localStorage.getItem("role");
const items = computed(() => {
if (role === "owner") {
return baseItems;
}
if (role === "kasir") {
return baseItems.filter(item => !["Akun", "Laporan"].includes(item.label));
}
return baseItems;
});
const toggleDropdown = (index = null) => {
if (index !== null) {
openDropdownIndex.value = openDropdownIndex.value === index ? null : index;
} else {
isOpen.value = !isOpen.value;
}
};
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value;
isOpen.value = false;
openDropdownIndex.value = null;
};
const closeMobileMenu = () => {
isMobileMenuOpen.value = false;
isOpen.value = false;
openDropdownIndex.value = null;
};
const logout = async () => {
try {
await axios.post("/api/logout", null, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
localStorage.removeItem("token");
localStorage.removeItem("role");
window.location.href = "/";
} catch (error) {
if (error.response && error.response.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("role");
window.location.href = "/";
} else {
console.error("Logout failed:", error);
}
}
closeMobileMenu();
};
// Provide shared data to child components
provide("navigationData", {
logo,
items,
isOpen,
isMobileMenuOpen,
openDropdownIndex,
toggleDropdown,
toggleMobileMenu,
closeMobileMenu,
logout
});
</script>
<template>
<div class="relative">
<!-- Desktop Navigation -->
<NavDesktop />
<!-- Mobile Navigation -->
<NavMobile />
<!-- Click Outside Handler for Desktop Dropdown -->
<div v-if="openDropdownIndex !== null && !isMobileMenuOpen" @click="openDropdownIndex = null"
class="fixed inset-0 z-10"></div>
</div>
</template>

View File

@ -1,53 +1,49 @@
<template>
<div>
<div class="relative">
<!-- Card Produk -->
<div
class="border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer"
@click="showDetail = true"
class="relative z-0 border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer overflow-hidden"
@click="$emit('click', product.id)"
>
<!-- Foto Produk -->
<img
v-if="product.foto && product.foto.length > 0"
:src="product.foto[0].url"
:alt="product.nama"
class="w-full h-full object-cover"
/>
<span v-else class="text-gray-400 text-sm">[tidak ada foto]</span>
<!-- Nama Produk di bawah -->
<div
class="absolute bottom-0 w-full bg-black/60 text-white text-center text-sm py-1"
>
<span class="text-gray-700 font-medium text-center px-2">
{{ product.nama }}
</span>
</div>
</div>
<!-- Overlay Detail -->
<!-- Notifikasi Stok Menipis (di luar card) -->
<div
v-if="showDetail"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
v-if="isStockLow"
class="absolute -top-2 -right-2 group z-20"
>
<div
class="bg-white rounded-lg shadow-lg w-[90%] max-w-md p-6 relative"
>
<!-- Tombol Close -->
<button
class="absolute top-3 right-3 text-gray-500 hover:text-gray-800"
@click="showDetail = false"
>
</button>
<!-- Judul -->
<h2 class="text-xl font-semibold text-D mb-4 text-center">
Detail Produk
</h2>
<!-- Data Produk -->
<div class="space-y-2 text-gray-700">
<p><span class="font-semibold">Nama:</span> {{ product.nama }}</p>
<p><span class="font-semibold">Kategori:</span> {{ product.kategori }}</p>
<p><span class="font-semibold">Berat:</span> {{ product.berat }} gram</p>
<p><span class="font-semibold">Kadar:</span> {{ product.kadar }}%</p>
<p><span class="font-semibold">Harga/gram:</span> Rp {{ formatHarga(product.harga_per_gram) }}</p>
<p><span class="font-semibold">Harga Jual:</span> Rp {{ formatHarga(product.harga_jual) }}</p>
<p><span class="font-semibold">Stok:</span> {{ product.items_count }} pcs</p>
<!-- Bulatan Merah -->
<div class="w-5 h-5 bg-red-500 rounded-full flex items-center justify-center cursor-help border-2 border-white shadow-md">
<span class="text-white text-xs font-bold">!</span>
</div>
<!-- Tooltip -->
<div class="absolute top-6 -right-4 bg-red-600 text-white text-xs px-3 py-2 rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-30">
Stok menipis: {{ product.items_count }} pcs
<!-- Arrow untuk tooltip -->
<div class="absolute -top-1 left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-2 border-r-2 border-b-2 border-transparent border-b-red-600"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import { computed } from 'vue';
const props = defineProps({
product: {
@ -56,10 +52,8 @@ const props = defineProps({
},
});
const showDetail = ref(false);
// Format rupiah
function formatHarga(value) {
return new Intl.NumberFormat("id-ID").format(value);
}
// Computed untuk mengecek apakah stok menipis (kurang dari 5)
const isStockLow = computed(() => {
return props.product.items_count < 5;
});
</script>

View File

@ -0,0 +1,282 @@
<template>
<div class="flex flex-row items-center justify-end mt-5 gap-3">
<div class="relative w-32" ref="filterDropdownRef">
<button @click="isFilterOpen = !isFilterOpen" type="button"
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
<span>{{ selectedFilterLabel }}</span>
<i class="fas fa-chevron-down"></i>
</button>
<div v-if="isFilterOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
<ul class="py-1">
<li v-for="option in filterOptions" :key="option.value" @click="selectFilter(option)"
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
{{ option.label }}
</li>
</ul>
</div>
</div>
<div class="relative w-40" ref="exportDropdownRef">
<button v-if="loadingExport" type="button"
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
</button>
<button v-else @click="isExportOpen = !isExportOpen" type="button"
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
<i class="fas fa-chevron-down"></i>
</button>
<div v-if="isExportOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
<ul class="py-1">
<li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)"
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
{{ option.label }}
</li>
</ul>
</div>
</div>
</div>
<div class="mt-5 overflow-x-auto">
<table class="w-full border-collapse border border-C rounded-md">
<thead>
<tr class="bg-C text-D rounded-t-md">
<th class="border-x border-C px-3 py-3">Tanggal</th>
<th class="border-x border-C px-3 py-3">Nama Sales</th>
<th class="border-x border-C px-3 py-3">Jumlah Item Terjual</th>
<th class="border-x border-C px-3 py-3">Total Berat Terjual</th>
<th class="border-x border-C px-3 py-3">Total Pendapatan</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="5" class="p-4">
<div class="flex items-center justify-center w-full h-30">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
<span class="ml-2 text-gray-600">Memuat data...</span>
</div>
</td>
</tr>
<tr v-else-if="!ringkasanLaporan.length">
<td colspan="5" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
</tr>
<template v-else v-for="item in ringkasanLaporan" :key="item.tanggal">
<template v-if="item.sales && item.sales.length > 0">
<tr class="text-center border-y border-C hover:bg-A">
<td class="px-3 py-2 border-x border-C bg-white" :rowspan="item.sales.length">{{
item.tanggal }}</td>
<td class="px-3 py-2 border-x border-C text-left">{{ item.sales[0].nama }}</td>
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].item_terjual }}</td>
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].berat_terjual }}</td>
<td class="flex justify-center">
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
{{ item.sales[0].pendapatan }}
</div>
</td>
</tr>
<tr v-for="sales in item.sales.slice(1)" :key="sales.nama"
class="text-center border-y border-C hover:bg-A">
<td class="px-3 py-2 text-left border-x border-C">{{ sales.nama }}</td>
<td class="px-3 py-2 border-x border-C">{{ sales.item_terjual }}</td>
<td class="px-3 py-2 border-x border-C">{{ sales.berat_terjual }}</td>
<td class="flex justify-center">
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="sales.pendapatan == '-' ? 'text-center' : 'text-right'">
{{ sales.pendapatan }}
</div>
</td>
</tr>
<tr class="font-semibold text-center border-y border-C bg-B hover:bg-C/80">
<td class="px-3 py-2 border-x border-C" colspan="2">Total</td>
<td class="px-3 py-2 border-x border-C">{{ item.total_item_terjual }}</td>
<td class="px-3 py-2 border-x border-C">{{ item.total_berat }}</td>
<td class="flex justify-center">
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
{{ item.total_pendapatan }}
</div>
</td>
</tr>
</template>
<template v-else>
<tr class="text-center border-y border-C hover:bg-A">
<td class="px-3 py-2 border-x border-C">{{ item.tanggal }}</td>
<td colspan="4" class="px-3 py-2 italic text-gray-500 border-x border-C">Tidak ada transaksi
pada hari ini</td>
</tr>
</template>
</template>
</tbody>
</table>
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
Sebelumnya
</button>
<span class="text-sm text-D">
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
</span>
<button @click="goToPage(pagination.current_page + 1)"
:disabled="(pagination.current_page === pagination.last_page) || loading"
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
Berikutnya
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from "vue";
import axios from "axios";
// --- State ---
const isFilterOpen = ref(false);
const isExportOpen = ref(false);
const filterDropdownRef = ref(null);
const exportDropdownRef = ref(null);
const filterOptions = ref([
{ value: 'bulan', label: 'Bulanan' },
{ value: 'hari', label: 'Harian' }
]);
const exportOptions = ref([
{ value: 'pdf', label: 'Pdf' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'csv', label: 'Csv' }
]);
const filterRingkasan = ref("bulan");
const loadingExport = ref(false);
const exportFormat = ref(null);
const ringkasanLaporan = ref([]);
const loading = ref(false);
const pagination = ref({
current_page: 1,
last_page: 1,
total: 0,
});
const pendapatanWidth = ref(0);
const pendapatanElements = ref([]);
// --- Computed ---
const selectedFilterLabel = computed(() => {
return filterOptions.value.find(opt => opt.value === filterRingkasan.value)?.label;
});
const selectedExportLabel = computed(() => {
return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan';
});
const pendapatanStyle = computed(() => ({
minWidth: `${pendapatanWidth.value}px`,
padding: '0.5rem 0.75rem'
}));
// --- Watchers ---
watch(ringkasanLaporan, async (newValue) => {
if (newValue && newValue.length > 0) {
await nextTick();
let maxWidth = 0;
pendapatanElements.value.forEach(el => {
if (el && el.scrollWidth > maxWidth) {
maxWidth = el.scrollWidth;
}
});
pendapatanWidth.value = maxWidth;
}
}, { deep: true });
// --- Methods ---
const fetchRingkasan = async (page = 1) => {
loading.value = true;
pendapatanElements.value = [];
try {
const response = await axios.get(`/api/laporan/ringkasan?filter=${filterRingkasan.value}&page=${page}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});;
ringkasanLaporan.value = response.data.data;
pagination.value = {
current_page: response.data.current_page,
last_page: response.data.last_page,
total: response.data.total,
};
} catch (error) {
console.error("Error fetching laporan:", error);
ringkasanLaporan.value = [];
} finally {
loading.value = false;
}
};
const goToPage = (page) => {
if (page >= 1 && page <= pagination.value.last_page) {
fetchRingkasan(page);
}
};
const selectFilter = (option) => {
filterRingkasan.value = option.value;
isFilterOpen.value = false;
goToPage(1);
};
const selectExport = (option) => {
isExportOpen.value = false;
triggerDownload(option.value);
};
const triggerDownload = async (format) => {
loadingExport.value = true;
try {
const response = await axios.get('/api/laporan/export/ringkasan', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
responseType: 'blob',
params: {
filter: filterRingkasan.value,
format: format,
page: pagination.value.current_page,
},
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = `laporan_${filterRingkasan.value}_${new Date().toISOString().split('T')[0]}.${format}`;
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Gagal mengunduh laporan:", error);
alert("Terjadi kesalahan saat membuat laporan.");
} finally {
loadingExport.value = false;
}
};
const closeDropdownsOnClickOutside = (event) => {
if (filterDropdownRef.value && !filterDropdownRef.value.contains(event.target)) {
isFilterOpen.value = false;
}
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
isExportOpen.value = false;
}
};
onMounted(() => {
fetchRingkasan(pagination.value.current_page);
document.addEventListener('click', closeDropdownsOnClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', closeDropdownsOnClickOutside);
});
</script>

View File

@ -0,0 +1,397 @@
<template>
<div class="my-6">
<!-- Divider -->
<hr class="border-B mb-5" />
<!-- Filter Section -->
<div class="flex flex-col md:flex-row justify-between my-3 gap-3 md:gap-5">
<!-- Date Range Filter -->
<div class="w-full md:w-1/3">
<DatePicker v-model="dateRange" label="Filter Tanggal" placeholder="Pilih rentang tanggal" :max-days="31"
@change="handleDateChange" />
</div>
<div class="flex flex-col sm:flex-row w-full md:w-1/3">
<div class="flex-1 min-w-0">
<input placeholder="Cari kode transaksi atau nama pembeli" v-model="searchQuery"
class="mt-1 block w-full rounded-l-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:outline-none focus:ring-D focus:ring-opacity-50 p-2" />
</div>
<div>
<button @click="handleSearch"
class="mt-1 px-4 py-2 bg-C hover:bg-C/80 text-D rounded-r-md text-sm font-medium transition-colors">
Cari
</button>
</div>
</div>
</div>
<!-- Table Section -->
<div class="mt-6 overflow-x-auto">
<div class="bg-white rounded-md border border-C overflow-hidden">
<table class="w-full">
<thead>
<tr class="bg-C text-D">
<th class="border-x border-C px-3 py-3 text-left">
<button @click="handleSort('created_at')"
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
<span>Tanggal & Waktu</span>
<i :class="getSortIcon('created_at')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3 text-left">
<button @click="handleSort('kode_transaksi')"
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
<span>Kode Transaksi</span>
<i :class="getSortIcon('kode_transaksi')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3 text-left">
<button @click="handleSort('nama_pembeli')"
class="flex items-center justify-between w-full hover:text-D/80 transition-colors">
<span>Nama Pembeli</span>
<i :class="getSortIcon('nama_pembeli')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3 text-left">
<button @click="handleSort('total_harga')"
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
<span>Total</span>
<i :class="getSortIcon('total_harga')" class="ml-2"></i>
</button>
</th>
<th class="border-x border-C px-3 py-3 text-center">
<span>Jml</span>
</th>
<th class="border-r border-C px-3 py-3 text-center">
<span>Aksi</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-C/20">
<!-- Loading Row -->
<tr v-if="loading">
<td :colspan="tableColumns" class="p-8 text-center">
<div class="flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
<span class="ml-2 text-D/70">Memuat riwayat transaksi...</span>
</div>
</td>
</tr>
<!-- Empty State Row -->
<tr v-else-if="filteredTransaksi.length === 0 && !loading">
<td :colspan="tableColumns" class="p-12 text-center">
<div class="text-D/50 space-y-2">
<svg class="w-16 h-16 mx-auto text-D/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<div class="space-y-1">
<p class="text-sm font-medium">Tidak ada transaksi ditemukan.</p>
</div>
</div>
</td>
</tr>
<!-- Data Rows -->
<template v-else v-for="trx in sortedTransaksi" :key="trx.id">
<tr class="hover:bg-A/50 transition-colors">
<!-- Tanggal & Waktu -->
<td class="border-x border-C px-3 py-3">
<div class="flex flex-row text-sm gap-2">
<div class="text-D">{{ formatDate(trx.created_at) }},</div>
<div class="text-D/60"> {{ formatTime(trx.created_at) }}</div>
</div>
</td>
<!-- Kode Transaksi -->
<td class="text-sm border-x border-C px-3 py-3">
{{ trx.kode_transaksi }}
</td>
<!-- Nama pembeli -->
<td class="text-sm border-x border-C px-3 py-3">
{{ trx.nama_pembeli || '-' }}
</td>
<!-- Total -->
<td class="text-sm border-x border-C px-3 py-3 text-center">
Rp{{ (trx.total_harga || 0).toLocaleString('id-ID') }}
</td>
<!-- Jumlah Item -->
<td class="text-sm border-x border-C px-3 py-3 text-center">
{{ trx.total_items || 0 }}
</td>
<!-- Aksi -->
<td class="border-r border-C px-3 py-3 text-center">
<button @click="lihatDetail(trx)"
class="inline-flex items-center px-3 py-1.5 bg-C hover:bg-C/80 text-D rounded-md text-xs font-medium transition-colors"
:disabled="isDetailLoading">
<i v-if="isDetailLoading && selectedTransaksi.id === trx.id"
class="fas fa-spinner fa-spin mr-1"></i>
<span>Lihat Detail</span>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div v-if="pagination && pagination.total > 0 && pagination.last_page > 1"
class="flex items-center justify-between gap-4 mt-6 px-1">
<div class="text-sm text-D/70">
Menampilkan {{ pagination.from }} - {{ pagination.to }} dari {{ pagination.total }} transaksi
<span v-if="filteredTransaksi.length !== pagination.per_page" class="ml-2 text-blue-600">
({{ filteredTransaksi.length }} sesuai filter)
</span>
</div>
<div class="flex items-center gap-2">
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
class="px-3 py-2 text-sm font-medium border rounded-md bg-A border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/50 transition-colors">
<i class="fas fa-chevron-left mr-1"></i>
Sebelumnya
</button>
<span class="text-sm text-D/70 px-3">
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
</span>
<button @click="goToPage(pagination.current_page + 1)"
:disabled="pagination.current_page === pagination.last_page || loading"
class="px-3 py-2 text-sm font-medium border rounded-md bg-A border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/50 transition-colors">
Berikutnya
<i class="fas fa-chevron-right ml-1"></i>
</button>
</div>
</div>
<!-- Modal Detail Transaksi -->
<StrukView :is-open="isDetailOpen" :transaksi="selectedTransaksi" @close="closeDetail" />
<!-- Loading Overlay for Detail -->
<div v-if="isDetailLoading" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[9999] p-4">
<div class="bg-white rounded-lg p-6 flex items-center gap-3 shadow-xl max-w-md w-full">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-D"></div>
<span class="text-D/80">Memuat detail transaksi...</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import DatePicker from '@/components/DatePicker.vue'
import StrukView from '@/components/StrukView.vue'
// Props & Emits
const props = defineProps({
initialData: {
type: Object,
default: () => ({ data: [], pagination: null })
}
})
// Reactive State
const transaksi = ref(props.initialData.data || [])
const pagination = ref(props.initialData.pagination || null)
const loading = ref(false)
const isDetailLoading = ref(false)
// Filter State
const dateRange = ref({
start: new Date().toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
})
const statusDipilih = ref('')
const pembayaranDipilih = ref('')
const searchQuery = ref('')
// Sort State
const sortField = ref('created_at')
const sortDirection = ref('desc')
// Modal State
const isDetailOpen = ref(false)
const selectedTransaksi = ref({})
// Computed
const filteredTransaksi = computed(() => {
let filtered = [...transaksi.value]
if (dateRange.value.start && dateRange.value.end) {
const startDate = new Date(dateRange.value.start)
const endDate = new Date(dateRange.value.end)
endDate.setHours(23, 59, 59, 999)
filtered = filtered.filter(trx => {
const trxDate = new Date(trx.created_at)
return trxDate >= startDate && trxDate <= endDate
})
}
// Status filter
if (statusDipilih.value) {
filtered = filtered.filter(trx => trx.status === statusDipilih.value)
}
// Payment method filter
if (pembayaranDipilih.value) {
filtered = filtered.filter(trx => trx.metode_pembayaran === pembayaranDipilih.value)
}
// Removed searchQuery filter to prevent client-side filtering
return filtered
})
const sortedTransaksi = computed(() => {
return [...filteredTransaksi.value].sort((a, b) => {
let aVal = a[sortField.value] || ''
let bVal = b[sortField.value] || ''
// Handle numeric fields
if (['total_harga', 'total_items'].includes(sortField.value)) {
aVal = parseFloat(aVal) || 0
bVal = parseFloat(bVal) || 0
}
if (aVal < bVal) return sortDirection.value === 'asc' ? -1 : 1
if (aVal > bVal) return sortDirection.value === 'asc' ? 1 : -1
return 0
})
})
const tableColumns = computed(() => 6)
// Methods
const fetchTransaksi = async (page = 1) => {
try {
loading.value = true
const params = new URLSearchParams({
page,
limit: 10,
start_date: dateRange.value.start,
end_date: dateRange.value.end,
status: statusDipilih.value,
search: searchQuery.value,
})
const response = await axios.get(`/api/transaksi?${params}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`
}
})
transaksi.value = response.data.data || []
pagination.value = response.data.pagination || null
// console.log("data", transaksi.value)
} catch (error) {
console.error('Error fetching transaksi:', error)
transaksi.value = []
} finally {
loading.value = false
}
}
const handleDateChange = (newRange) => {
dateRange.value = newRange
pagination.value = null // Reset pagination
fetchTransaksi(1)
}
const handleSearch = () => {
pagination.value = null
fetchTransaksi(1)
}
const handleSort = (field) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
fetchTransaksi(1)
}
const getSortIcon = (field) => {
if (sortField.value !== field) return 'fas fa-sort text-D/40'
if (sortDirection.value === 'asc') return 'fas fa-sort-up text-D'
return 'fas fa-sort-down text-D'
}
const goToPage = (page) => {
if (page >= 1 && page <= (pagination.value?.last_page || 1)) {
fetchTransaksi(page)
}
}
const lihatDetail = async (trx) => {
try {
isDetailLoading.value = true
selectedTransaksi.value = trx // Show loading state first
const response = await axios.get(`/api/transaksi/${trx.id}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`
}
})
selectedTransaksi.value = {
...response.data,
total_items: response.data.itemTransaksi?.length || 0
}
isDetailOpen.value = true
} catch (error) {
console.error('Error fetching detail:', error)
alert('Gagal memuat detail transaksi: ' + (error.response?.data?.message || error.message))
} finally {
isDetailLoading.value = false
}
}
const closeDetail = () => {
isDetailOpen.value = false
selectedTransaksi.value = {}
}
// Helper Functions
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
}
const formatTime = (dateString) => {
return new Date(dateString).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit'
})
}
// Lifecycle
onMounted(() => {
fetchTransaksi()
})
// Watchers
import { watch } from 'vue'
watch([statusDipilih, pembayaranDipilih], () => {
pagination.value = null
fetchTransaksi(1)
}, { deep: true })
</script>

View File

@ -0,0 +1,404 @@
<template>
<div v-if="isOpen"
class="text-D pt-serif-regular-italic fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]">
<div class="bg-white w-[1224px] h-[528px] rounded-md shadow-lg relative overflow-hidden">
<div class="bg-yellow-500 h-8 w-full">
<div class="bg-D h-6 w-full"></div>
</div>
<div class="p-6 text-sm flex flex-col h-full relative">
<div class="relative flex items-center justify-between top-0 pb-1 mb-2">
<div class="flex flex-col gap-2 -mt-5">
<p class="flex items-center gap-2">
<i class="fab fa-instagram text-pink-600 text-xl"></i> tokomas_Jakartacitayam
</p>
<p class="flex items-center gap-2">
<i class="fab fa-tiktok text-black text-xl"></i> tokomas_Jakartacitayam
</p>
<p class="flex items-center gap-2">
<i class="fab fa-whatsapp text-green-500 text-xl"></i> 08158851178
</p>
<p class=" text-sm">{{ generateTransactionCode() }}</p>
</div>
<div class="absolute inset-x-0 top-[-48px] flex flex-col items-center">
<img :src="logo" alt="Logo" class="h-40" />
</div>
<div class="grid grid-cols-[130px_1fr] gap-y-0 text-xs items-center -mt-5 relative z-10">
<div class="text-right font-semibold pr-3">Tanggal :</div>
<p class="mt-1 text-left pl-2">{{ getCurrentDate() }}</p>
<div class="text-right font-semibold pr-3">Nama :</div>
<inputField v-model="namaPembeli" class="h-7 text-sm rounded bg-blue-200 w-full" />
<div class="text-right font-semibold pr-3">Alamat :</div>
<inputField v-model="alamat" class="h-7 px-2 text-sm rounded bg-blue-200 w-full" />
<div class="text-right font-semibold pr-3">No.Hp :</div>
<inputField v-model="nomorTelepon" class="h-7 px-2 text-sm rounded bg-blue-200 w-full" />
</div>
</div>
<div class="flex mb-1 gap-179">
<div class="flex gap-4">
<img :src="logo_bca" alt="Logo_bca" class="h-5" />
<img :src="logo_bri" alt="Logo_bri" class="h-5" />
<img :src="logo_bni" alt="Logo_bni" class="h-5" />
</div>
<div class="flex gap-4">
<img :src="logo_mastercard" alt="Logo_mastercard" class="h-5" />
<img :src="logo_visa" alt="Logo_visa" class="h-5" />
<img :src="logo_mandiri" alt="Logo_mandiri" class="h-5" />
</div>
</div>
<table class="w-full border-D text-sm table-fixed border-b">
<thead>
<tr class="border-b border-t border-D">
<th class="w-[40px] border-r text-lg border-D">Jml</th>
<th class="w-[425px] py-2 text-lg border-r border-D">Item</th>
<th class="w-[70px] border-r text-lg border-D">Posisi</th>
<th class="w-[40px] border-r text-lg border-D">Berat</th>
<th class="w-[40px] border-r text-lg border-D">Kadar</th>
<th class="w-[175px] text-lg">Harga</th>
</tr>
</thead>
<tbody>
<!-- Item rows dengan dynamic height -->
<tr v-for="(item, index) in props.pesanan" :key="index"
class="text-center"
:style="getRowStyle()">
<td class="border-r border-D">
<span v-if="item.harga_deal">1</span>
</td>
<td class="flex items-center gap-2 p-2 border-r border-D" :style="getRowStyle()">
<template v-if="item.produk?.foto?.[0]?.url">
<img :src="item.produk.foto[0].url"
:class="getImageClass()"
class="object-cover" />
</template>
<template v-else>
<div :class="getImageClass()"></div>
</template>
<span :class="getTextClass()">{{ item.produk?.nama || '' }}</span>
</td>
<td class="border-r border-D">{{ item.produk.nama ? (item.nampan?.nama || 'Brankas') : '' }}</td>
<td class="border-r border-D">
<span v-if="item.produk?.berat">{{ item.produk.berat }}g</span>
</td>
<td class="border-r border-D">
<span v-if="item.produk?.kadar">{{ item.produk.kadar }}k</span>
</td>
<td>
<span v-if="item.harga_deal">Rp{{ item.harga_deal.toLocaleString() }}</span>
</td>
</tr>
</tbody>
</table>
<!-- Bagian bawah -->
<div class="flex text-sm mt-2">
<!-- PERHATIAN -->
<div class="w-[40%] p-2 text-left">
<p class="font-semibold">PERHATIAN</p>
<ol class="list-decimal ml-4 text-xs space-y-1">
<li>Berat barang telah ditimbang dan disaksikan oleh pembeli.</li>
<li>Barang yang dikembalikan menurut harga pasaran dan dipotong ongkos bikin, barang rusak
lain harga.</li>
<li>Barang yang sudah dibeli berarti sudah diperiksa dan disetujui.</li>
<li class="text-red-500">Surat ini harap dibawa pada saat menjual kembali.</li>
</ol>
</div>
<!-- SALES -->
<div class="w-[20%] p-2 flex flex-col items-center justify-center">
<p><strong>Hormat Kami</strong></p>
<inputSelect v-model="selectedSales" :options="salesOptions"
class="mt-16 text-sm rounded bg-B cursor-pointer !w-[160px] text-center [option]:text-left" />
</div>
<!-- ONGKOS & TOTAL -->
<div class="ml-auto w-[25%] p-2 flex flex-col justify-between">
<div class="space-y-4">
<!-- Ongkos bikin -->
<div class="flex items-start justify-between ">
<div class="flex flex-col ">
<p class="font-semibold">Ongkos bikin</p>
<p class="text-red-500 text-xs">diluar harga jual</p>
</div>
<div class="flex items-center w-40">
<p>Rp</p>
<input type="text" v-model="ongkosBikinFormatted" @input="formatInput"
class="h-7 px-2 text-sm rounded bg-blue-200 text-left w-full" />
</div>
</div>
<!-- Total -->
<div class="flex items-center justify-between -mt-4">
<p class="font-semibold">Total Harga</p>
<div class="flex items-center w-40">
<p>Rp</p>
<p class="px-3 pl-0 py-1 text-left text-sm w-full">
{{ grandTotal.toLocaleString() }},-
</p>
</div>
</div>
</div>
<!-- Tombol -->
<div class="flex justify-end gap-2 mt-4">
<button @click="$emit('close')" class="bg-gray-400 text-white px-6 py-2 rounded">
Batal
</button>
<button @click="handleSimpan" class="bg-C text-white px-6 py-2 rounded">
Simpan
</button>
</div>
</div>
</div>
</div>
<p class="absolute p-8 bottom-0 left-0 w-full text-left text-xs bg-D text-white py-1">
Terima kasih sudah berbelanja dengan kami
</p>
</div>
</div>
<!-- Simple Toast Alert -->
<div v-if="showToast"
class="fixed top-4 left-1/2 transform -translate-x-1/2 z-[10001]
transition-all duration-300 ease-in-out"
:class="toastClasses">
<div class="flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg max-w-sm">
<!-- Icon -->
<div class="flex-shrink-0">
<svg v-if="toastType === 'error'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<svg v-else-if="toastType === 'success'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<!-- Message -->
<p class="text-sm font-medium">{{ toastMessage }}</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import logo from '@/../images/logo.png'
import logo_bca from '@/../images/logo_bca.png'
import logo_bri from '@/../images/logo_bri.png'
import logo_bni from '@/../images/logo_bni.png'
import logo_mastercard from '@/../images/logo_mastercard.png'
import logo_visa from '@/../images/logo_visa.png'
import logo_mandiri from '@/../images/logo_mandiri.png'
import inputField from '@/components/InputField.vue'
import inputSelect from '@/components/InputSelect.vue'
import axios from 'axios'
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
pesanan: {
type: Array,
default: () => []
},
total: {
type: Number,
default: 0
}
})
const emit = defineEmits(['close', 'confirm'])
const namaPembeli = ref('')
const nomorTelepon = ref('')
const alamat = ref('')
const ongkosBikin = ref(0)
const selectedSales = ref(null)
const salesOptions = ref([])
const ongkosBikinFormatted = ref("")
// Simple Toast State
const showToast = ref(false)
const toastType = ref('error') // 'error', 'success', 'info'
const toastMessage = ref('')
const toastClasses = computed(() => {
const baseClasses = 'text-white'
const typeClasses = {
error: 'bg-red-500',
success: 'bg-green-500',
info: 'bg-blue-500'
}
return `${baseClasses} ${typeClasses[toastType.value]}`
})
const grandTotal = computed(() => {
return props.total + (ongkosBikin.value || 0)
})
// Fungsi untuk menentukan style row berdasarkan jumlah item
const getRowStyle = () => {
if (props.pesanan.length === 1) {
return { height: '126px' } // 2x lipat dari tinggi normal (48px)
}
return { height: '63px' } // Tinggi normal
}
// Fungsi untuk menentukan class gambar berdasarkan jumlah item
const getImageClass = () => {
if (props.pesanan.length === 1) {
return 'w-25 h-25' // 2x lipat dari ukuran normal (w-10 h-10)
}
return 'w-12 h-12' // Ukuran normal
}
// Fungsi untuk menentukan class text berdasarkan jumlah item
const getTextClass = () => {
if (props.pesanan.length === 1) {
return 'text-lg font-medium' // Text lebih besar untuk single item
}
return 'text-sm' // Text normal
}
const getCurrentDate = () => {
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu']
const months = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
const now = new Date()
const dayName = days[now.getDay()]
const day = String(now.getDate()).padStart(2, '0')
const month = months[now.getMonth()]
const year = now.getFullYear()
return `${dayName}/${day}-${month}-${year}`
}
const generateTransactionCode = () => {
const now = new Date()
const timestamp = now.getTime().toString().slice(-6)
return `TRS-${timestamp}`
}
// Simple Toast Function
const showSimpleToast = (type, message, duration = 3000) => {
toastType.value = type
toastMessage.value = message
showToast.value = true
setTimeout(() => {
showToast.value = false
}, duration)
}
const fetchSales = async () => {
try {
const response = await axios.get('/api/sales', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
})
salesOptions.value = response.data.map(sales => ({
value: sales.id,
label: sales.nama
}))
if (salesOptions.value.length > 0) {
selectedSales.value = salesOptions.value[0].value
}
} catch (error) {
console.error('Error fetching sales:', error)
}
}
const handleSimpan = () => {
if (!namaPembeli.value.trim()) {
showSimpleToast('error', 'Nama pembeli harus diisi!')
return
}
if (!nomorTelepon.value.trim()) {
showSimpleToast('error', 'Nomor telepon harus diisi!')
return
}
if (!alamat.value.trim()) {
showSimpleToast('error', 'Alamat harus diisi!')
return
}
if (!selectedSales.value) {
showSimpleToast('error', 'Sales harus dipilih!')
return
}
simpanTransaksi({
id_sales: selectedSales.value,
nama_pembeli: namaPembeli.value,
no_hp: nomorTelepon.value,
alamat: alamat.value,
ongkos_bikin: ongkosBikin.value || 0, // Pastikan nama field benar
total_harga: grandTotal.value,
items: props.pesanan
})
}
const simpanTransaksi = async (dataTransaksi) => {
// console.log('Data transaksi yang akan disimpan:', dataTransaksi);
try {
const response = await axios.post('/api/transaksi', dataTransaksi, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
showSimpleToast('success', 'Transaksi berhasil disimpan!', 2000)
// Delay untuk memberikan waktu user membaca notifikasi
setTimeout(() => {
emit('close');
window.location.reload();
}, 2200);
} catch (error) {
console.error('Error saving transaksi:', error);
const errorMessage = error.response?.data?.message || error.message || 'Terjadi kesalahan saat menyimpan transaksi';
showSimpleToast('error', `Error: ${errorMessage}`, 4000);
}
};
onMounted(() => {
if (props.isOpen) {
fetchSales()
}
})
function formatInput(e) {
let value = e.target.value.replace(/\D/g, "");
ongkosBikin.value = value ? parseInt(value, 10) : null;
ongkosBikinFormatted.value = value
? new Intl.NumberFormat("id-ID").format(value)
: "";
}
</script>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
.pt-serif-regular-italic {
font-family: "PT Serif", serif;
font-weight: 400;
}
</style>

View File

@ -0,0 +1,296 @@
<template>
<div v-if="isOpen"
class="text-D pt-serif-regular-italic fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]">
<!-- print-area untuk fokus saat print -->
<div class="print-area bg-white w-[1224px] h-[528px] shadow-lg relative overflow-hidden">
<div class="bg-yellow-500 h-8 w-full">
<div class="bg-D h-6 w-full"></div>
</div>
<div class="p-6 text-sm flex flex-col h-full relative">
<div class="relative flex items-center justify-between top-0 pb-1 mb-2">
<div class="flex flex-col gap-2 -mt-5">
<p class="flex items-center gap-2">
<i class="fab fa-instagram text-pink-600 text-xl"></i> tokomas_Jakartacitayam
</p>
<p class="flex items-center gap-2">
<i class="fab fa-tiktok text-black text-xl"></i> tokomas_Jakartacitayam
</p>
<p class="flex items-center gap-2">
<i class="fab fa-whatsapp text-green-500 text-xl"></i> 08158851178
</p>
<p class=" text-sm">{{ transaksi.kode_transaksi || 'N/A' }}</p>
</div>
<div class="absolute inset-x-0 top-[-48px] flex flex-col items-center">
<img :src="logo" alt="Logo" class="h-40" />
</div>
<div class="grid grid-cols-[130px_1fr] gap-y-1 text-xs items-center -mt-5 relative z-10">
<div class="text-right font-semibold pr-3">Tanggal :</div>
<p class="text-left pl-2">{{ formatDate(transaksi.created_at) }}</p>
<div class="text-right font-semibold pr-3">Nama :</div>
<p class="text-left pl-2">{{ transaksi.nama_pembeli || '-' }}</p>
<div class="text-right font-semibold pr-3">Alamat :</div>
<p class="text-left pl-2">{{ transaksi.alamat || '-' }}</p>
<div class="text-right font-semibold pr-3">No.Hp :</div>
<p class="text-left pl-2">{{ transaksi.no_hp || '-' }}</p>
</div>
</div>
<div class="flex mb-1 gap-179">
<div class="flex gap-4">
<img :src="logo_bca" alt="Logo_bca" class="h-5" />
<img :src="logo_bri" alt="Logo_bri" class="h-5" />
<img :src="logo_bni" alt="Logo_bni" class="h-5" />
</div>
<div class="flex gap-4">
<img :src="logo_mastercard" alt="Logo_mastercard" class="h-5" />
<img :src="logo_visa" alt="Logo_visa" class="h-5" />
<img :src="logo_mandiri" alt="Logo_mandiri" class="h-5" />
</div>
</div>
<table class="w-full border-D text-sm table-fixed border-b">
<thead>
<tr class="border-b border-t border-D">
<th class="w-[40px] border-r text-lg border-D">Jml</th>
<th class="w-[425px] py-2 text-lg border-r border-D">Item</th>
<th class="w-[70px] border-r text-lg border-D">Posisi</th>
<th class="w-[40px] border-r text-lg border-D">Berat</th>
<th class="w-[40px] border-r text-lg border-D">Kadar</th>
<th class="w-[175px] text-lg">Harga</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in itemsWithMinimal" :key="index" class="text-center" :style="getRowStyle()">
<td class="border-r border-D">
<span v-if="item.harga_deal && item.harga_deal > 0">1</span>
</td>
<td class="flex items-center gap-2 p-2 border-r border-D" :style="getRowStyle()">
<template v-if="item.produk?.foto?.[0]?.url">
<img :src="item.produk.foto[0].url" :class="getImageClass()" class="object-cover rounded" />
</template>
<template v-else-if="item.produk?.nama">
<div :class="getImageClass() + ' bg-gray-200 rounded flex items-center justify-center'">
<span class="text-xs text-gray-500">IMG</span>
</div>
</template>
<template v-else>
<div :class="getImageClass()"></div>
</template>
<span :class="getTextClass()">{{ item.produk?.nama || '' }}</span>
</td>
<td class="border-r border-D">
<span v-if="item.produk?.nama">{{ item.posisi_asal || 'Brankas' }}</span>
</td>
<td class="border-r border-D">
<span v-if="item.produk?.berat">{{ formatNumber(item.produk.berat) }}g</span>
</td>
<td class="border-r border-D">
<span v-if="item.produk?.kadar">{{ item.produk.kadar }}k</span>
</td>
<td>
<span v-if="item.harga_deal && item.harga_deal > 0">
Rp{{ formatNumber(item.harga_deal) }}
</span>
</td>
</tr>
</tbody>
</table>
<div class="flex text-sm mt-2">
<div class="w-[40%] p-2 text-left">
<p class="font-semibold">PERHATIAN</p>
<ol class="list-decimal ml-4 text-xs space-y-1">
<li>Berat barang telah ditimbang dan disaksikan oleh pembeli.</li>
<li>Barang yang dikembalikan menurut harga pasaran dan dipotong ongkos bikin, barang rusak lain harga.</li>
<li>Barang yang sudah dibeli berarti sudah diperiksa dan disetujui.</li>
<li class="text-red-500">Surat ini harap dibawa pada saat menjual kembali.</li>
</ol>
</div>
<div class="w-[20%] p-2 flex flex-col items-center justify-center">
<p><strong>Hormat Kami</strong></p>
<div class="mt-16 text-sm text-center">
<p class="font-semibold">{{ transaksi.nama_sales || '-' }}</p>
</div>
</div>
<div class="ml-auto w-[25%] p-2 flex flex-col justify-between">
<div class="space-y-4">
<div class="flex items-start justify-between">
<div class="flex flex-col">
<p class="font-semibold">Ongkos bikin</p>
<p class="text-red-500 text-xs">diluar harga jual</p>
</div>
<div class="flex p-1 items-center w-40 bg-B rounded-sm">
<p>Rp</p>
<p class="px-2 pl-0 text-left text-sm w-full">
{{ (transaksi.ongkos_bikin || 0).toLocaleString() }},-
</p>
</div>
</div>
<!-- Total -->
<div class="flex items-center justify-between -mt-4">
<p class="font-semibold">Total Harga</p>
<div class="flex items-center w-40">
<p>Rp</p>
<p class="px-3 pl-0 py-1 text-left text-sm w-full">
{{ (transaksi.total_harga || 0).toLocaleString() }},-
</p>
</div>
</div>
</div>
<!-- Tombol -->
<div class="flex justify-end gap-2 mt-4 no-print">
<button @click="$emit('close')" class="bg-gray-400 text-white px-6 py-2 rounded">
Tutup
</button>
<button @click="handlePrint" class="bg-C text-white px-6 py-2 rounded">
Print
</button>
</div>
</div>
</div>
</div>
<p class="absolute p-8 bottom-0 left-0 w-full text-left text-xs bg-D text-white py-1">
Terima kasih sudah berbelanja dengan kami
</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import logo from '@/../images/logo.png'
import logo_bca from '@/../images/logo_bca.png'
import logo_bri from '@/../images/logo_bri.png'
import logo_bni from '@/../images/logo_bni.png'
import logo_mastercard from '@/../images/logo_mastercard.png'
import logo_visa from '@/../images/logo_visa.png'
import logo_mandiri from '@/../images/logo_mandiri.png'
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
transaksi: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['close'])
const formatDate = (dateString) => {
if (!dateString) return '-'
const days = ['Minggu','Senin','Selasa','Rabu','Kamis','Jumat','Sabtu']
const months = ['01','02','03','04','05','06','07','08','09','10','11','12']
const date = new Date(dateString)
const dayName = days[date.getDay()]
const day = String(date.getDate()).padStart(2, '0')
const month = months[date.getMonth()]
const year = date.getFullYear()
return `${dayName}/${day}-${month}-${year}`
}
const itemsWithMinimal = computed(() => {
const items = props.transaksi.itemTransaksi ||
props.transaksi.items ||
props.transaksi.item_transaksi ||
[]
const arr = [...items]
if (arr.length === 0) arr.push({ produk: {}, harga_deal: 0, posisi_asal: '' })
return arr
})
const getRowStyle = () => {
if (itemsWithMinimal.value.length === 1) {
return { height: '126px' }
}
return { height: '63px' }
}
const getImageClass = () => {
if (itemsWithMinimal.value.length === 1) {
return 'w-25 h-25'
}
return 'w-12 h-12'
}
const getTextClass = () => {
if (itemsWithMinimal.value.length === 1) {
return 'text-lg font-medium'
}
return 'text-sm'
}
const handlePrint = () => {
window.print()
}
const formatNumber = (number) => {
if (!number) return 0
return parseFloat(number).toLocaleString('id-ID')
}
</script>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
.pt-serif-regular-italic {
font-family: "PT Serif", serif;
font-weight: 400;
}
@media print {
@page {
size: A4; /* atau '80mm 200mm' kalau thermal */
margin: Minimum;
}
/* Sembunyikan semua elemen di luar print-area */
body * {
visibility: hidden !important;
}
.print-area * {
visibility: visible !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.print-area {
position: absolute;
top: 0;
left: 0;
width: 1224px;
height: 528px;
margin: 0;
padding: 0;
transform: scale(0.6673);
transform-origin: top left;
}
/* Hilangkan tombol tutup & print */
.no-print {
display: none !important;
}
}
</style>

View File

@ -1,118 +1,355 @@
<template>
<div>
<!-- Loading -->
<div v-if="loading" class="text-center py-6">Loading...</div>
<!-- Tampilkan berat rata-rata -->
<div class="bg-A border border-C rounded-xl p-4 mx-6 mb-6">
<div class="flex flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-A rounded-lg">
<i class="fas fa-weight text-D"></i>
</div>
<div class="flex flex-col sm:flex-row sm:gap-6 text-sm text-gray-600">
<span>Total: {{ totalTrays }}</span>
<span>Berisi: {{ nonEmptyTrays }}</span>
<span>Kosong: {{ emptyTrays }}</span>
</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-D">{{ averageWeight }}g</div>
<div class="text-sm text-gray-500">Rata-rata</div>
</div>
</div>
</div>
<div v-if="loading" class="flex justify-center items-center h-screen">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
<span class="ml-2 text-gray-600">Memuat data...</span>
</div>
<!-- Error -->
<div v-else-if="error" class="text-center text-red-500 py-6">{{ error }}</div>
<!-- Kalau hasil search kosong -->
<div
v-else-if="filteredTrays.length === 0"
class="text-center text-gray-500 py-6"
>
<div v-else-if="filteredTrays.length === 0" class="text-center text-gray-500 py-[120px]">
Nampan tidak ditemukan.
</div>
<!-- Grid nampan -->
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
<div
v-for="tray in filteredTrays"
:key="tray.id"
class="border rounded-lg p-4 shadow-sm hover:shadow-md transition"
>
<!-- Header Nampan -->
<!-- Grid Card -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch px-6">
<div v-for="tray in filteredTrays" :key="tray.id"
class="border border-C rounded-xl p-4 shadow-sm hover:shadow-md transition flex flex-col h-full">
<!-- Header Card -->
<div class="flex justify-between items-center mb-3">
<h2 class="font-bold text-lg">{{ tray.nama }}</h2>
<div class="flex gap-2">
<button class="bg-yellow-300 p-1 rounded"></button>
<button class="bg-red-500 text-white p-1 rounded">🗑</button>
<h2 class="font-bold text-lg text-[#102C57]">{{ tray.nama }}</h2>
<div class="flex gap-2" v-if="isAdmin">
<button class="p-1 rounded" @click="emit('edit', tray)">
<i class="fa fa-pen fa-sm text-yellow-500 hover:text-yellow-600"></i>
</button>
<button class="p-1 rounded" @click="emit('delete', tray)">
<i class="fa fa-trash fa-sm text-red-500 hover:text-red-600"></i>
</button>
</div>
</div>
<!-- Isi Nampan -->
<div v-if="tray.items && tray.items.length > 0" class="space-y-2">
<div
v-for="item in tray.items"
:key="item.id"
class="flex justify-between items-center border rounded-lg p-2"
>
<!-- Gambar + Info -->
<!-- Isi Card (Max tinggi 3 item + scroll kalau lebih) -->
<div v-if="tray.items && tray.items.length" class="space-y-2 flex-1 overflow-y-auto max-h-[168px] pr-1">
<div v-for="item in tray.items" :key="item.id"
class="flex justify-between items-center border border-C rounded-lg p-2 cursor-pointer hover:bg-gray-50"
@click="openMovePopup(item)">
<div class="flex items-center gap-3">
<img
:src="item.image"
alt="Product Image"
class="w-12 h-12 object-contain"
/>
<div>
<p class="font-semibold">{{ item.produk.nama }}</p>
<p class="text-sm text-gray-500">{{ item.produk.id }}</p>
<img v-if="item.produk?.foto && item.produk?.foto.length > 0" :src="item.produk?.foto[0].url"
alt="foto produk" class="size-12 object-cover rounded" />
<div class="text-D">
<p class="text-sm">{{ item.produk?.nama }}</p>
<p class="text-sm font-medium">{{ item.kode_item }}</p>
</div>
</div>
<!-- Berat -->
<span class="font-medium">{{ item.berat }}g</span>
<div class="flex items-center gap-2">
<span class="font-medium">{{ item.produk?.berat }}g</span>
</div>
</div>
</div>
<!-- Kalau nampan kosong -->
<div v-else class="text-gray-400 text-center py-4">
<!-- Kalau kosong -->
<div v-else class="text-gray-400 text-center py-4 flex-1">
Nampan kosong.<br />
Masuk ke menu <b>Brankas</b> untuk memindahkan item ke nampan.
</div>
<!-- Total Berat -->
<div class="border-t mt-3 pt-2 text-right font-semibold">
Berat Total: {{ totalWeight(tray) }}g
<!-- Footer Card -->
<div class="border-t border-C mt-3 pt-2 text-right font-semibold">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500">{{ tray.items?.length || 0 }} item</span>
<span class="text-lg">Berat Total: {{ totalWeight(tray) }}g</span>
</div>
</div>
</div>
</div>
<!-- Pop-up pindah item -->
<div v-if="isPopupVisible" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative">
<div class="flex justify-center mb-2">
<div class="p-2 border rounded-lg">
<img :src="qrCodeUrl" alt="QR Code" class="size-36" />
</div>
</div>
<div class="text-center text-D font-bold text-lg">
{{ selectedItem.kode_item }}
</div>
<div class="text-center text-gray-700 font-medium mb-3">
{{ selectedItem.produk.nama }}
</div>
<div class="flex justify-center mb-4">
<button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition">
<i class="fas fa-print mr-2"></i>Cetak
</button>
</div>
<!-- Dropdown -->
<div class="mb-4">
<label for="tray-select" class="block text-sm font-medium mb-1">Nama Nampan</label>
<InputSelect v-if="isAdmin" v-model="selectedTrayId"
:options="trays.map(tray => ({ label: tray.nama, value: tray.id }))" placeholder="Pilih Nampan"
class="mt-2" />
<div class="bg-A px-3 py-2 rounded text-D font-medium" v-else>
{{trays.find(tray => tray.id === selectedTrayId)?.nama}}
</div>
</div>
<div class="flex justify-end gap-2">
<button @click="closePopup" class="px-4 py-2 rounded bg-gray-400 hover:bg-gray-500 text-white transition">
{{ isAdmin ? 'Batal' : 'Tutup' }}
</button>
<!-- Tombol Hapus hanya muncul kalau Admin -->
<button v-if="isAdmin" @click="showDeleteConfirm = true"
class="px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600 transition flex items-center">
<i class="fas fa-trash mr-2"></i>Hapus
</button>
<button v-if="isAdmin" @click="saveMove" :disabled="!selectedTrayId" class="px-4 py-2 rounded transition"
:class="selectedTrayId ? 'bg-C hover:bg-C/80 text-D' : 'bg-gray-400 cursor-not-allowed'">
Simpan
</button>
</div>
</div>
</div>
<!-- Modal Konfirmasi Hapus -->
<ConfirmDeleteModal :isOpen="showDeleteConfirm" title="Konfirmasi Hapus Item"
message="Apakah kamu yakin ingin menghapus item ini?" confirmText="Ya, Hapus" cancelText="Batal"
@confirm="confirmDelete" @cancel="cancelDelete" />
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import axios from "axios";
import InputSelect from "./InputSelect.vue";
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
const isAdmin = localStorage.getItem("role") === "owner";
// terima search dari parent
const props = defineProps({
search: {
type: String,
default: "",
},
search: { type: String, default: "" },
});
const emit = defineEmits(["edit", "delete"]);
const trays = ref([]);
const loading = ref(true);
const error = ref(null);
// hitung total berat
const totalWeight = (tray) => {
if (!tray.items) return 0;
return tray.items.reduce((sum, item) => sum + (item.berat || 0), 0);
// --- State Pop-up ---
const isPopupVisible = ref(false);
const selectedItem = ref(null);
const selectedTrayId = ref("");
const showDeleteConfirm = ref(false);
// QR Code generator
const qrCodeUrl = computed(() => {
if (selectedItem.value) {
const data = selectedItem.value.kode_item;
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
}
return "";
});
const printQR = () => {
if (qrCodeUrl.value) {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>Print QR Code - ${selectedItem.value.kode_item}</title>
<style>
@page {
size: 60mm 50mm;
margin: 1mm;
}
* {
margin: 0;
padding: 0;
}
body {
font-family: Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
}
.qr-container {
text-align: center;
width: 100%;
}
.qr-img {
width: 40mm;
height: 40mm;
margin-bottom: 2mm;
}
.kode-item {
font-weight: bold;
font-size: 14pt;
}
</style>
</head>
<body>
<div class="qr-container">
<img id="qr-img" class="qr-img" src="${qrCodeUrl.value}" alt="QR Code" />
<div class="kode-item">${selectedItem.value.kode_item}</div>
</div>
</body>
</html>
`);
printWindow.document.close();
const img = printWindow.document.getElementById("qr-img");
img.onload = () => {
printWindow.focus();
printWindow.print();
};
}
};
// ambil data dari backend
onMounted(async () => {
const confirmDelete = async () => {
if (!selectedItem.value) return;
try {
const res = await axios.get("/api/nampan");
trays.value = res.data; // harus array tray dengan items
console.log("Data nampan:", res.data);
await axios.delete(`/api/item/${selectedItem.value.id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
await refreshData();
showDeleteConfirm.value = false;
closePopup();
} catch (err) {
console.error("Gagal menghapus item:", err.response?.data || err);
error.value = err.response?.data?.message || "Gagal menghapus item. Silakan coba lagi.";
}
};
const cancelDelete = () => {
showDeleteConfirm.value = false;
};
// --- Fungsi Pop-up ---
const openMovePopup = (item) => {
selectedItem.value = item;
selectedTrayId.value = item.id_nampan;
isPopupVisible.value = true;
};
const closePopup = () => {
isPopupVisible.value = false;
selectedItem.value = null;
selectedTrayId.value = "";
};
const saveMove = async () => {
if (!selectedTrayId.value || !selectedItem.value) return;
try {
await axios.put(
`/api/item/${selectedItem.value.id}`,
{
id_nampan: selectedTrayId.value,
id_produk: selectedItem.value.id_produk,
},
{
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
}
);
await refreshData();
closePopup();
} catch (err) {
console.error("Gagal memindahkan item:", err.response?.data || err);
error.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
}
};
// Hitung total berat
const totalWeight = (tray) => {
if (!tray.items) return 0;
const total = tray.items.reduce((sum, item) => sum + (item.produk?.berat || 0), 0);
return total.toFixed(2);
};
// Computed untuk statistik berat rata-rata
const averageWeight = computed(() => {
const nonEmptyTraysData = trays.value.filter(tray => {
const weight = parseFloat(totalWeight(tray));
return weight > 0;
});
if (nonEmptyTraysData.length === 0) return "0.00";
const totalWeightSum = nonEmptyTraysData.reduce((sum, tray) => {
return sum + parseFloat(totalWeight(tray));
}, 0);
const average = totalWeightSum / nonEmptyTraysData.length;
return average.toFixed(2);
});
// Computed untuk statistik tambahan
const totalTrays = computed(() => trays.value.length);
const nonEmptyTrays = computed(() => {
return trays.value.filter(tray => parseFloat(totalWeight(tray)) > 0).length;
});
const emptyTrays = computed(() => {
return trays.value.filter(tray => parseFloat(totalWeight(tray)) === 0).length;
});
// Ambil data nampan + item
const refreshData = async () => {
try {
const nampanRes = await axios.get("/api/nampan", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
trays.value = nampanRes.data;
} catch (err) {
error.value = err.message || "Gagal mengambil data";
} finally {
loading.value = false;
}
});
};
// filter berdasarkan nama nampan
// Filter nampan
const filteredTrays = computed(() => {
if (!props.search) return trays.value;
return trays.value.filter((tray) =>
tray.nama.toLowerCase().includes(props.search.toLowerCase())
);
});
onMounted(() => {
refreshData();
});
// Expose refreshData to parent
defineExpose({ refreshData });
</script>

View File

@ -1,12 +1,11 @@
<template>
<div class="flex justify-end mb-4">
<input
v-model="searchText"
type="text"
placeholder="Cari ..."
class="border rounded-md px-3 py-2 w-64 focus:outline-none focus:ring-2 focus:ring-blue-400"
@input="$emit('update:search', searchText)"
/>
<div class="border border-C bg-A rounded-md w-full relative items-center">
<input v-model="searchText" type="text" placeholder="Cari ..."
class="focus:outline-none focus:ring-2 focus:ring-blue-400 rounded-md w-full px-3 py-2 "
@input="$emit('update:search', searchText)" />
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-C">
<i class="fas fa-search"></i>
</div>
</div>
</template>
<script setup>

View File

@ -1,10 +1,19 @@
<template>
<Header />
<div class="mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
<div class="min-h-screen flex flex-col">
<!-- Navbar -->
<NavigationComponent />
<!-- Konten utama -->
<div class="flex-1 mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
<slot />
</div>
<!-- Footer selalu di bawah -->
<Footer class="w-full" />
</div>
</template>
<script setup>
import Header from '../components/Header.vue'
import Footer from '../components/Footer.vue'
import NavigationComponent from '../components/NavigationComponent.vue'
</script>

Some files were not shown because too many files have changed in this diff Show More