feat: add saldo balance tracking and item notes support for orders and reports

This commit is contained in:
Firman Ramdhani
2026-09-16 13:13:40 +07:00
parent 44466f1566
commit d5791e759e
16 changed files with 6793 additions and 2538 deletions
+109 -60
View File
@@ -13,8 +13,8 @@ import { PlusCircle, MinusCircle, CheckCircle2 } from 'lucide-react'
export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => void }) {
const [liveOrder, setLiveOrder] = useState<any>(order)
const [userId, setUserId] = useState<string>('')
const [items, setItems] = useState<Record<string, { selected: boolean, qty: number }>>({})
const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number }>>([])
const [items, setItems] = useState<Record<string, { selected: boolean, qty: number, note?: string }>>({})
const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number, note?: string }>>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -42,10 +42,15 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
if (!item.is_custom) {
const stdItem = currentOrder.available_items.find((ai: any) => ai.name === item.name)
if (stdItem) {
freshItems[stdItem.id] = { selected: true, qty: item.qty }
freshItems[stdItem.id] = { selected: true, qty: item.qty, note: item.note || '' }
}
} else {
newCustoms.push({ id: Math.random().toString(), name: item.name, qty: item.qty })
newCustoms.push({
id: Math.random().toString(36).substr(2, 9),
name: item.name,
qty: item.qty,
note: item.note || ''
})
}
})
setItems(freshItems)
@@ -53,30 +58,47 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
}
}
const handleStandardItemToggle = (itemId: string, checked: boolean) => {
const handleStandardItemToggle = (id: string, checked: boolean) => {
setItems(prev => ({
...prev,
[itemId]: { selected: checked, qty: checked ? 1 : 0 }
[id]: { selected: checked, qty: checked ? 1 : 0, note: prev[id]?.note || '' }
}))
}
const handleStandardItemQty = (itemId: string, qty: number) => {
const handleStandardItemQty = (id: string, qty: number) => {
if (qty < 1) {
handleStandardItemToggle(itemId, false)
handleStandardItemToggle(id, false)
return
}
setItems(prev => ({
...prev,
[itemId]: { selected: true, qty }
[id]: { ...prev[id], qty }
}))
}
const handleStandardItemNote = (id: string, note: string) => {
setItems(prev => ({
...prev,
[id]: { ...prev[id], note }
}))
}
const addCustomItem = () => {
setCustomItems([...customItems, { id: Math.random().toString(), name: '', qty: 1 }])
setCustomItems(prev => [...prev, { id: Math.random().toString(36).substr(2, 9), name: '', qty: 1, note: '' }])
}
const updateCustomItem = (id: string, field: 'name' | 'qty', value: any) => {
setCustomItems(customItems.map(c => c.id === id ? { ...c, [field]: value } : c))
const handleCustomItemChange = (id: string, field: 'name' | 'qty' | 'note', value: string | number) => {
setCustomItems(prev => prev.map(item => {
if (item.id === id) {
return { ...item, [field]: value }
}
return item
}))
}
const handleCustomItemQty = (id: string, qty: number) => {
if (qty < 1) return
handleCustomItemChange(id, 'qty', qty)
}
const removeCustomItem = (id: string) => {
@@ -91,15 +113,25 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
const payloadItems: any[] = []
liveOrder.available_items.forEach((ai: any) => {
const state = items[ai.id]
if (state?.selected && state.qty > 0) {
payloadItems.push({ name: ai.name, qty: state.qty, is_custom: false })
const selectedItem = items[ai.id]
if (selectedItem?.selected && selectedItem.qty > 0) {
payloadItems.push({
name: ai.name,
qty: selectedItem.qty,
is_custom: false,
note: selectedItem.note?.trim() || undefined
})
}
})
customItems.forEach(ci => {
if (ci.name.trim() && ci.qty > 0) {
payloadItems.push({ name: ci.name.trim(), qty: ci.qty, is_custom: true })
payloadItems.push({
name: ci.name.trim(),
qty: ci.qty,
is_custom: true,
note: ci.note?.trim() || undefined
})
}
})
@@ -149,50 +181,63 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
<div
key={item.id}
className={cn(
"flex items-center justify-between p-3.5 rounded-xl border transition-all",
isSelected
? "border-[#1B2CC1] bg-[#1B2CC1]/5 dark:bg-blue-950/20 shadow-sm"
: "border-slate-200/80 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50"
"flex flex-col gap-2 p-3.5 rounded-xl border transition-all",
isSelected ? "border-[#1B2CC1]/40 bg-blue-50/20 dark:bg-blue-950/10 shadow-sm" : "border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40"
)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
id={`item-${item.id}`}
checked={isSelected && !item.is_sold_out}
disabled={item.is_sold_out}
onCheckedChange={(c) => !item.is_sold_out && handleStandardItemToggle(item.id, c as boolean)}
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1] disabled:opacity-50"
/>
<Label htmlFor={`item-${item.id}`} className={cn("text-sm font-bold truncate flex items-center gap-2", item.is_sold_out ? "text-slate-400 dark:text-slate-500 cursor-not-allowed" : "text-slate-800 dark:text-slate-200 cursor-pointer")}>
<span className={item.is_sold_out ? "line-through" : ""}>{item.name}</span>
{item.is_sold_out && (
<span className="text-[9px] bg-rose-100 dark:bg-rose-950/30 text-rose-600 dark:text-rose-400 px-1.5 py-0.5 rounded font-black uppercase tracking-wider border border-rose-200 dark:border-rose-900/50">
Sold Out
</span>
)}
</Label>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
id={`item-${item.id}`}
checked={isSelected && !item.is_sold_out}
disabled={item.is_sold_out || loading}
onCheckedChange={(c) => !item.is_sold_out && handleStandardItemToggle(item.id, c as boolean)}
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1] disabled:opacity-50"
/>
<Label htmlFor={`item-${item.id}`} className={cn("text-sm font-bold truncate flex items-center gap-2", item.is_sold_out ? "text-slate-400 dark:text-slate-500 cursor-not-allowed" : "text-slate-800 dark:text-slate-200 cursor-pointer")}>
<span className={item.is_sold_out ? "line-through" : ""}>{item.name}</span>
{item.is_sold_out && (
<span className="text-[9px] bg-rose-100 dark:bg-rose-950/30 text-rose-600 dark:text-rose-400 px-1.5 py-0.5 rounded font-black uppercase tracking-wider border border-rose-200 dark:border-rose-900/50">
Sold Out
</span>
)}
</Label>
</div>
{isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
disabled={loading}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty - 1)}
>
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={loading}
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div>
)}
</div>
{isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty - 1)}
>
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
<div className="pl-7 mt-1">
<Input
placeholder="Catatan (opsional), misal: 1/2 porsi"
className="h-8 text-xs bg-slate-50/50 dark:bg-slate-900/50 border-slate-200/60 dark:border-slate-700/60"
value={items[item.id]?.note || ''}
disabled={loading}
onChange={(e) => handleStandardItemNote(item.id, e.target.value)}
/>
</div>
)}
</div>
@@ -217,6 +262,7 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
type="button"
variant="outline"
size="sm"
disabled={loading}
onClick={addCustomItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
>
@@ -230,24 +276,27 @@ export function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: ()
<div key={ci.id} className="flex gap-2 items-center p-3 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span>
<Input
placeholder="Nama Menu / Catatan Khusus"
placeholder="Nama Menu Kustom"
value={ci.name}
onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)}
disabled={loading}
onChange={(e) => handleCustomItemChange(ci.id, 'name', e.target.value)}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
/>
<Input
type="number"
min="1"
value={ci.qty}
onChange={(e) => updateCustomItem(ci.id, 'qty', parseInt(e.target.value) || 1)}
disabled={loading}
onChange={(e) => handleCustomItemChange(ci.id, 'qty', parseInt(e.target.value) || 1)}
className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={loading}
onClick={() => removeCustomItem(ci.id)}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg shrink-0"
>
<MinusCircle className="h-4 w-4" />
</Button>