Spaces:
Sleeping
Sleeping
| // 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 | |
| }; | |