6 Commits
10 changed files with 124 additions and 61 deletions
+17 -17
View File
@@ -166,13 +166,13 @@ export default function BalancesPage() {
return (
<div className="animate-in fade-in space-y-6 pb-12 duration-500">
<div>
<span className="text-xs font-semibold text-slate-400">Keuangan / Saldo</span>
<span className="text-xs font-semibold text-slate-400">Keuangan / Kas</span>
<h2 className="flex items-center gap-2 text-xl font-black tracking-tight text-slate-900 dark:text-white">
<Wallet className="h-6 w-6 text-[#1B2CC1]" />
Buku Saldo
Kas & Hutang
</h2>
<p className="mt-1 text-sm text-slate-500">
Pantau riwayat saldo lebih atau kurang dari transaksi pesanan.
Pantau riwayat uang deposit (lebih bayar) dan hutang (kurang bayar).
</p>
</div>
@@ -186,7 +186,7 @@ export default function BalancesPage() {
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)}
>
Saldo Saya (Penitip)
Kas Anda
</button>
<button
onClick={() => setActiveTab('CREATOR')}
@@ -197,7 +197,7 @@ export default function BalancesPage() {
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)}
>
Saldo Orang (Kreator)
Kas Penitip
</button>
</div>
@@ -210,10 +210,10 @@ export default function BalancesPage() {
<div className="space-y-5">
<div className="rounded-xl border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
<h3 className="mb-1 text-sm font-bold text-blue-900 dark:text-blue-100">
Saldo Anda di Kreator Lain
Deposit & Hutang Anda
</h3>
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300">
Jika saldo <strong>positif</strong> (hijau), Anda memiliki deposit yang bisa digunakan
Jika kas <strong>positif</strong> (hijau), Anda memiliki deposit yang bisa digunakan
untuk pesanan berikutnya. Jika <strong>negatif</strong> (merah), Anda berhutang.
</p>
</div>
@@ -223,9 +223,9 @@ export default function BalancesPage() {
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div>
<p className="text-sm font-semibold text-slate-500">Belum ada catatan saldo</p>
<p className="text-sm font-semibold text-slate-500">Belum ada catatan kas</p>
<p className="mt-1 text-xs text-slate-400">
Saldo muncul saat ada sisa atau kekurangan bayar dari PO.
Kas muncul saat ada deposit atau hutang dari PO.
</p>
</div>
) : (
@@ -295,7 +295,7 @@ export default function BalancesPage() {
{/* Amount */}
<div className="shrink-0 text-right">
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
Total Saldo
{isPositive ? 'Total Deposit' : 'Total Hutang'}
</p>
<p
className={cn(
@@ -354,12 +354,12 @@ export default function BalancesPage() {
<div className="space-y-5">
<div className="rounded-xl border border-amber-100 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<h3 className="mb-1 text-sm font-bold text-amber-900 dark:text-amber-100">
Saldo Orang Lain di Anda
Kas & Hutang Penitip di Anda
</h3>
<p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300">
Saldo <strong>positif</strong> = Anda memegang uang lebih milik penitip. Klik{' '}
<strong>Kembalikan</strong> untuk mencatatnya. Saldo <strong>negatif</strong> =
penitip masih berhutang ke Anda.
Kas <strong>positif</strong> = Anda memegang uang deposit milik penitip. Klik{' '}
<strong>Kembalikan</strong> untuk mencatatnya. Kas <strong>negatif</strong> = penitip
masih berhutang ke Anda.
</p>
</div>
@@ -368,9 +368,9 @@ export default function BalancesPage() {
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
<Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div>
<p className="text-sm font-semibold text-slate-500">Tidak ada catatan saldo</p>
<p className="text-sm font-semibold text-slate-500">Tidak ada catatan kas</p>
<p className="mt-1 text-xs text-slate-400">
Saldo muncul saat ada kelebihan atau kekurangan bayar.
Kas muncul saat ada deposit atau hutang dari PO.
</p>
</div>
) : (
@@ -443,7 +443,7 @@ export default function BalancesPage() {
{/* Amount */}
<div className="shrink-0 text-right">
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
{isPositive ? 'Anda Hutang' : 'Piutang Anda'}
{isPositive ? 'Total Deposit' : 'Total Piutang'}
</p>
<p
className={cn(
+4 -2
View File
@@ -506,11 +506,13 @@ function SubmissionRow({
useEffect(() => {
if (currentBalance !== undefined) {
let balanceBeforeThisOrder = currentBalance
if (sub.payment_status === 'LUNAS') {
const shouldCountBill = sub.bill != null && (isClosed || sub.payment_status === 'LUNAS')
if (shouldCountBill) {
const diff = (sub.paid_amount || 0) - (sub.bill || 0)
balanceBeforeThisOrder -= diff
} else {
balanceBeforeThisOrder += sub.saldo_used || 0
const diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
balanceBeforeThisOrder -= diff
}
setAvailableBalance(Math.max(0, balanceBeforeThisOrder))
}
+21 -10
View File
@@ -1,7 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { getSessionUser, updateProfile, changePassword, getSettings, getUserNotificationConfig, updateUserNotificationConfig } from '@/app/actions'
import {
getSessionUser,
updateProfile,
changePassword,
getSettings,
getUserNotificationConfig,
updateUserNotificationConfig,
} from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -65,7 +72,7 @@ export default function ProfilePage() {
const [globalSettings, notifConfig] = await Promise.all([
getSettings(),
getUserNotificationConfig(sessionUser.id)
getUserNotificationConfig(sessionUser.id),
])
if (globalSettings?.MATTERMOST_NOTIF_ENABLED === 'true') {
@@ -93,10 +100,12 @@ export default function ProfilePage() {
const [res, notifRes] = await Promise.all([
updateProfile(user.id, name.trim(), photo.trim() || null),
globalNotifEnabled ? updateUserNotificationConfig(user.id, {
globalNotifEnabled
? updateUserNotificationConfig(user.id, {
mattermost_channel_id: mmChannelId,
is_active: mmActive
}) : Promise.resolve({ success: true, error: null })
is_active: mmActive,
})
: Promise.resolve({ success: true, error: null }),
])
if (res.success && notifRes.success) {
@@ -244,7 +253,7 @@ export default function ProfilePage() {
</div>
{globalNotifEnabled && (
<div className="space-y-6 pt-6 mt-6 border-t border-slate-100 dark:border-slate-800">
<div className="mt-6 space-y-6 border-t border-slate-100 pt-6 dark:border-slate-800">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
<MessageCircle className="h-5 w-5" />
@@ -259,7 +268,7 @@ export default function ProfilePage() {
</div>
</div>
<div className="flex flex-row items-start space-x-3 space-y-0 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<div className="flex flex-row items-start space-y-0 space-x-3 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<input
type="checkbox"
id="mmActive"
@@ -278,14 +287,17 @@ export default function ProfilePage() {
</div>
<div className="space-y-1.5">
<Label htmlFor="mm-channel" className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300">
<Label
htmlFor="mm-channel"
className="text-xs font-bold tracking-wider text-slate-700 uppercase dark:text-slate-300"
>
Username / Channel ID
</Label>
<Input
id="mm-channel"
value={mmChannelId}
onChange={(e) => setMmChannelId(e.target.value)}
placeholder="Contoh: @firman atau 9dpxnitm..."
placeholder="Contoh: budisantoso atau 9dpxnitm..."
className="h-11 rounded-xl"
/>
</div>
@@ -321,7 +333,6 @@ export default function ProfilePage() {
</div>
</form>
</Card>
</div>
{/* Right 1 Col: Account Details */}
+30 -13
View File
@@ -3,7 +3,8 @@
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { hashPassword, verifyPassword, encrypt, decrypt } from '@/lib/auth'
import { encrypt, decrypt } from '@/lib/auth'
import { hashPassword, verifyPassword } from '@/lib/password'
import { cookies } from 'next/headers'
import { sendMattermostNotification } from '@/lib/mattermost'
@@ -438,6 +439,19 @@ export async function processBulkPayment(data: {
// Sort by order date ascending (oldest first)
submissions.sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime())
// Reverse the effect of these submissions on currentBalance to get true available balance
if (data.use_balance) {
for (const sub of submissions) {
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
if (shouldCountBill) {
currentBalance -= ((sub.paid_amount || 0) - (sub.bill || 0))
} else {
currentBalance -= ((sub.paid_amount || 0) - (sub.saldo_used || 0))
}
}
currentBalance = Math.max(0, currentBalance)
}
let remainingBalance = currentBalance
let remainingCash = data.cash_amount
let totalAvailable = remainingBalance + remainingCash
@@ -669,7 +683,10 @@ export async function getBalancesAsCreator(creator_id: string) {
const [submissions, withdrawals] = await Promise.all([
prisma.submission.findMany({
where: { order: { creator_id } },
include: { user: { select: { id: true, name: true, photo: true } } },
include: {
user: { select: { id: true, name: true, photo: true } },
order: { select: { status: true } },
},
}),
prisma.balanceWithdrawal.findMany({
where: { creator_id },
@@ -681,12 +698,12 @@ export async function getBalancesAsCreator(creator_id: string) {
submissions.forEach((sub) => {
let diff = 0
if (sub.payment_status === 'LUNAS') {
if (sub.bill != null) {
diff = (sub.paid_amount || 0) - sub.bill
}
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
if (shouldCountBill) {
diff = (sub.paid_amount || 0) - sub.bill!
} else {
diff = -(sub.saldo_used || 0)
diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
}
if (diff !== 0) {
@@ -732,12 +749,12 @@ export async function getBalancesAsSubmittor(user_id: string) {
submissions.forEach((sub) => {
let diff = 0
if (sub.payment_status === 'LUNAS') {
if (sub.bill != null) {
diff = (sub.paid_amount || 0) - sub.bill
}
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
if (shouldCountBill) {
diff = (sub.paid_amount || 0) - sub.bill!
} else {
diff = -(sub.saldo_used || 0)
diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
}
if (diff !== 0) {
@@ -945,7 +962,7 @@ export async function getPiutangSummary(creator_id: string) {
select: { id: true, name: true, photo: true },
},
order: {
select: { id: true, title: true, date: true },
select: { id: true, title: true, date: true, status: true },
},
},
orderBy: {
+15 -1
View File
@@ -96,7 +96,21 @@ export default function PiutangManager({ creatorId }: { creatorId: string }) {
u.user.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const targetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0
let rawTargetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0
const userObj = data.find((u) => u.user.id === target.userId)
if (userObj) {
userObj.submissions.forEach((sub: any) => {
const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
if (shouldCountBill) {
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.bill || 0))
} else {
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.saldo_used || 0))
}
})
}
const targetUserBalance = Math.max(0, rawTargetUserBalance)
if (loading) {
return (
+19 -3
View File
@@ -92,7 +92,7 @@ export default function ReportByPersonGrid({
const unpaid = Math.max(0, bill - paid)
obj.totalPiutang += unpaid
obj.totalPaid += paid
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date, paid, unpaid })
obj.submissions.push({ ...sub, orderTitle: order.title, orderDate: order.date, orderStatus: order.status, paid, unpaid })
})
})
@@ -148,8 +148,24 @@ export default function ReportByPersonGrid({
}
}
const targetUserBalanceObj = balancesData.find((b) => b.user.id === target.userId)
const targetUserBalance = targetUserBalanceObj ? targetUserBalanceObj.amount : 0
let rawTargetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0
const userObj = userList.find((u) => u.user.id === target.userId)
if (userObj) {
userObj.submissions.forEach((sub: any) => {
// Only add back submissions that are part of the payment target
if (target.ids.includes(sub.id)) {
const shouldCountBill = sub.bill != null && (sub.orderStatus === 'CLOSE' || sub.payment_status === 'LUNAS')
if (shouldCountBill) {
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.bill || 0))
} else {
rawTargetUserBalance -= ((sub.paid_amount || 0) - (sub.saldo_used || 0))
}
}
})
}
const targetUserBalance = Math.max(0, rawTargetUserBalance)
return (
<>
+1 -1
View File
@@ -29,7 +29,7 @@ const menuItems = [
desc: 'Kelola PO buatan Anda',
},
{ name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan Anda' },
{ name: 'Buku Saldo', href: '/balances', icon: Wallet, desc: 'Pantau riwayat saldo' },
{ name: 'Kas & Hutang', href: '/balances', icon: Wallet, desc: 'Pantau kas dan hutang' },
{ name: 'Laporan', href: '/reports', icon: BarChart2, desc: 'Ringkasan transaksi' },
]
-9
View File
@@ -1,18 +1,9 @@
import { SignJWT, jwtVerify } from 'jose'
import bcrypt from 'bcryptjs'
import { cookies } from 'next/headers'
const secretKey = process.env.JWT_SECRET
const key = new TextEncoder().encode(secretKey)
export async function hashPassword(password: string) {
return await bcrypt.hash(password, 10)
}
export async function verifyPassword(password: string, hash: string) {
return await bcrypt.compare(password, hash)
}
export async function encrypt(payload: any) {
return await new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().sign(key) // No expiration as requested
}
+9
View File
@@ -0,0 +1,9 @@
import bcrypt from 'bcryptjs'
export async function hashPassword(password: string) {
return await bcrypt.hash(password, 10)
}
export async function verifyPassword(password: string, hash: string) {
return await bcrypt.compare(password, hash)
}
+7 -4
View File
@@ -2,14 +2,17 @@ import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { decrypt } from '@/lib/auth'
const protectedRoutes = ['/', '/my-orders', '/my-purchases', '/reports', '/profile', '/users']
const protectedRoutes = ['/', '/my-orders', '/my-purchases', '/reports', '/profile', '/users', '/balances']
const protectedPrefixes = ['/my-orders/', '/order/', '/settings/']
const publicRoutes = ['/login', '/register']
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname
// Also protect dynamic routes like /my-orders/[id]
const isProtectedRoute = protectedRoutes.includes(path) || path.startsWith('/my-orders/')
const isProtectedRoute =
protectedRoutes.includes(path) ||
protectedPrefixes.some(prefix => path.startsWith(prefix))
const isPublicRoute = publicRoutes.includes(path)
const cookie = request.cookies.get('session')?.value
@@ -23,7 +26,7 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/', request.nextUrl))
}
if (path.startsWith('/users') && session?.role !== 'superadmin') {
if (path.startsWith('/settings') && session?.role !== 'superadmin') {
return NextResponse.redirect(new URL('/', request.nextUrl))
}