pratikfrontend / components /shared /BillComponents.tsx
Antaram's picture
Upload 61 files
24c454f verified
Raw
History Blame Contribute Delete
19.7 kB
// Shared Bill Components
// Extracted from AwaakBill.tsx and JawaakBill.tsx to reduce code duplication
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Search, ChevronDown, Check, X } from 'lucide-react';
import { Party, PartyType } from '../types';
// ============================================
// PARTY DROPDOWN COMPONENT
// ============================================
export interface PartyDropdownProps {
parties: Party[];
selectedPartyId: string;
onSelectParty: (partyId: string) => void;
partyType?: PartyType | 'all';
placeholder?: string;
disabled?: boolean;
className?: string;
}
export const PartyDropdown: React.FC<PartyDropdownProps> = ({
parties,
selectedPartyId,
onSelectParty,
partyType = 'all',
placeholder = 'Select Party (पार्टी निवडा)',
disabled = false,
className = ''
}) => {
const [search, setSearch] = useState('');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
// Filter parties by type
const filteredParties = useMemo(() => {
let filtered = parties;
if (partyType !== 'all') {
filtered = parties.filter(p =>
p.party_type === partyType || p.party_type === PartyType.BOTH
);
}
// Apply search filter
if (search.trim()) {
const term = search.toLowerCase().trim();
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(term) ||
(p.city && p.city.toLowerCase().includes(term)) ||
(p.phone && p.phone.includes(term))
);
}
return filtered;
}, [parties, partyType, search]);
// Get selected party
const selectedParty = useMemo(() => {
return parties.find(p => p.id === selectedPartyId);
}, [parties, selectedPartyId]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (partyId: string) => {
onSelectParty(partyId);
setSearch('');
setIsOpen(false);
};
return (
<div ref={dropdownRef} className={`relative ${className}`}>
{/* Trigger Button */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={`w-full flex items-center justify-between px-3 py-2.5 border rounded-lg text-left transition-colors ${
disabled
? 'bg-gray-100 cursor-not-allowed text-gray-400'
: 'bg-white hover:border-teal-400 focus:ring-2 focus:ring-teal-500 focus:border-teal-500'
} ${isOpen ? 'ring-2 ring-teal-500 border-teal-500' : 'border-gray-300'}`}
>
<div className="flex items-center gap-2 overflow-hidden">
{selectedParty ? (
<>
<span className="font-medium text-gray-800 truncate">
{selectedParty.name}
</span>
{selectedParty.city && (
<span className="text-xs text-gray-500 truncate">
({selectedParty.city})
</span>
)}
</>
) : (
<span className="text-gray-400">{placeholder}</span>
)}
</div>
<ChevronDown
size={18}
className={`text-gray-400 transition-transform flex-shrink-0 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-64 overflow-hidden">
{/* Search Input */}
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
placeholder="Search party..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded-md focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none"
autoFocus
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-gray-100 rounded"
>
<X size={14} className="text-gray-400" />
</button>
)}
</div>
</div>
{/* Party List */}
<div className="overflow-y-auto max-h-52">
{filteredParties.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">
{search ? 'No parties found' : 'No parties available'}
</div>
) : (
filteredParties.map(party => (
<button
key={party.id}
type="button"
onClick={() => handleSelect(party.id)}
className={`w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-teal-50 transition-colors ${
party.id === selectedPartyId ? 'bg-teal-50' : ''
}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-800 truncate">
{party.name}
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
{party.city && <span>{party.city}</span>}
{party.phone && <span>• {party.phone}</span>}
</div>
</div>
{party.id === selectedPartyId && (
<Check size={16} className="text-teal-600 flex-shrink-0" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
};
// ============================================
// SUMMARY INPUT COMPONENT
// ============================================
export interface SummaryInputProps {
label: string;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
placeholder?: string;
suffix?: string;
className?: string;
}
export const SummaryInput: React.FC<SummaryInputProps> = ({
label,
value,
onChange,
disabled = false,
min = 0,
max,
step = 0.01,
placeholder = '0',
suffix,
className = ''
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseFloat(e.target.value);
let newValue = isNaN(parsed) ? 0 : parsed;
// Apply min/max constraints
newValue = Math.max(min, newValue);
if (max !== undefined) {
newValue = Math.min(max, newValue);
}
onChange(newValue);
};
return (
<div className={`flex justify-between items-center mb-1 bg-gray-50 p-1.5 rounded ${className}`}>
<span className="text-gray-600 text-xs">{label}</span>
<div className="flex items-center gap-1">
<input
type="number"
step={step}
min={min}
max={max}
disabled={disabled}
className={`w-24 border rounded text-right p-1 text-sm bg-white focus:ring-1 focus:ring-teal-500 outline-none appearance-none ${
disabled ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
value={value === 0 ? '' : value}
onChange={handleChange}
placeholder={placeholder}
/>
{suffix && <span className="text-xs text-gray-500">{suffix}</span>}
</div>
</div>
);
};
// ============================================
// PAYMENT SECTION COMPONENT
// ============================================
export interface PaymentSectionProps {
totalAmount: number;
paymentMode: 'cash' | 'online' | 'hybrid' | 'due';
onPaymentModeChange: (mode: 'cash' | 'online' | 'hybrid' | 'due') => void;
cashAmount: number;
onCashAmountChange: (amount: number) => void;
onlineAmount: number;
onOnlineAmountChange: (amount: number) => void;
disabled?: boolean;
className?: string;
}
export const PaymentSection: React.FC<PaymentSectionProps> = ({
totalAmount,
paymentMode,
onPaymentModeChange,
cashAmount,
onCashAmountChange,
onlineAmount,
onOnlineAmountChange,
disabled = false,
className = ''
}) => {
// Calculate due amount
const dueAmount = useMemo(() => {
if (paymentMode === 'due') return totalAmount;
if (paymentMode === 'hybrid') {
return Math.max(0, totalAmount - cashAmount - onlineAmount);
}
return 0;
}, [totalAmount, paymentMode, cashAmount, onlineAmount]);
// Handle hybrid payment auto-calculation
const handleHybridCashChange = (cash: number) => {
onCashAmountChange(cash);
const remaining = totalAmount - cash;
if (remaining >= 0) {
onOnlineAmountChange(remaining);
}
};
const handleHybridOnlineChange = (online: number) => {
onOnlineAmountChange(online);
const remaining = totalAmount - online;
if (remaining >= 0) {
onCashAmountChange(remaining);
}
};
// Auto-fill amounts when mode changes
useEffect(() => {
if (paymentMode === 'cash') {
onCashAmountChange(totalAmount);
onOnlineAmountChange(0);
} else if (paymentMode === 'online') {
onCashAmountChange(0);
onOnlineAmountChange(totalAmount);
} else if (paymentMode === 'due') {
onCashAmountChange(0);
onOnlineAmountChange(0);
} else if (paymentMode === 'hybrid') {
// Keep existing values or default to 50-50 split
if (cashAmount === 0 && onlineAmount === 0) {
const half = Math.round(totalAmount / 2);
onCashAmountChange(half);
onOnlineAmountChange(totalAmount - half);
}
}
}, [paymentMode, totalAmount]);
return (
<div className={`space-y-3 ${className}`}>
{/* Payment Mode Selection */}
<div className="grid grid-cols-4 gap-2">
{(['cash', 'online', 'hybrid', 'due'] as const).map(mode => (
<button
key={mode}
type="button"
onClick={() => !disabled && onPaymentModeChange(mode)}
disabled={disabled}
className={`px-3 py-2 text-xs font-medium rounded-lg border transition-colors ${
paymentMode === mode
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-teal-400'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{mode === 'cash' && 'Cash'}
{mode === 'online' && 'Online'}
{mode === 'hybrid' && 'Hybrid'}
{mode === 'due' && 'Due'}
</button>
))}
</div>
{/* Amount Inputs */}
{paymentMode === 'hybrid' && (
<div className="space-y-2">
<SummaryInput
label="Cash Amount"
value={cashAmount}
onChange={handleHybridCashChange}
disabled={disabled}
min={0}
max={totalAmount}
/>
<SummaryInput
label="Online Amount"
value={onlineAmount}
onChange={handleHybridOnlineChange}
disabled={disabled}
min={0}
max={totalAmount}
/>
</div>
)}
{/* Summary */}
<div className="bg-gray-50 rounded-lg p-3 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total Amount:</span>
<span className="font-medium">₹{totalAmount.toFixed(2)}</span>
</div>
{paymentMode !== 'due' && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Paid Amount:</span>
<span className="font-medium text-green-600">
₹{(paymentMode === 'hybrid' ? cashAmount + onlineAmount :
paymentMode === 'cash' ? cashAmount : onlineAmount).toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between text-sm font-medium pt-1 border-t border-gray-200">
<span className="text-gray-700">Due Amount:</span>
<span className={dueAmount > 0 ? 'text-red-600' : 'text-green-600'}>
₹{dueAmount.toFixed(2)}
</span>
</div>
</div>
</div>
);
};
// ============================================
// MOBILE SUMMARY PANEL COMPONENT
// ============================================
export interface MobileSummaryPanelProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const MobileSummaryPanel: React.FC<MobileSummaryPanelProps> = ({
isOpen,
onClose,
title,
children
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 md:hidden">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
{/* Panel */}
<div className="absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl shadow-2xl max-h-[80vh] overflow-hidden animate-slide-up">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-800">{title}</h3>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded-full"
>
<X size={20} className="text-gray-600" />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-4">
{children}
</div>
</div>
{/* Animation */}
<style>{`
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
`}</style>
</div>
);
};
// ============================================
// LOADING BUTTON COMPONENT
// ============================================
export interface LoadingButtonProps {
onClick: () => void | Promise<void>;
isLoading: boolean;
loadingText?: string;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
className?: string;
icon?: React.ReactNode;
}
export const LoadingButton: React.FC<LoadingButtonProps> = ({
onClick,
isLoading,
loadingText = 'Saving...',
children,
variant = 'primary',
disabled = false,
className = '',
icon
}) => {
const variantStyles = {
primary: 'bg-teal-600 hover:bg-teal-700 text-white',
secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-700',
danger: 'bg-red-500 hover:bg-red-600 text-white'
};
return (
<button
type="button"
onClick={onClick}
disabled={disabled || isLoading}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 ${
variantStyles[variant]
} ${disabled || isLoading ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
{isLoading ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{loadingText}
</>
) : (
<>
{icon}
{children}
</>
)}
</button>
);
};
export default {
PartyDropdown,
SummaryInput,
PaymentSection,
MobileSummaryPanel,
LoadingButton
};