7 Commits
13 changed files with 461 additions and 61 deletions
+15 -2
View File
@@ -19,8 +19,9 @@ model User {
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
orders Order[] @relation("CreatedOrders") orders Order[] @relation("CreatedOrders")
purchases Submission[] purchases Submission[]
creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals") creator_withdrawals BalanceWithdrawal[] @relation("CreatorWithdrawals")
user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals") user_withdrawals BalanceWithdrawal[] @relation("UserWithdrawals")
notification_config UserNotificationConfig?
} }
model Order { model Order {
@@ -91,3 +92,15 @@ model BalanceWithdrawal {
creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade) creator User @relation("CreatorWithdrawals", fields: [creator_id], references: [id], onDelete: Cascade)
user User @relation("UserWithdrawals", fields: [user_id], references: [id], onDelete: Cascade) user User @relation("UserWithdrawals", fields: [user_id], references: [id], onDelete: Cascade)
} }
model UserNotificationConfig {
id String @id @default(uuid())
user_id String @unique
mattermost_channel_id String?
telegram_chat_id String?
whatsapp_number String?
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
+17 -17
View File
@@ -166,13 +166,13 @@ export default function BalancesPage() {
return ( return (
<div className="animate-in fade-in space-y-6 pb-12 duration-500"> <div className="animate-in fade-in space-y-6 pb-12 duration-500">
<div> <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"> <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]" /> <Wallet className="h-6 w-6 text-[#1B2CC1]" />
Buku Saldo Kas & Hutang
</h2> </h2>
<p className="mt-1 text-sm text-slate-500"> <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> </p>
</div> </div>
@@ -186,7 +186,7 @@ export default function BalancesPage() {
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300' : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)} )}
> >
Saldo Saya (Penitip) Kas Anda
</button> </button>
<button <button
onClick={() => setActiveTab('CREATOR')} onClick={() => setActiveTab('CREATOR')}
@@ -197,7 +197,7 @@ export default function BalancesPage() {
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300' : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)} )}
> >
Saldo Orang (Kreator) Kas Penitip
</button> </button>
</div> </div>
@@ -210,10 +210,10 @@ export default function BalancesPage() {
<div className="space-y-5"> <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"> <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"> <h3 className="mb-1 text-sm font-bold text-blue-900 dark:text-blue-100">
Saldo Anda di Kreator Lain Deposit & Hutang Anda
</h3> </h3>
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300"> <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. untuk pesanan berikutnya. Jika <strong>negatif</strong> (merah), Anda berhutang.
</p> </p>
</div> </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"> <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" /> <Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div> </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"> <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> </p>
</div> </div>
) : ( ) : (
@@ -295,7 +295,7 @@ export default function BalancesPage() {
{/* Amount */} {/* Amount */}
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase"> <p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
Total Saldo {isPositive ? 'Total Deposit' : 'Total Hutang'}
</p> </p>
<p <p
className={cn( className={cn(
@@ -354,12 +354,12 @@ export default function BalancesPage() {
<div className="space-y-5"> <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"> <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"> <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> </h3>
<p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300"> <p className="text-xs leading-relaxed text-amber-700 dark:text-amber-300">
Saldo <strong>positif</strong> = Anda memegang uang lebih milik penitip. Klik{' '} Kas <strong>positif</strong> = Anda memegang uang deposit milik penitip. Klik{' '}
<strong>Kembalikan</strong> untuk mencatatnya. Saldo <strong>negatif</strong> = <strong>Kembalikan</strong> untuk mencatatnya. Kas <strong>negatif</strong> = penitip
penitip masih berhutang ke Anda. masih berhutang ke Anda.
</p> </p>
</div> </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"> <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" /> <Wallet className="h-7 w-7 text-slate-300 dark:text-slate-600" />
</div> </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"> <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> </p>
</div> </div>
) : ( ) : (
@@ -443,7 +443,7 @@ export default function BalancesPage() {
{/* Amount */} {/* Amount */}
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase"> <p className="text-[10px] font-semibold tracking-wider text-slate-400 uppercase">
{isPositive ? 'Anda Hutang' : 'Piutang Anda'} {isPositive ? 'Total Deposit' : 'Total Piutang'}
</p> </p>
<p <p
className={cn( className={cn(
+4 -2
View File
@@ -506,11 +506,13 @@ function SubmissionRow({
useEffect(() => { useEffect(() => {
if (currentBalance !== undefined) { if (currentBalance !== undefined) {
let balanceBeforeThisOrder = currentBalance 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) const diff = (sub.paid_amount || 0) - (sub.bill || 0)
balanceBeforeThisOrder -= diff balanceBeforeThisOrder -= diff
} else { } else {
balanceBeforeThisOrder += sub.saldo_used || 0 const diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
balanceBeforeThisOrder -= diff
} }
setAvailableBalance(Math.max(0, balanceBeforeThisOrder)) setAvailableBalance(Math.max(0, balanceBeforeThisOrder))
} }
+94 -5
View File
@@ -1,7 +1,14 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getSessionUser, updateProfile, changePassword } from '@/app/actions' import {
getSessionUser,
updateProfile,
changePassword,
getSettings,
getUserNotificationConfig,
updateUserNotificationConfig,
} from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -21,6 +28,7 @@ import {
UserCircle, UserCircle,
KeyRound, KeyRound,
LockKeyhole, LockKeyhole,
MessageCircle,
} from 'lucide-react' } from 'lucide-react'
export default function ProfilePage() { export default function ProfilePage() {
@@ -29,6 +37,13 @@ export default function ProfilePage() {
const [photo, setPhoto] = useState('') const [photo, setPhoto] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Notification Config state
const [globalNotifEnabled, setGlobalNotifEnabled] = useState(false)
const [mmChannelId, setMmChannelId] = useState('')
const [mmActive, setMmActive] = useState(true)
const [savingNotif, setSavingNotif] = useState(false)
const [notifSuccess, setNotifSuccess] = useState(false)
// Profile state // Profile state
const [savingProfile, setSavingProfile] = useState(false) const [savingProfile, setSavingProfile] = useState(false)
const [profileError, setProfileError] = useState('') const [profileError, setProfileError] = useState('')
@@ -54,6 +69,20 @@ export default function ProfilePage() {
setUser(sessionUser) setUser(sessionUser)
setName(sessionUser.name) setName(sessionUser.name)
setPhoto(sessionUser.photo || '') setPhoto(sessionUser.photo || '')
const [globalSettings, notifConfig] = await Promise.all([
getSettings(),
getUserNotificationConfig(sessionUser.id),
])
if (globalSettings?.MATTERMOST_NOTIF_ENABLED === 'true') {
setGlobalNotifEnabled(true)
}
if (notifConfig?.success && notifConfig.config) {
setMmChannelId(notifConfig.config.mattermost_channel_id || '')
setMmActive(notifConfig.config.is_active ?? true)
}
} }
setLoading(false) setLoading(false)
} }
@@ -69,14 +98,22 @@ export default function ProfilePage() {
setProfileSuccess(false) setProfileSuccess(false)
setSavingProfile(true) setSavingProfile(true)
const res = await updateProfile(user.id, name.trim(), photo.trim() || null) const [res, notifRes] = await Promise.all([
updateProfile(user.id, name.trim(), photo.trim() || null),
globalNotifEnabled
? updateUserNotificationConfig(user.id, {
mattermost_channel_id: mmChannelId,
is_active: mmActive,
})
: Promise.resolve({ success: true, error: null }),
])
if (res.success) { if (res.success && notifRes.success) {
setUser(res.user) setUser(res.user)
setProfileSuccess(true) setProfileSuccess(true)
setTimeout(() => setProfileSuccess(false), 3000) setTimeout(() => setProfileSuccess(false), 3000)
} else { } else {
setProfileError(res.error || 'Terjadi kesalahan') setProfileError(res.error || notifRes.error || 'Terjadi kesalahan')
} }
setSavingProfile(false) setSavingProfile(false)
} }
@@ -215,6 +252,58 @@ export default function ProfilePage() {
/> />
</div> </div>
{globalNotifEnabled && (
<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" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-white">
Notifikasi Mattermost
</h3>
<p className="mt-0.5 text-[11px] text-slate-500">
Terima update pesanan PO langsung di DM Mattermost Anda.
</p>
</div>
</div>
<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"
checked={mmActive}
onChange={(e) => setMmActive(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-[#1B2CC1] focus:ring-[#1B2CC1]"
/>
<div className="space-y-1 leading-none">
<Label htmlFor="mmActive" className="text-sm font-bold">
Aktifkan Notifikasi
</Label>
<p className="text-[11px] text-slate-500">
Kirim notifikasi setiap kali ada yang merubah pesanannya di PO Anda.
</p>
</div>
</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"
>
Username / Channel ID
</Label>
<Input
id="mm-channel"
value={mmChannelId}
onChange={(e) => setMmChannelId(e.target.value)}
placeholder="Contoh: budisantoso atau 9dpxnitm..."
className="h-11 rounded-xl"
/>
</div>
</div>
)}
{profileError && ( {profileError && (
<div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600"> <div className="rounded-xl bg-red-50 p-3 text-xs font-semibold text-red-600">
{profileError} {profileError}
@@ -223,7 +312,7 @@ export default function ProfilePage() {
{profileSuccess && ( {profileSuccess && (
<div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700"> <div className="flex items-center gap-2 rounded-xl bg-emerald-50 p-3 text-xs font-bold text-emerald-700">
<CheckCircle className="h-4 w-4" /> <CheckCircle className="h-4 w-4" />
<span>Profil berhasil diperbarui!</span> <span>Perubahan berhasil disimpan!</span>
</div> </div>
)} )}
</CardContent> </CardContent>
+51 -4
View File
@@ -8,11 +8,15 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
export default function IntegrationsPage() { export default function IntegrationsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
MATTERMOST_NOTIF_ENABLED: 'false',
MATTERMOST_BOT_TOKEN: '',
MATTERMOST_API_URL: '',
MATTERMOST_WEBHOOK_URL: '', MATTERMOST_WEBHOOK_URL: '',
MATTERMOST_TEMPLATE: '', MATTERMOST_TEMPLATE: '',
WHATSAPP_TEMPLATE: '', WHATSAPP_TEMPLATE: '',
@@ -23,6 +27,9 @@ export default function IntegrationsPage() {
const data = await getSettings() const data = await getSettings()
if (data) { if (data) {
setSettings({ setSettings({
MATTERMOST_NOTIF_ENABLED: data.MATTERMOST_NOTIF_ENABLED || 'false',
MATTERMOST_BOT_TOKEN: data.MATTERMOST_BOT_TOKEN || '',
MATTERMOST_API_URL: data.MATTERMOST_API_URL || '',
MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '', MATTERMOST_WEBHOOK_URL: data.MATTERMOST_WEBHOOK_URL || '',
MATTERMOST_TEMPLATE: MATTERMOST_TEMPLATE:
data.MATTERMOST_TEMPLATE || data.MATTERMOST_TEMPLATE ||
@@ -80,14 +87,54 @@ export default function IntegrationsPage() {
<div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/50 p-5 dark:border-slate-800 dark:bg-slate-900/50"> <div className="space-y-4 rounded-2xl border border-slate-200 bg-slate-50/50 p-5 dark:border-slate-800 dark:bg-slate-900/50">
<div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400"> <div className="flex items-center gap-2 font-black text-indigo-600 dark:text-indigo-400">
<MessageSquare className="h-5 w-5" /> <MessageSquare className="h-5 w-5" />
<h3>Mattermost Webhook</h3> <h3>Konfigurasi Mattermost</h3>
</div> </div>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Gunakan URL Incoming Webhook dari Mattermost untuk mengirim broadcast PO baru secara Atur integrasi Mattermost. Bagian atas untuk Notifikasi Personal (DM) ke Kreator, sedangkan bagian bawah untuk fitur Share (Broadcast) PO menggunakan Webhook.
otomatis.
</p> </p>
<div className="space-y-1.5"> <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 bg-white dark:bg-slate-950">
<Checkbox
id="mattermostNotifEnabled"
checked={settings.MATTERMOST_NOTIF_ENABLED === 'true'}
onCheckedChange={(checked) => handleChange('MATTERMOST_NOTIF_ENABLED', checked ? 'true' : 'false')}
/>
<div className="space-y-1 leading-none w-full">
<Label htmlFor="mattermostNotifEnabled" className="text-sm font-bold">
Aktifkan DM Kreator saat Update Pesanan (Menggunakan Bot API)
</Label>
<p className="text-xs text-slate-500 mb-3">
Kirim notifikasi otomatis ke channel/DM kreator PO tiap ada update pesanan. (Membutuhkan Bot Token).
</p>
{settings.MATTERMOST_NOTIF_ENABLED === 'true' && (
<div className="space-y-4 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<div className="space-y-1.5">
<Label className="text-[11px] font-bold text-slate-700 uppercase">API URL POST</Label>
<Input
value={settings.MATTERMOST_API_URL}
onChange={(e) => handleChange('MATTERMOST_API_URL', e.target.value)}
className="h-10 rounded-xl"
placeholder="https://mattermost.domain.com/api/v4/posts"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-bold text-slate-700 uppercase">Bot Bearer Token</Label>
<Input
type="password"
value={settings.MATTERMOST_BOT_TOKEN}
onChange={(e) => handleChange('MATTERMOST_BOT_TOKEN', e.target.value)}
className="h-10 rounded-xl"
placeholder="Masukan Bearer Token"
/>
</div>
</div>
)}
</div>
</div>
<div className="space-y-1.5 mt-6 pt-6 border-t border-slate-200 dark:border-slate-800">
<h4 className="text-sm font-bold text-slate-800 dark:text-slate-200 mb-3">Broadcast via Incoming Webhook</h4>
<Label className="text-xs font-bold text-slate-700 uppercase">Webhook URL</Label> <Label className="text-xs font-bold text-slate-700 uppercase">Webhook URL</Label>
<Input <Input
value={settings.MATTERMOST_WEBHOOK_URL} value={settings.MATTERMOST_WEBHOOK_URL}
+152 -13
View File
@@ -3,8 +3,10 @@
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache' 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 { cookies } from 'next/headers'
import { sendMattermostNotification } from '@/lib/mattermost'
// === AUTHENTICATION ACTIONS === // === AUTHENTICATION ACTIONS ===
export async function loginUser(username: string, password: string) { export async function loginUser(username: string, password: string) {
@@ -437,6 +439,19 @@ export async function processBulkPayment(data: {
// Sort by order date ascending (oldest first) // Sort by order date ascending (oldest first)
submissions.sort((a, b) => new Date(a.order.date).getTime() - new Date(b.order.date).getTime()) 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 remainingBalance = currentBalance
let remainingCash = data.cash_amount let remainingCash = data.cash_amount
let totalAvailable = remainingBalance + remainingCash let totalAvailable = remainingBalance + remainingCash
@@ -606,6 +621,10 @@ export async function submitOrder(data: {
}, },
}) })
} }
// Trigger notification without awaiting so it doesn't block
notifyCreatorOnSubmission(data.order_id, data.user_id, existing ? 'update' : 'create').catch(console.error)
revalidatePath('/') revalidatePath('/')
revalidatePath('/my-purchases') revalidatePath('/my-purchases')
@@ -664,7 +683,10 @@ export async function getBalancesAsCreator(creator_id: string) {
const [submissions, withdrawals] = await Promise.all([ const [submissions, withdrawals] = await Promise.all([
prisma.submission.findMany({ prisma.submission.findMany({
where: { order: { creator_id } }, 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({ prisma.balanceWithdrawal.findMany({
where: { creator_id }, where: { creator_id },
@@ -676,12 +698,12 @@ export async function getBalancesAsCreator(creator_id: string) {
submissions.forEach((sub) => { submissions.forEach((sub) => {
let diff = 0 let diff = 0
if (sub.payment_status === 'LUNAS') { const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
if (sub.bill != null) {
diff = (sub.paid_amount || 0) - sub.bill if (shouldCountBill) {
} diff = (sub.paid_amount || 0) - sub.bill!
} else { } else {
diff = -(sub.saldo_used || 0) diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
} }
if (diff !== 0) { if (diff !== 0) {
@@ -727,12 +749,12 @@ export async function getBalancesAsSubmittor(user_id: string) {
submissions.forEach((sub) => { submissions.forEach((sub) => {
let diff = 0 let diff = 0
if (sub.payment_status === 'LUNAS') { const shouldCountBill = sub.bill != null && (sub.order.status === 'CLOSE' || sub.payment_status === 'LUNAS')
if (sub.bill != null) {
diff = (sub.paid_amount || 0) - sub.bill if (shouldCountBill) {
} diff = (sub.paid_amount || 0) - sub.bill!
} else { } else {
diff = -(sub.saldo_used || 0) diff = (sub.paid_amount || 0) - (sub.saldo_used || 0)
} }
if (diff !== 0) { if (diff !== 0) {
@@ -853,6 +875,10 @@ export async function deleteSubmission(submission_id: string, user_id: string) {
await prisma.submission.delete({ await prisma.submission.delete({
where: { id: submission_id }, where: { id: submission_id },
}) })
// Trigger notification
notifyCreatorOnSubmission(submission.order_id, user_id, 'delete').catch(console.error)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
return { success: false, error: 'Gagal membatalkan pesanan' } return { success: false, error: 'Gagal membatalkan pesanan' }
@@ -936,7 +962,7 @@ export async function getPiutangSummary(creator_id: string) {
select: { id: true, name: true, photo: true }, select: { id: true, name: true, photo: true },
}, },
order: { order: {
select: { id: true, title: true, date: true }, select: { id: true, title: true, date: true, status: true },
}, },
}, },
orderBy: { orderBy: {
@@ -971,3 +997,116 @@ export async function getPiutangSummary(creator_id: string) {
return Array.from(grouped.values()).filter((g) => g.totalPiutang > 0) return Array.from(grouped.values()).filter((g) => g.totalPiutang > 0)
} }
// === SETTINGS ACTIONS ===
export async function getSystemSettings() {
try {
const settings = await prisma.setting.findMany()
const config: Record<string, string> = {}
settings.forEach((s: any) => { config[s.key] = s.value })
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function updateSystemSettings(key: string, value: string) {
try {
await prisma.setting.upsert({
where: { key },
update: { value },
create: { key, value }
})
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
// === USER NOTIFICATION CONFIG ACTIONS ===
export async function getUserNotificationConfig(userId: string) {
try {
const config = await prisma.userNotificationConfig.findUnique({
where: { user_id: userId }
})
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function updateUserNotificationConfig(userId: string, data: { mattermost_channel_id?: string, is_active?: boolean }) {
try {
const config = await prisma.userNotificationConfig.upsert({
where: { user_id: userId },
update: data,
create: { user_id: userId, ...data }
})
return { success: true, config }
} catch (e: any) {
return { success: false, error: e.message }
}
}
export async function notifyCreatorOnSubmission(orderId: string, submittorId: string, actionType: 'create' | 'update' | 'delete' = 'update') {
try {
const settings = await getSettings()
if (!settings || settings.MATTERMOST_NOTIF_ENABLED !== 'true') return
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
creator: {
include: { notification_config: true }
},
submissions: {
include: { user: true, items: true }
}
}
})
if (!order) return
const config = order.creator.notification_config
if (!config?.is_active || !config?.mattermost_channel_id) return
const submittor = await prisma.user.findUnique({ where: { id: submittorId } })
if (!submittor) return
const actionText = actionType === 'delete' ? 'membatalkan pesanan' : actionType === 'create' ? 'menambahkan pesanan baru' : 'merubah pesanan'
const headerMessage = `Ada yang ${actionText} dari **${submittor.name}** di PO **${order.title}**!`
let summaryByPerson = `**Rekap per Orang:**\n\`\`\`text\n${order.title}\n`
const itemCounts: Record<string, number> = {}
order.submissions?.forEach((sub: any) => {
const itemStrings = sub.items.map(
(i: any) => `${i.name} ${i.qty}x${i.note ? ` (${i.note})` : ''}`
)
summaryByPerson += `- ${sub.user?.name || 'Unknown'} : ${itemStrings.join(', ')}\n`
sub.items.forEach((i: any) => {
const key = i.note ? `${i.name} (${i.note})` : i.name
itemCounts[key] = (itemCounts[key] || 0) + i.qty
})
})
summaryByPerson += `\`\`\``
let summaryByItem = `**Rekap per Item (Akumulasi):**\n\`\`\`text\n${order.title}\n`
Object.entries(itemCounts).forEach(([name, qty]) => {
summaryByItem += `- ${name} : ${qty} pcs\n`
})
summaryByItem += `\`\`\``
const finalMessage = `${headerMessage}\n\n${summaryByPerson}\n\n${summaryByItem}`
await sendMattermostNotification(
config.mattermost_channel_id,
finalMessage,
settings.MATTERMOST_BOT_TOKEN,
settings.MATTERMOST_API_URL
)
} catch (error) {
console.error('Failed to notify creator:', error)
}
}
+15 -1
View File
@@ -96,7 +96,21 @@ export default function PiutangManager({ creatorId }: { creatorId: string }) {
u.user.name.toLowerCase().includes(searchQuery.toLowerCase()) 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) { if (loading) {
return ( return (
+19 -3
View File
@@ -92,7 +92,7 @@ export default function ReportByPersonGrid({
const unpaid = Math.max(0, bill - paid) const unpaid = Math.max(0, bill - paid)
obj.totalPiutang += unpaid obj.totalPiutang += unpaid
obj.totalPaid += paid 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) let rawTargetUserBalance = balancesData.find((b) => b.user.id === target.userId)?.amount || 0
const targetUserBalance = targetUserBalanceObj ? targetUserBalanceObj.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 ( return (
<> <>
+1 -1
View File
@@ -29,7 +29,7 @@ const menuItems = [
desc: 'Kelola PO buatan Anda', desc: 'Kelola PO buatan Anda',
}, },
{ name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan 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' }, { name: 'Laporan', href: '/reports', icon: BarChart2, desc: 'Ringkasan transaksi' },
] ]
-9
View File
@@ -1,18 +1,9 @@
import { SignJWT, jwtVerify } from 'jose' import { SignJWT, jwtVerify } from 'jose'
import bcrypt from 'bcryptjs'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
const secretKey = process.env.JWT_SECRET const secretKey = process.env.JWT_SECRET
const key = new TextEncoder().encode(secretKey) 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) { export async function encrypt(payload: any) {
return await new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().sign(key) // No expiration as requested return await new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().sign(key) // No expiration as requested
} }
+77
View File
@@ -0,0 +1,77 @@
export async function sendMattermostNotification(targetIdentifier: string, message: string, botToken?: string, apiUrl?: string) {
try {
const token = botToken || process.env.MATTERMOST_BOT_TOKEN || '5zubexudb38uuradfa36qy98ca'
// Ensure we get the base URL by stripping '/posts' if it exists in the configured URL
let baseUrl = apiUrl || process.env.MATTERMOST_API_URL || 'https://mattermost.eigen.co.id/api/v4/posts'
if (baseUrl.endsWith('/posts')) {
baseUrl = baseUrl.replace('/posts', '')
}
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
let finalChannelId = targetIdentifier
let targetUserId = targetIdentifier
// 1. If it's a username (starts with @ or doesn't look like a 26-char ID), look up the User ID
if (targetIdentifier.startsWith('@') || targetIdentifier.length !== 26) {
const username = targetIdentifier.replace('@', '').trim()
const userRes = await fetch(`${baseUrl}/users/usernames`, {
method: 'POST',
headers,
body: JSON.stringify([username]),
})
if (userRes.ok) {
const users = await userRes.json()
if (users && users.length > 0) {
targetUserId = users[0].id
}
}
}
// 2. Try to create a DM channel if we have a valid 26-char User ID
if (targetUserId.length === 26) {
// Fetch Bot's own User ID
const meRes = await fetch(`${baseUrl}/users/me`, { headers })
if (meRes.ok) {
const me = await meRes.json()
const botId = me.id
// Create Direct Message channel between Bot and Target User
const dmRes = await fetch(`${baseUrl}/channels/direct`, {
method: 'POST',
headers,
body: JSON.stringify([botId, targetUserId]),
})
if (dmRes.ok) {
const dmChannel = await dmRes.json()
finalChannelId = dmChannel.id
}
}
}
// 3. Post the message to the final resolved channel ID
const res = await fetch(`${baseUrl}/posts`, {
method: 'POST',
headers,
body: JSON.stringify({
channel_id: finalChannelId,
message,
}),
})
if (!res.ok) {
const errorText = await res.text()
console.error(`[Mattermost Error] Failed to send message to ${targetIdentifier}:`, errorText)
return { success: false, error: errorText }
}
return { success: true }
} catch (error: any) {
console.error(`[Mattermost Exception] Failed to send message to ${targetIdentifier}:`, error.message)
return { success: false, error: error.message }
}
}
+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 type { NextRequest } from 'next/server'
import { decrypt } from '@/lib/auth' 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'] const publicRoutes = ['/login', '/register']
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname const path = request.nextUrl.pathname
// Also protect dynamic routes like /my-orders/[id] const isProtectedRoute =
const isProtectedRoute = protectedRoutes.includes(path) || path.startsWith('/my-orders/') protectedRoutes.includes(path) ||
protectedPrefixes.some(prefix => path.startsWith(prefix))
const isPublicRoute = publicRoutes.includes(path) const isPublicRoute = publicRoutes.includes(path)
const cookie = request.cookies.get('session')?.value const cookie = request.cookies.get('session')?.value
@@ -23,7 +26,7 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/', request.nextUrl)) 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)) return NextResponse.redirect(new URL('/', request.nextUrl))
} }