Add Point of Sale system and tax rate lookup integration
POS System: - Full POS interface with product grid, cart panel, and payment flow - Product and category management with barcode scanning support - Cash drawer operations and shift management - Order history and receipt generation - Thermal printer integration (ESC/POS protocol) - Gift card support with purchase and redemption - Inventory tracking with low stock alerts - Customer selection and walk-in support Tax Rate Integration: - ZIP-to-state mapping for automatic state detection - SST boundary data import for 24 member states - Static rates for uniform-rate states (IN, MA, CT, etc.) - Statewide jurisdiction fallback for simple lookups - Tax rate suggestion in location editor with auto-apply - Multiple data sources: SST, CDTFA, TX Comptroller, Avalara UI Improvements: - POS renders full-screen outside BusinessLayout - Clear cart button prominently in cart header - Tax rate limited to 2 decimal places - Location tax rate field with suggestion UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,8 @@ const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import Pub
|
||||
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
|
||||
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
|
||||
const MediaGalleryPage = React.lazy(() => import('./pages/MediaGalleryPage')); // Import Media Gallery page
|
||||
const POS = React.lazy(() => import('./pages/POS')); // Import Point of Sale page
|
||||
const Products = React.lazy(() => import('./pages/Products')); // Import Products management page
|
||||
|
||||
// Settings pages
|
||||
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
||||
@@ -765,6 +767,18 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/login" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
|
||||
{/* Point of Sale - Full screen mode outside BusinessLayout */}
|
||||
<Route
|
||||
path="/dashboard/pos"
|
||||
element={
|
||||
canAccess('can_access_pos') ? (
|
||||
<POS />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dashboard routes inside BusinessLayout */}
|
||||
<Route
|
||||
element={
|
||||
@@ -989,6 +1003,17 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Products Management */}
|
||||
<Route
|
||||
path="/dashboard/products"
|
||||
element={
|
||||
canAccess('can_access_pos') ? (
|
||||
<Products />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Settings Routes with Nested Layout */}
|
||||
{/* Owners have full access, staff need can_access_settings permission */}
|
||||
{canAccess('can_access_settings') ? (
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
CalendarOff,
|
||||
Image,
|
||||
BarChart3,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||
import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
import UnfinishedBadge from './ui/UnfinishedBadge';
|
||||
import {
|
||||
@@ -41,6 +44,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
const { role } = user;
|
||||
const logoutMutation = useLogout();
|
||||
const { canUse } = usePlanFeatures();
|
||||
const { hasFeature } = useEntitlements();
|
||||
|
||||
// Helper to check if user has a specific staff permission
|
||||
// Owners always have all permissions
|
||||
@@ -139,6 +143,24 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Point of Sale Section - Requires tenant feature AND user permission */}
|
||||
{hasFeature(FEATURE_CODES.CAN_USE_POS) && hasPermission('can_access_pos') && (
|
||||
<SidebarSection title={t('nav.sections.pos', 'Point of Sale')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/pos"
|
||||
icon={ShoppingCart}
|
||||
label={t('nav.pos', 'Point of Sale')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/products"
|
||||
icon={Package}
|
||||
label={t('nav.products', 'Products')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{/* Staff-only: My Schedule and My Availability */}
|
||||
{((isStaff && hasPermission('can_access_my_schedule')) ||
|
||||
((role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability'))) && (
|
||||
|
||||
176
frontend/src/hooks/useTaxRates.ts
Normal file
176
frontend/src/hooks/useTaxRates.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Hook for looking up tax rates by ZIP code or address.
|
||||
*
|
||||
* Supports multiple data sources:
|
||||
* - SST (Streamlined Sales Tax) for 24 member states - address-level accuracy
|
||||
* - California CDTFA data
|
||||
* - Texas Comptroller data
|
||||
* - ZIP-based fallback for other states
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
/**
|
||||
* Tax rate lookup result.
|
||||
*/
|
||||
export interface TaxRateLookup {
|
||||
zip_code: string;
|
||||
zip_ext?: string;
|
||||
state: string;
|
||||
combined_rate: number;
|
||||
combined_rate_percent: string;
|
||||
state_rate: number;
|
||||
county_rate: number;
|
||||
city_rate: number;
|
||||
special_rate: number;
|
||||
// Source and accuracy info
|
||||
source: 'sst' | 'cdtfa' | 'tx_comptroller' | 'avalara' | 'state_dor' | 'no_sales_tax' | 'not_found';
|
||||
accuracy: 'zip9' | 'zip5' | 'zip' | 'address' | 'jurisdiction' | 'state' | 'exact' | 'none';
|
||||
// Jurisdiction details
|
||||
jurisdiction_code?: string;
|
||||
jurisdiction_name?: string;
|
||||
county_name?: string;
|
||||
city_name?: string;
|
||||
// SST liability protection
|
||||
liability_protection: boolean;
|
||||
effective_date?: string;
|
||||
// Legacy fields for backwards compatibility
|
||||
risk_level?: number;
|
||||
has_multiple_rates?: boolean;
|
||||
note?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP-based tax rate (fallback data).
|
||||
*/
|
||||
export interface TaxRate {
|
||||
id: number;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
tax_region_name: string;
|
||||
estimated_combined_rate: string;
|
||||
combined_rate_percent: string;
|
||||
state_rate: string;
|
||||
estimated_county_rate: string;
|
||||
estimated_city_rate: string;
|
||||
estimated_special_rate: string;
|
||||
risk_level: number;
|
||||
source: string;
|
||||
effective_date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for tax rate lookup.
|
||||
*/
|
||||
export interface TaxLookupParams {
|
||||
zipCode: string;
|
||||
zipExt?: string;
|
||||
state?: string;
|
||||
streetAddress?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up tax rate by ZIP code or address.
|
||||
*
|
||||
* @param params - Lookup parameters or ZIP code string
|
||||
* @param options - Query options
|
||||
* @returns Tax rate lookup result
|
||||
*
|
||||
* @example
|
||||
* // Simple ZIP lookup
|
||||
* const { data } = useTaxRateLookup('84003');
|
||||
*
|
||||
* @example
|
||||
* // Enhanced lookup with ZIP+4 for better accuracy
|
||||
* const { data } = useTaxRateLookup({
|
||||
* zipCode: '84003',
|
||||
* zipExt: '1234',
|
||||
* state: 'UT'
|
||||
* });
|
||||
*/
|
||||
export function useTaxRateLookup(
|
||||
params: string | TaxLookupParams | null,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
// Normalize params
|
||||
const zipCode = typeof params === 'string' ? params : params?.zipCode || '';
|
||||
const zipExt = typeof params === 'string' ? '' : params?.zipExt || '';
|
||||
const state = typeof params === 'string' ? '' : params?.state || '';
|
||||
const streetAddress = typeof params === 'string' ? '' : params?.streetAddress || '';
|
||||
|
||||
const normalizedZip = zipCode.replace(/\D/g, '').slice(0, 5);
|
||||
const normalizedExt = zipExt.replace(/\D/g, '').slice(0, 4);
|
||||
const normalizedState = state.replace(/[^A-Za-z]/g, '').slice(0, 2).toUpperCase();
|
||||
const isValidZip = normalizedZip.length === 5;
|
||||
|
||||
// Build query key based on all params
|
||||
const queryKey = ['tax', 'lookup', normalizedZip, normalizedExt, normalizedState, streetAddress];
|
||||
|
||||
return useQuery<TaxRateLookup>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/tax/lookup/', {
|
||||
params: {
|
||||
zip_code: normalizedZip,
|
||||
...(normalizedExt && { zip_ext: normalizedExt }),
|
||||
...(normalizedState && { state: normalizedState }),
|
||||
...(streetAddress && { street_address: streetAddress }),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: isValidZip && (options?.enabled !== false),
|
||||
staleTime: 1000 * 60 * 60 * 24, // Cache for 24 hours (tax rates don't change often)
|
||||
retry: false, // Don't retry if ZIP not found
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tax rate as a decimal for a ZIP code.
|
||||
* Returns null if not found or still loading.
|
||||
*
|
||||
* @param zipCode - 5-digit US ZIP code
|
||||
* @returns Combined tax rate as decimal (e.g., 0.0825) or null
|
||||
*/
|
||||
export function useTaxRateForZip(zipCode: string | null): number | null {
|
||||
const { data, isSuccess } = useTaxRateLookup(zipCode);
|
||||
|
||||
if (isSuccess && data && data.source !== 'not_found') {
|
||||
return data.combined_rate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted tax rate info for display.
|
||||
* Includes source and accuracy information.
|
||||
*/
|
||||
export function useTaxRateInfo(params: string | TaxLookupParams | null) {
|
||||
const { data, isLoading, isError } = useTaxRateLookup(params);
|
||||
|
||||
if (isLoading) {
|
||||
return { loading: true };
|
||||
}
|
||||
|
||||
if (isError || !data || data.source === 'not_found') {
|
||||
return { loading: false, notFound: true };
|
||||
}
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
notFound: false,
|
||||
rate: data.combined_rate,
|
||||
ratePercent: data.combined_rate_percent,
|
||||
source: data.source,
|
||||
accuracy: data.accuracy,
|
||||
jurisdictionName: data.jurisdiction_name,
|
||||
liabilityProtection: data.liability_protection,
|
||||
note: data.note,
|
||||
// Helper flags
|
||||
isSST: data.source === 'sst',
|
||||
isNoTax: data.source === 'no_sales_tax',
|
||||
isHighAccuracy: ['zip9', 'address', 'exact'].includes(data.accuracy),
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Allows business owners/managers to manage multiple locations.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Location } from '../types';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useSetPrimaryLocation,
|
||||
useSetLocationActive,
|
||||
} from '../hooks/useLocations';
|
||||
import { useTaxRateLookup } from '../hooks/useTaxRates';
|
||||
import {
|
||||
Plus,
|
||||
MapPin,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
Building2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Modal, FormInput, Button, Alert } from '../components/ui';
|
||||
|
||||
@@ -39,6 +41,7 @@ interface LocationFormData {
|
||||
phone: string;
|
||||
email: string;
|
||||
timezone: string;
|
||||
default_tax_rate: string; // Stored as percentage (e.g., "8.25" for 8.25%)
|
||||
}
|
||||
|
||||
const emptyFormData: LocationFormData = {
|
||||
@@ -52,6 +55,7 @@ const emptyFormData: LocationFormData = {
|
||||
phone: '',
|
||||
email: '',
|
||||
timezone: '',
|
||||
default_tax_rate: '',
|
||||
};
|
||||
|
||||
const Locations: React.FC = () => {
|
||||
@@ -69,6 +73,20 @@ const Locations: React.FC = () => {
|
||||
const setPrimaryMutation = useSetPrimaryLocation();
|
||||
const setActiveMutation = useSetLocationActive();
|
||||
|
||||
// Tax rate lookup for ZIP code auto-suggest
|
||||
const { data: taxRateData, isLoading: isLoadingTaxRate } = useTaxRateLookup(
|
||||
formData.country === 'US' ? formData.postal_code : null,
|
||||
{ enabled: isModalOpen && formData.country === 'US' && formData.postal_code.length >= 5 }
|
||||
);
|
||||
|
||||
// Auto-apply suggested tax rate when data loads (only if field is empty)
|
||||
useEffect(() => {
|
||||
if (taxRateData && !formData.default_tax_rate && !editingLocation) {
|
||||
const suggestedRate = (taxRateData.combined_rate * 100).toFixed(2);
|
||||
setFormData(prev => ({ ...prev, default_tax_rate: suggestedRate }));
|
||||
}
|
||||
}, [taxRateData, formData.default_tax_rate, editingLocation]);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingLocation(null);
|
||||
setFormData(emptyFormData);
|
||||
@@ -77,6 +95,10 @@ const Locations: React.FC = () => {
|
||||
|
||||
const handleOpenEdit = (location: Location) => {
|
||||
setEditingLocation(location);
|
||||
// Convert tax rate from decimal (0.0825) to percentage string ("8.25")
|
||||
const taxRatePercent = location.default_tax_rate
|
||||
? (Number(location.default_tax_rate) * 100).toFixed(2)
|
||||
: '';
|
||||
setFormData({
|
||||
name: location.name,
|
||||
address_line1: location.address_line1 || '',
|
||||
@@ -88,6 +110,7 @@ const Locations: React.FC = () => {
|
||||
phone: location.phone || '',
|
||||
email: location.email || '',
|
||||
timezone: location.timezone || '',
|
||||
default_tax_rate: taxRatePercent,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
setActiveMenu(null);
|
||||
@@ -101,14 +124,24 @@ const Locations: React.FC = () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Convert tax rate from percentage string ("8.25") to decimal (0.0825)
|
||||
const taxRateDecimal = formData.default_tax_rate
|
||||
? parseFloat(formData.default_tax_rate) / 100
|
||||
: 0;
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
default_tax_rate: taxRateDecimal,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingLocation) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editingLocation.id,
|
||||
updates: formData,
|
||||
updates: submitData,
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
await createMutation.mutateAsync(submitData);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
setFormData(emptyFormData);
|
||||
@@ -328,6 +361,60 @@ const Locations: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormInput
|
||||
label="Default Tax Rate (%)"
|
||||
name="default_tax_rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.default_tax_rate}
|
||||
onChange={handleInputChange}
|
||||
placeholder="e.g., 8.25"
|
||||
hint="Tax rate applied to POS sales at this location"
|
||||
/>
|
||||
{/* Tax rate suggestion from ZIP code lookup */}
|
||||
{taxRateData && formData.country === 'US' && (
|
||||
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="text-blue-800 dark:text-blue-300">
|
||||
<span className="font-medium">Suggested rate for {taxRateData.zip_code}:</span>{' '}
|
||||
{taxRateData.combined_rate_percent}
|
||||
{taxRateData.jurisdiction_name && (
|
||||
<span className="text-blue-600 dark:text-blue-400"> ({taxRateData.jurisdiction_name})</span>
|
||||
)}
|
||||
</p>
|
||||
{taxRateData.has_multiple_rates && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
Note: This ZIP code spans multiple tax jurisdictions. Verify with your tax advisor.
|
||||
</p>
|
||||
)}
|
||||
{formData.default_tax_rate !== (taxRateData.combined_rate * 100).toFixed(2) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({
|
||||
...prev,
|
||||
default_tax_rate: (taxRateData.combined_rate * 100).toFixed(2)
|
||||
}))}
|
||||
className="mt-2 text-blue-700 dark:text-blue-300 hover:text-blue-900 dark:hover:text-blue-100 font-medium underline"
|
||||
>
|
||||
Apply suggested rate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoadingTaxRate && formData.postal_code.length >= 5 && formData.country === 'US' && (
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Looking up tax rate for {formData.postal_code}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -488,6 +575,9 @@ const LocationCard: React.FC<LocationCardProps> = ({
|
||||
{location.service_count !== undefined && (
|
||||
<span>{location.service_count} services</span>
|
||||
)}
|
||||
{location.default_tax_rate !== undefined && location.default_tax_rate > 0 && (
|
||||
<span>{(Number(location.default_tax_rate) * 100).toFixed(2)}% tax</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
|
||||
185
frontend/src/pages/POS.tsx
Normal file
185
frontend/src/pages/POS.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { POSProvider, usePOS } from '../pos/context/POSContext';
|
||||
import { useLocations } from '../hooks/useLocations';
|
||||
import { useCurrentUser } from '../hooks/useAuth';
|
||||
import { useCurrentBusiness } from '../hooks/useBusiness';
|
||||
import POSLayout from '../pos/components/POSLayout';
|
||||
import POSHeader from '../pos/components/POSHeader';
|
||||
import { LoadingSpinner, Alert } from '../components/ui';
|
||||
|
||||
/**
|
||||
* POS Page - Main Point of Sale Interface
|
||||
*
|
||||
* Features:
|
||||
* - Full-screen POS mode (hides main app navigation)
|
||||
* - Location selection for multi-location businesses
|
||||
* - Active shift verification
|
||||
* - Wraps components with POSProvider context
|
||||
*
|
||||
* Component composition approach:
|
||||
* - Page handles data fetching and location selection
|
||||
* - POSProvider manages cart/shift state at top level
|
||||
* - POSLayout handles the main grid layout
|
||||
* - POSHeader provides minimal navigation
|
||||
*/
|
||||
|
||||
interface POSContentProps {
|
||||
locationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner content component - must be inside POSProvider
|
||||
*/
|
||||
const POSContent: React.FC<POSContentProps> = ({ locationId }) => {
|
||||
const { state } = usePOS();
|
||||
const { data: user } = useCurrentUser();
|
||||
const { data: business } = useCurrentBusiness();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
|
||||
{/* Custom POS header instead of main app navigation */}
|
||||
<POSHeader
|
||||
businessName={business?.name || ''}
|
||||
businessLogo={business?.logoUrl}
|
||||
locationId={locationId}
|
||||
staffName={user?.full_name || user?.username || 'Staff'}
|
||||
activeShift={state.activeShift}
|
||||
printerStatus={state.printerStatus}
|
||||
/>
|
||||
|
||||
{/* Main POS interface */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<POSLayout />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Location selector modal for multi-location businesses
|
||||
*/
|
||||
interface LocationSelectorProps {
|
||||
locations: Array<{ id: number; name: string; address: string }>;
|
||||
onSelect: (locationId: number) => void;
|
||||
}
|
||||
|
||||
const LocationSelector: React.FC<LocationSelectorProps> = ({ locations, onSelect }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Select Location</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Choose which location you'll be working from for this POS session.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{locations.map((location) => (
|
||||
<button
|
||||
key={location.id}
|
||||
onClick={() => onSelect(location.id)}
|
||||
className="w-full p-4 bg-gray-50 hover:bg-gray-100 border border-gray-200 hover:border-blue-300 rounded-lg text-left transition-all group"
|
||||
>
|
||||
<div className="font-semibold text-gray-900 group-hover:text-blue-600">
|
||||
{location.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{location.address}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main POS Page Component
|
||||
*/
|
||||
const POS: React.FC = () => {
|
||||
const { data: user, isLoading: userLoading } = useCurrentUser();
|
||||
const { data: business, isLoading: businessLoading } = useCurrentBusiness();
|
||||
const { data: locations, isLoading: locationsLoading } = useLocations();
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
||||
|
||||
// Check user permissions
|
||||
const canAccessPOS = user?.role === 'owner' ||
|
||||
user?.role === 'staff' ||
|
||||
user?.effective_permissions?.can_access_pos === true;
|
||||
|
||||
// Auto-select location if only one exists
|
||||
useEffect(() => {
|
||||
if (locations && locations.length === 1 && !selectedLocationId) {
|
||||
setSelectedLocationId(locations[0].id);
|
||||
}
|
||||
}, [locations, selectedLocationId]);
|
||||
|
||||
// Loading state
|
||||
if (userLoading || businessLoading || locationsLoading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="mt-4 text-gray-600">Loading POS...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if (!canAccessPOS) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md mx-4">
|
||||
<Alert variant="error">
|
||||
You don't have permission to access the Point of Sale system.
|
||||
Contact your administrator for access.
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No locations configured
|
||||
if (!locations || locations.length === 0) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md mx-4 text-center">
|
||||
<Alert variant="warning">
|
||||
<div className="mb-2 font-semibold">No Locations Found</div>
|
||||
<div>
|
||||
You need to set up at least one location before using the POS system.
|
||||
Go to Settings → Locations to add a location.
|
||||
</div>
|
||||
</Alert>
|
||||
<a
|
||||
href="/dashboard/settings/locations"
|
||||
className="inline-block mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Go to Locations Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Location selection required for multi-location businesses
|
||||
if (!selectedLocationId && locations.length > 1) {
|
||||
return (
|
||||
<LocationSelector
|
||||
locations={locations}
|
||||
onSelect={setSelectedLocationId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render POS interface with provider
|
||||
const locationId = selectedLocationId || locations[0].id;
|
||||
|
||||
return (
|
||||
<POSProvider initialLocationId={locationId}>
|
||||
<POSContent locationId={locationId} />
|
||||
</POSProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default POS;
|
||||
395
frontend/src/pages/Products.tsx
Normal file
395
frontend/src/pages/Products.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Products Page
|
||||
*
|
||||
* Manage POS products and inventory.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { FolderOpen, ArrowLeftRight, Package } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
FormInput,
|
||||
FormSelect,
|
||||
TabGroup,
|
||||
Badge,
|
||||
EmptyState,
|
||||
PageLoading,
|
||||
ErrorMessage,
|
||||
} from '../components/ui';
|
||||
import { ProductEditorModal } from '../pos/components/ProductEditorModal';
|
||||
import { CategoryManagerModal } from '../pos/components/CategoryManagerModal';
|
||||
import InventoryTransferModal from '../pos/components/InventoryTransferModal';
|
||||
import {
|
||||
useProducts,
|
||||
useProductCategories,
|
||||
ProductFilters,
|
||||
} from '../pos/hooks/usePOSProducts';
|
||||
import { useLowStockItems } from '../pos/hooks/useInventory';
|
||||
import { useDeleteProduct, useToggleProductStatus } from '../pos/hooks/useProductMutations';
|
||||
import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
|
||||
import type { POSProduct } from '../pos/types';
|
||||
|
||||
type ViewTab = 'all' | 'active' | 'inactive' | 'low-stock';
|
||||
|
||||
export default function ProductsPage() {
|
||||
// Check if POS feature is enabled
|
||||
const { hasFeature, isLoading: entitlementsLoading } = useEntitlements();
|
||||
const hasPOSFeature = hasFeature(FEATURE_CODES.CAN_USE_POS);
|
||||
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState<ViewTab>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('');
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isCategoryManagerOpen, setIsCategoryManagerOpen] = useState(false);
|
||||
const [isTransferModalOpen, setIsTransferModalOpen] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<POSProduct | null>(null);
|
||||
|
||||
// Build filters based on tab and search
|
||||
const filters: ProductFilters = useMemo(() => {
|
||||
const f: ProductFilters = {};
|
||||
|
||||
if (searchQuery) {
|
||||
f.search = searchQuery;
|
||||
}
|
||||
|
||||
if (categoryFilter) {
|
||||
f.categoryId = parseInt(categoryFilter, 10);
|
||||
}
|
||||
|
||||
if (activeTab === 'active') {
|
||||
f.status = 'active';
|
||||
} else if (activeTab === 'inactive') {
|
||||
f.status = 'inactive';
|
||||
}
|
||||
|
||||
return f;
|
||||
}, [activeTab, searchQuery, categoryFilter]);
|
||||
|
||||
// Query hooks
|
||||
const { data: products, isLoading, error } = useProducts(filters);
|
||||
const { data: categories } = useProductCategories();
|
||||
const { data: lowStockItems } = useLowStockItems();
|
||||
|
||||
// Mutation hooks
|
||||
const deleteProduct = useDeleteProduct();
|
||||
const toggleStatus = useToggleProductStatus();
|
||||
|
||||
// Filter products for low stock tab
|
||||
const displayProducts = useMemo(() => {
|
||||
if (activeTab === 'low-stock') {
|
||||
// Get product IDs that are low stock
|
||||
const lowStockProductIds = new Set(lowStockItems?.map((item) => item.product) || []);
|
||||
return products?.filter((p) => lowStockProductIds.has(p.id)) || [];
|
||||
}
|
||||
return products || [];
|
||||
}, [activeTab, products, lowStockItems]);
|
||||
|
||||
const handleAddProduct = () => {
|
||||
setSelectedProduct(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditProduct = (product: POSProduct) => {
|
||||
setSelectedProduct(product);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProduct = async (product: POSProduct) => {
|
||||
if (!confirm(`Are you sure you want to delete "${product.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
await deleteProduct.mutateAsync(product.id);
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (product: POSProduct) => {
|
||||
await toggleStatus.mutateAsync({
|
||||
id: product.id,
|
||||
is_active: product.status !== 'active',
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransferInventory = (product: POSProduct) => {
|
||||
setSelectedProduct(product);
|
||||
setIsTransferModalOpen(true);
|
||||
};
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'All Categories' },
|
||||
...(categories?.map((cat) => ({
|
||||
value: cat.id.toString(),
|
||||
label: cat.name,
|
||||
})) || []),
|
||||
];
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all' as const, label: 'All Products' },
|
||||
{ id: 'active' as const, label: 'Active' },
|
||||
{ id: 'inactive' as const, label: 'Inactive' },
|
||||
{
|
||||
id: 'low-stock' as const,
|
||||
label: `Low Stock${lowStockItems?.length ? ` (${lowStockItems.length})` : ''}`,
|
||||
},
|
||||
];
|
||||
|
||||
const formatPrice = (cents: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(cents / 100);
|
||||
};
|
||||
|
||||
// Check for entitlements loading
|
||||
if (entitlementsLoading) {
|
||||
return <PageLoading label="Loading..." />;
|
||||
}
|
||||
|
||||
// Show upgrade prompt if POS is not enabled
|
||||
if (!hasPOSFeature) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Package className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
Product Management
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Unlock powerful product and inventory management features with our
|
||||
Point of Sale add-on. Track stock levels, manage categories, and more.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => window.location.href = '/dashboard/settings/billing'}
|
||||
className="w-full"
|
||||
>
|
||||
Upgrade Your Plan
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.history.back()}
|
||||
className="w-full"
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoading label="Loading products..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Products</h1>
|
||||
<p className="text-gray-500 mt-1">Manage your product catalog and inventory</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setIsCategoryManagerOpen(true)}>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Categories
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsTransferModalOpen(true)}>
|
||||
<ArrowLeftRight className="w-4 h-4 mr-2" />
|
||||
Transfer Inventory
|
||||
</Button>
|
||||
<Button onClick={handleAddProduct}>Add Product</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorMessage message="Failed to load products. Please try again." />}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<TabGroup
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onChange={(tab) => setActiveTab(tab as ViewTab)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormInput
|
||||
placeholder="Search products..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<FormSelect
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
options={categoryOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product List */}
|
||||
{displayProducts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No products found"
|
||||
description={
|
||||
searchQuery || categoryFilter
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Add your first product to get started'
|
||||
}
|
||||
action={
|
||||
!searchQuery && !categoryFilter ? (
|
||||
<Button onClick={handleAddProduct}>Add Product</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Product
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stock
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{displayProducts.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{product.name}
|
||||
</div>
|
||||
{product.sku && (
|
||||
<div className="text-sm text-gray-500">SKU: {product.sku}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-500">
|
||||
{product.category_name || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{formatPrice(product.price_cents)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{product.track_inventory ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
product.is_low_stock ? 'text-red-600 font-medium' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{product.quantity_in_stock ?? 0}
|
||||
</span>
|
||||
{product.is_low_stock && (
|
||||
<Badge variant="danger" size="sm">
|
||||
Low
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">Not tracked</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<Badge
|
||||
variant={product.status === 'active' ? 'success' : 'default'}
|
||||
>
|
||||
{product.status === 'active' ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEditProduct(product)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{product.track_inventory && (
|
||||
<button
|
||||
onClick={() => handleTransferInventory(product)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
title="Transfer inventory"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleToggleStatus(product)}
|
||||
className="text-gray-600 hover:text-gray-900 mr-4"
|
||||
>
|
||||
{product.status === 'active' ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Modal */}
|
||||
<ProductEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
product={selectedProduct}
|
||||
onSuccess={() => {
|
||||
setIsEditorOpen(false);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Category Manager Modal */}
|
||||
<CategoryManagerModal
|
||||
isOpen={isCategoryManagerOpen}
|
||||
onClose={() => setIsCategoryManagerOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Inventory Transfer Modal */}
|
||||
<InventoryTransferModal
|
||||
isOpen={isTransferModalOpen}
|
||||
onClose={() => {
|
||||
setIsTransferModalOpen(false);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setIsTransferModalOpen(false);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
productId={selectedProduct?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
355
frontend/src/pos/BARCODE_SCANNER.md
Normal file
355
frontend/src/pos/BARCODE_SCANNER.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Barcode Scanner Integration
|
||||
|
||||
This document describes the barcode scanner integration for the POS module.
|
||||
|
||||
## Overview
|
||||
|
||||
The barcode scanner integration provides:
|
||||
- Keyboard-wedge barcode scanner support (hardware scanners that emit keystrokes)
|
||||
- Automatic detection of rapid keystrokes (distinguishes from normal typing)
|
||||
- Manual barcode entry fallback
|
||||
- Visual feedback during scanning
|
||||
- Auto-add to cart functionality
|
||||
- Product lookup integration
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **useBarcodeScanner Hook** (`/pos/hooks/useBarcodeScanner.ts`)
|
||||
- Listens for rapid keystrokes characteristic of barcode scanners
|
||||
- Buffers characters until Enter key or timeout
|
||||
- Returns scanner state (buffer, isScanning)
|
||||
- Distinguishes scanner input from normal typing based on speed
|
||||
|
||||
2. **BarcodeScannerStatus Component** (`/pos/components/BarcodeScannerStatus.tsx`)
|
||||
- Visual indicator showing scanner status
|
||||
- Manual barcode entry input
|
||||
- Integration with product lookup and cart
|
||||
- Compact and full modes
|
||||
|
||||
3. **Product Lookup Integration** (`/pos/hooks/usePOSProducts.ts`)
|
||||
- `useBarcodeScanner()` hook provides `lookupBarcode()` function
|
||||
- Searches products by barcode field
|
||||
- Returns product data or null if not found
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { BarcodeScannerStatus } from '../pos/components';
|
||||
|
||||
function POSTerminal() {
|
||||
const handleScan = (barcode: string) => {
|
||||
console.log('Scanned:', barcode);
|
||||
};
|
||||
|
||||
return (
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
showManualEntry={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Add to Cart
|
||||
|
||||
```typescript
|
||||
function QuickCheckout() {
|
||||
return (
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={(barcode) => console.log('Product added:', barcode)}
|
||||
autoAddToCart={true} // Automatically look up and add to cart
|
||||
showManualEntry={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Compact Mode
|
||||
|
||||
```typescript
|
||||
function POSHeader() {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<h3>Checkout</h3>
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
compact={true} // Small icon with tooltip
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```typescript
|
||||
function CustomScanner() {
|
||||
return (
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
keystrokeThreshold={150} // Allow slower scanners (default: 100ms)
|
||||
timeout={300} // Wait longer after last char (default: 200ms)
|
||||
minLength={5} // Require at least 5 chars (default: 3)
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Full Integration Example
|
||||
|
||||
```typescript
|
||||
import { BarcodeScannerStatus } from '../pos/components';
|
||||
import { useCart } from '../pos/hooks/useCart';
|
||||
import { useBarcodeScanner } from '../pos/hooks/usePOSProducts';
|
||||
|
||||
function POSTerminal() {
|
||||
const { addProduct } = useCart();
|
||||
const { lookupBarcode } = useBarcodeScanner();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleScan = async (barcode: string) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const product = await lookupBarcode(barcode);
|
||||
|
||||
if (product) {
|
||||
addProduct(product);
|
||||
// Show success notification
|
||||
} else {
|
||||
setError(`Product not found: ${barcode}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to process barcode');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
showManualEntry={true}
|
||||
/>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### BarcodeScannerStatus Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `enabled` | `boolean` | Required | Enable/disable scanner listening |
|
||||
| `onScan` | `(barcode: string) => void` | Required | Callback when barcode detected |
|
||||
| `showManualEntry` | `boolean` | `true` | Show manual barcode input field |
|
||||
| `compact` | `boolean` | `false` | Show compact icon-only mode |
|
||||
| `keystrokeThreshold` | `number` | `100` | Max ms between keystrokes (scanner detection) |
|
||||
| `timeout` | `number` | `200` | Ms to wait after last keystroke |
|
||||
| `minLength` | `number` | `3` | Minimum barcode length |
|
||||
| `autoAddToCart` | `boolean` | `false` | Automatically lookup and add products to cart |
|
||||
|
||||
### useBarcodeScanner Hook Options
|
||||
|
||||
```typescript
|
||||
interface UseBarcodeScannerOptions {
|
||||
onScan: (barcode: string) => void;
|
||||
enabled?: boolean; // Default: false
|
||||
keystrokeThreshold?: number; // Default: 100ms
|
||||
timeout?: number; // Default: 200ms
|
||||
minLength?: number; // Default: 3
|
||||
ignoreKeys?: string[]; // Keys to ignore (default: modifiers, arrows, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
### useBarcodeScanner Hook Return Value
|
||||
|
||||
```typescript
|
||||
interface UseBarcodeScannerReturn {
|
||||
buffer: string; // Current accumulated characters
|
||||
isScanning: boolean; // Whether scanner is actively receiving input
|
||||
clearBuffer: () => void; // Manually clear the buffer
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Scanner Detection Algorithm
|
||||
|
||||
1. **Keystroke Timing**: Hardware scanners typically send keystrokes at 10-50ms intervals, while humans type at 200-400ms intervals
|
||||
2. **Threshold**: Default 100ms threshold distinguishes scanner from human input
|
||||
3. **Buffer Management**: Characters are accumulated in a buffer
|
||||
4. **Completion**: Scan completes on:
|
||||
- Enter key press
|
||||
- Timeout after last keystroke (default 200ms)
|
||||
5. **Reset**: Buffer clears if gap between keystrokes exceeds threshold
|
||||
|
||||
### Example Timeline
|
||||
|
||||
```
|
||||
Scanner (FAST - triggers callback):
|
||||
[0ms] Key: '1'
|
||||
[10ms] Key: '2'
|
||||
[20ms] Key: '3'
|
||||
[30ms] Key: 'Enter'
|
||||
→ Callback: onScan('123')
|
||||
|
||||
Human (SLOW - ignored):
|
||||
[0ms] Key: '1'
|
||||
[250ms] Key: '2' (gap > 100ms, buffer cleared)
|
||||
[500ms] Key: '3' (gap > 100ms, buffer cleared)
|
||||
[750ms] Key: 'Enter'
|
||||
→ No callback
|
||||
```
|
||||
|
||||
### Input Filtering
|
||||
|
||||
The scanner automatically ignores:
|
||||
- Input focused on `<input>`, `<textarea>`, or contentEditable elements
|
||||
- Modifier keys (Shift, Control, Alt, Meta)
|
||||
- Navigation keys (Tab, Arrow keys, Home, End, Page Up/Down)
|
||||
- Special keys (Backspace, Delete, CapsLock)
|
||||
|
||||
## Hardware Compatibility
|
||||
|
||||
### Tested Scanners
|
||||
|
||||
- USB keyboard-wedge scanners (most common)
|
||||
- Wireless Bluetooth scanners in keyboard mode
|
||||
- Mobile device camera scanners with keyboard output
|
||||
|
||||
### Scanner Configuration
|
||||
|
||||
For best results, configure your scanner to:
|
||||
1. **Append Enter**: Send Enter/Return key after barcode
|
||||
2. **No Prefix/Suffix**: Disable any prefix or suffix characters
|
||||
3. **Fast Scan Speed**: Maximum scan speed setting
|
||||
4. **Keyboard Mode**: Ensure scanner is in keyboard-wedge mode
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
# Run hook tests
|
||||
npm test -- src/pos/hooks/__tests__/useBarcodeScanner.test.ts
|
||||
|
||||
# Run component tests
|
||||
npm test -- src/pos/components/__tests__/BarcodeScannerStatus.test.tsx
|
||||
|
||||
# Run all barcode scanner tests
|
||||
npm test -- src/pos/hooks/__tests__/useBarcodeScanner.test.ts src/pos/components/__tests__/BarcodeScannerStatus.test.tsx
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **With Hardware Scanner**:
|
||||
- Navigate to POS terminal
|
||||
- Scan a product barcode
|
||||
- Verify product is found and added to cart
|
||||
|
||||
2. **Manual Entry**:
|
||||
- Type barcode in manual entry field
|
||||
- Press Enter or click "Add"
|
||||
- Verify product lookup works
|
||||
|
||||
3. **Edge Cases**:
|
||||
- Scan while typing in search box (should be ignored)
|
||||
- Scan non-existent barcode (should show error)
|
||||
- Scan very fast (< 10ms per char)
|
||||
- Scan slowly (> 100ms per char, should still work with custom threshold)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Scanner Not Working
|
||||
|
||||
1. **Check scanner mode**: Ensure scanner is in keyboard-wedge mode, not serial/USB-HID
|
||||
2. **Test in notepad**: Scan into a text editor to verify scanner outputs characters
|
||||
3. **Check threshold**: If scanner is slower, increase `keystrokeThreshold` prop
|
||||
4. **Verify enabled**: Ensure `enabled={true}` is set on component
|
||||
|
||||
### False Positives (Normal Typing Triggers Scanner)
|
||||
|
||||
1. **Decrease threshold**: Lower `keystrokeThreshold` to 50ms
|
||||
2. **Increase minLength**: Set `minLength` to match your barcode format (e.g., 12 for UPC)
|
||||
3. **Check scanner speed**: Scanner may be too slow, configure faster scan rate
|
||||
|
||||
### Product Not Found
|
||||
|
||||
1. **Verify barcode field**: Ensure products have `barcode` field set in database
|
||||
2. **Check barcode format**: Ensure scanner output matches database format (check for leading zeros, spaces)
|
||||
3. **Test manual entry**: Try entering barcode manually to isolate scanner vs lookup issue
|
||||
|
||||
## Backend Integration
|
||||
|
||||
### Product Model
|
||||
|
||||
Products must have a `barcode` field:
|
||||
|
||||
```python
|
||||
# smoothschedule/commerce/pos/models.py
|
||||
class Product(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
sku = models.CharField(max_length=100)
|
||||
barcode = models.CharField(max_length=100, blank=True, db_index=True)
|
||||
# ... other fields
|
||||
```
|
||||
|
||||
### API Endpoint
|
||||
|
||||
The frontend uses this endpoint:
|
||||
|
||||
```
|
||||
GET /api/pos/products/barcode/{barcode}/
|
||||
```
|
||||
|
||||
Example response:
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"name": "Example Product",
|
||||
"sku": "PROD-001",
|
||||
"barcode": "1234567890",
|
||||
"price_cents": 1999,
|
||||
"category": 5,
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
Returns 404 if product not found.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Enable Conditionally**: Disable scanner during checkout or when modals are open
|
||||
2. **Show Feedback**: Use visual/audio feedback for successful scans
|
||||
3. **Error Handling**: Show clear error messages for not-found products
|
||||
4. **Duplicate Prevention**: Debounce rapid duplicate scans of same barcode
|
||||
5. **Accessibility**: Provide manual entry for users without scanners
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Support for barcode prefixes/suffixes
|
||||
- [ ] Batch scanning mode
|
||||
- [ ] Scanner configuration UI
|
||||
- [ ] Audio feedback on scan
|
||||
- [ ] Support for 2D barcodes (QR codes) via camera
|
||||
- [ ] Scanner health monitoring
|
||||
- [ ] Scan history/log
|
||||
|
||||
## See Also
|
||||
|
||||
- [BarcodeScannerStatus.example.tsx](./components/BarcodeScannerStatus.example.tsx) - Usage examples
|
||||
- [useBarcodeScanner.test.ts](./hooks/__tests__/useBarcodeScanner.test.ts) - Test cases
|
||||
- [BarcodeScannerStatus.test.tsx](./components/__tests__/BarcodeScannerStatus.test.tsx) - Component tests
|
||||
528
frontend/src/pos/README.md
Normal file
528
frontend/src/pos/README.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# POS Module - React UI Components
|
||||
|
||||
This directory contains the core React UI components for the Point of Sale (POS) module.
|
||||
|
||||
## Components Created
|
||||
|
||||
### 1. POSLayout
|
||||
**File:** `components/POSLayout.tsx`
|
||||
|
||||
Full-screen POS interface with three-column layout:
|
||||
- **Left sidebar:** Category navigation and search
|
||||
- **Center:** Product grid
|
||||
- **Right:** Cart panel (fixed 320px width)
|
||||
- **Top bar:** Shift status and quick actions
|
||||
|
||||
**Features:**
|
||||
- 100vh to fill viewport
|
||||
- Touch-first design
|
||||
- Responsive for tablets/desktops
|
||||
- High contrast for retail environments
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { POSLayout } from './pos/components';
|
||||
|
||||
function POSPage() {
|
||||
return <POSLayout />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. CategoryTabs
|
||||
**File:** `components/CategoryTabs.tsx`
|
||||
|
||||
Touch-friendly category navigation with pills/buttons.
|
||||
|
||||
**Features:**
|
||||
- Large touch targets (44px min height)
|
||||
- Horizontal scrolling OR vertical sidebar layout
|
||||
- Color-coded categories
|
||||
- Clear active state
|
||||
- Touch feedback (scale on press)
|
||||
|
||||
**Props:**
|
||||
```tsx
|
||||
interface CategoryTabsProps {
|
||||
categories: Category[];
|
||||
activeCategory: string;
|
||||
onCategoryChange: (categoryId: string) => void;
|
||||
orientation?: 'horizontal' | 'vertical'; // default: 'horizontal'
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
<CategoryTabs
|
||||
categories={[
|
||||
{ id: 'all', name: 'All Products', color: '#6B7280' },
|
||||
{ id: 'category1', name: 'Category 1', color: '#8B5CF6' },
|
||||
]}
|
||||
activeCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
orientation="vertical"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. ProductGrid
|
||||
**File:** `components/ProductGrid.tsx`
|
||||
|
||||
Touch-optimized product display grid.
|
||||
|
||||
**Features:**
|
||||
- Responsive grid (4-6 columns)
|
||||
- Large touch targets (min 100x100px cards)
|
||||
- Product image or color swatch
|
||||
- Price prominently displayed
|
||||
- Quantity badge when in cart
|
||||
- Touch feedback (scale on press)
|
||||
- Low stock / out of stock badges
|
||||
- Empty state
|
||||
|
||||
**Props:**
|
||||
```tsx
|
||||
interface ProductGridProps {
|
||||
products: Product[];
|
||||
searchQuery?: string;
|
||||
selectedCategory?: string;
|
||||
onAddToCart?: (product: Product) => void;
|
||||
cartItems?: Map<string, number>; // productId -> quantity
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
<ProductGrid
|
||||
products={products}
|
||||
searchQuery={searchQuery}
|
||||
selectedCategory={selectedCategory}
|
||||
onAddToCart={(product) => console.log('Add:', product)}
|
||||
cartItems={cartItems}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. CartItem
|
||||
**File:** `components/CartItem.tsx`
|
||||
|
||||
Individual line item in the cart.
|
||||
|
||||
**Features:**
|
||||
- Item name and unit price
|
||||
- Large +/- quantity buttons (48px)
|
||||
- Remove button (X)
|
||||
- Discount badge
|
||||
- Line total
|
||||
- Touch-friendly controls
|
||||
|
||||
**Props:**
|
||||
```tsx
|
||||
interface CartItemProps {
|
||||
item: CartItemData;
|
||||
onUpdateQuantity: (itemId: string, quantity: number) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onApplyDiscount?: (itemId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
<CartItem
|
||||
item={{
|
||||
id: '1',
|
||||
product_id: '101',
|
||||
name: 'Product Name',
|
||||
unit_price_cents: 1500,
|
||||
quantity: 2,
|
||||
line_total_cents: 3000,
|
||||
}}
|
||||
onUpdateQuantity={(id, qty) => console.log('Update qty:', id, qty)}
|
||||
onRemove={(id) => console.log('Remove:', id)}
|
||||
onApplyDiscount={(id) => console.log('Apply discount:', id)}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. CartPanel
|
||||
**File:** `components/CartPanel.tsx`
|
||||
|
||||
Right sidebar cart with totals and checkout.
|
||||
|
||||
**Features:**
|
||||
- Scrollable item list
|
||||
- Customer assignment button
|
||||
- Subtotal, discount, tax, tip display
|
||||
- Running total (large, bold)
|
||||
- Clear cart button (with confirmation)
|
||||
- Large PAY button at bottom
|
||||
- Empty state
|
||||
|
||||
**Props:**
|
||||
```tsx
|
||||
interface CartPanelProps {
|
||||
items?: CartItemData[];
|
||||
customer?: { id: string; name: string };
|
||||
onUpdateQuantity?: (itemId: string, quantity: number) => void;
|
||||
onRemoveItem?: (itemId: string) => void;
|
||||
onClearCart?: () => void;
|
||||
onSelectCustomer?: () => void;
|
||||
onApplyDiscount?: (itemId: string) => void;
|
||||
onApplyOrderDiscount?: () => void;
|
||||
onAddTip?: () => void;
|
||||
onCheckout?: () => void;
|
||||
taxRate?: number; // e.g., 0.0825 for 8.25%
|
||||
discount_cents?: number;
|
||||
tip_cents?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
<CartPanel
|
||||
items={cartItems}
|
||||
customer={{ id: '1', name: 'John Doe' }}
|
||||
onCheckout={() => console.log('Checkout')}
|
||||
taxRate={0.0825}
|
||||
discount_cents={500}
|
||||
tip_cents={200}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. QuickSearch
|
||||
**File:** `components/QuickSearch.tsx`
|
||||
|
||||
Instant product/service search with debouncing.
|
||||
|
||||
**Features:**
|
||||
- Debounced filtering (200ms default)
|
||||
- Clear button (X)
|
||||
- Touch-friendly (48px height)
|
||||
- Keyboard accessible (ESC to clear)
|
||||
|
||||
**Props:**
|
||||
```tsx
|
||||
interface QuickSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
debounceMs?: number; // default: 200
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
<QuickSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search products..."
|
||||
debounceMs={300}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### Server vs Client Components
|
||||
All POS components are **client components** due to:
|
||||
- Interactive state (cart, search, selections)
|
||||
- Touch event handlers
|
||||
- Real-time updates
|
||||
- Hardware integration (printer, cash drawer)
|
||||
|
||||
### Composition Pattern
|
||||
Components follow React composition:
|
||||
```
|
||||
POSLayout
|
||||
├── QuickSearch
|
||||
├── CategoryTabs
|
||||
├── ProductGrid
|
||||
└── CartPanel
|
||||
└── CartItem (repeated)
|
||||
```
|
||||
|
||||
### State Management
|
||||
- **Local state:** `useState` for UI state (search, selected category)
|
||||
- **Context:** `POSContext` (to be implemented) for cart, shift, printer
|
||||
- **Server state:** React Query hooks for products, orders, inventory
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### Touch-First
|
||||
- **Minimum touch targets:** 48x48px (prefer 60-80px)
|
||||
- **Generous spacing:** 16px+ gaps between clickable items
|
||||
- **Visual feedback:** Scale/color transitions on touch
|
||||
- **No hover-dependent features**
|
||||
|
||||
### High Contrast
|
||||
- Clear visual hierarchy
|
||||
- Large fonts (min 16px body, 24px+ for prices)
|
||||
- Color-coded status
|
||||
- Readable in bright retail lighting
|
||||
|
||||
### Accessibility
|
||||
- ARIA labels on all interactive elements
|
||||
- Keyboard navigation support
|
||||
- Screen reader friendly
|
||||
- Focus management
|
||||
|
||||
### Performance
|
||||
- Debounced search
|
||||
- Memoized filtering (useMemo)
|
||||
- Virtualization for long lists (future)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Integration Tasks
|
||||
1. **Create POSContext:**
|
||||
- Cart state management
|
||||
- Cash shift tracking
|
||||
- Printer connection state
|
||||
|
||||
2. **Create hooks:**
|
||||
- `usePOS()` - Main POS operations
|
||||
- `useCart()` - Cart add/remove/update
|
||||
- `usePayment()` - Payment processing
|
||||
- `useCashDrawer()` - Shift management
|
||||
- `useThermalPrinter()` - Web Serial API
|
||||
|
||||
3. **Connect to API:**
|
||||
- Fetch products from `/api/pos/products/`
|
||||
- Fetch categories from `/api/pos/categories/`
|
||||
- Create orders via `/api/pos/orders/`
|
||||
- Process payments
|
||||
|
||||
4. **Add payment flow:**
|
||||
- PaymentModal component
|
||||
- TipSelector component
|
||||
- NumPad component
|
||||
- Split payment support
|
||||
|
||||
5. **Hardware integration:**
|
||||
- Thermal receipt printer (Web Serial API)
|
||||
- Cash drawer kick
|
||||
- Barcode scanner (keyboard wedge)
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
All types are defined in `types.ts`:
|
||||
- Product, ProductCategory
|
||||
- Order, OrderItem
|
||||
- POSTransaction
|
||||
- GiftCard
|
||||
- CashShift
|
||||
- Cart state types
|
||||
|
||||
Import types:
|
||||
```tsx
|
||||
import type { Product, Order, CartItem } from '../types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Component tests to be created in `__tests__/`:
|
||||
- `POSLayout.test.tsx`
|
||||
- `CategoryTabs.test.tsx`
|
||||
- `ProductGrid.test.tsx`
|
||||
- `CartItem.test.tsx`
|
||||
- `CartPanel.test.tsx`
|
||||
- `QuickSearch.test.tsx`
|
||||
|
||||
Test scenarios:
|
||||
- Render with empty state
|
||||
- Add/remove items
|
||||
- Update quantities
|
||||
- Apply discounts
|
||||
- Search/filter products
|
||||
- Category navigation
|
||||
- Touch interactions
|
||||
- Accessibility
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/src/pos/
|
||||
├── components/
|
||||
│ ├── POSLayout.tsx ✅ Created
|
||||
│ ├── CategoryTabs.tsx ✅ Created
|
||||
│ ├── ProductGrid.tsx ✅ Created
|
||||
│ ├── CartPanel.tsx ✅ Created
|
||||
│ ├── CartItem.tsx ✅ Created
|
||||
│ ├── QuickSearch.tsx ✅ Created
|
||||
│ ├── PaymentModal.tsx ⏳ Next phase
|
||||
│ ├── TipSelector.tsx ⏳ Next phase
|
||||
│ ├── NumPad.tsx ⏳ Next phase
|
||||
│ ├── CashDrawerPanel.tsx ⏳ Next phase
|
||||
│ ├── PrinterStatus.tsx ⏳ Next phase
|
||||
│ └── index.ts ✅ Created (barrel export)
|
||||
├── context/
|
||||
│ └── POSContext.tsx ⏳ To create
|
||||
├── hooks/
|
||||
│ ├── usePOS.ts ⏳ To create
|
||||
│ ├── useCart.ts ⏳ To create
|
||||
│ ├── usePayment.ts ⏳ To create
|
||||
│ ├── useCashDrawer.ts ⏳ To create
|
||||
│ └── useThermalPrinter.ts ⏳ To create
|
||||
├── hardware/
|
||||
│ ├── ESCPOSBuilder.ts ⏳ To create
|
||||
│ ├── ReceiptBuilder.ts ⏳ To create
|
||||
│ └── constants.ts ⏳ To create
|
||||
├── types.ts ✅ Exists
|
||||
├── utils.ts ✅ Exists
|
||||
└── README.md ✅ Created (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Example
|
||||
|
||||
Full POS page integration:
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
POSLayout,
|
||||
CategoryTabs,
|
||||
ProductGrid,
|
||||
CartPanel,
|
||||
QuickSearch,
|
||||
} from '../pos/components';
|
||||
|
||||
function POSPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [cartItems, setCartItems] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// TODO: Replace with actual data from hooks
|
||||
const categories = [
|
||||
{ id: 'all', name: 'All Products', color: '#6B7280' },
|
||||
{ id: 'category1', name: 'Category 1', color: '#8B5CF6' },
|
||||
];
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Sample Product',
|
||||
price_cents: 1500,
|
||||
category_id: 'category1',
|
||||
color: '#8B5CF6',
|
||||
},
|
||||
];
|
||||
|
||||
const handleAddToCart = (product: Product) => {
|
||||
const newCart = new Map(cartItems);
|
||||
newCart.set(product.id, (newCart.get(product.id) || 0) + 1);
|
||||
setCartItems(newCart);
|
||||
};
|
||||
|
||||
return (
|
||||
<POSLayout>
|
||||
{/* Layout handles structure, just pass data */}
|
||||
</POSLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default POSPage;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Reference
|
||||
|
||||
### Color Palette
|
||||
- **Primary:** Blue (#3B82F6)
|
||||
- **Success:** Green (#10B981)
|
||||
- **Warning:** Yellow (#F59E0B)
|
||||
- **Danger:** Red (#EF4444)
|
||||
- **Gray scale:** #F3F4F6 → #1F2937
|
||||
|
||||
### Typography Scale
|
||||
- **Prices:** 24px+ bold
|
||||
- **Totals:** 32px+ bold
|
||||
- **Body:** 16px
|
||||
- **Small:** 14px
|
||||
- **Tiny:** 12px
|
||||
|
||||
### Spacing Scale
|
||||
- **Tight:** 8px
|
||||
- **Normal:** 16px
|
||||
- **Loose:** 24px
|
||||
- **Extra loose:** 32px
|
||||
|
||||
---
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Web Serial API (Thermal Printer)
|
||||
- Chrome 89+
|
||||
- Edge 89+
|
||||
- Opera 75+
|
||||
- **NOT supported:** Firefox, Safari
|
||||
|
||||
**Fallback:** Provide "Print" button that opens browser print dialog.
|
||||
|
||||
### Touch Events
|
||||
- All modern browsers
|
||||
- iOS Safari
|
||||
- Chrome Android
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Product Grid
|
||||
- Use CSS Grid with `auto-fill`
|
||||
- Virtualize for 500+ products (react-window)
|
||||
- Memoize filtered products
|
||||
|
||||
### Cart Panel
|
||||
- Limit to 50 items per order
|
||||
- Virtualize for large carts
|
||||
- Debounce quantity updates
|
||||
|
||||
### Search
|
||||
- Debounce 200ms
|
||||
- Client-side filtering for <1000 products
|
||||
- Server-side search for larger catalogs
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Checklist
|
||||
|
||||
- ✅ All buttons have `aria-label`
|
||||
- ✅ Keyboard navigation works
|
||||
- ✅ Focus visible on all interactive elements
|
||||
- ✅ Color not the only indicator
|
||||
- ✅ Sufficient contrast ratios (4.5:1 min)
|
||||
- ✅ Touch targets min 48x48px
|
||||
- ⏳ Screen reader testing needed
|
||||
- ⏳ NVDA/JAWS compatibility testing
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API)
|
||||
- [ESC/POS Command Specification](https://reference.epson-biz.com/modules/ref_escpos/)
|
||||
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [React Hook Form](https://react-hook-form.com/)
|
||||
- [React Query](https://tanstack.com/query/latest)
|
||||
619
frontend/src/pos/__tests__/ESCPOSBuilder.test.ts
Normal file
619
frontend/src/pos/__tests__/ESCPOSBuilder.test.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Tests for ESCPOSBuilder
|
||||
*
|
||||
* ESC/POS is a command protocol for thermal receipt printers.
|
||||
* These tests verify the ESCPOSBuilder correctly generates printer commands.
|
||||
*
|
||||
* Common ESC/POS commands:
|
||||
* - ESC @ (1B 40) - Initialize printer
|
||||
* - ESC a n (1B 61 n) - Align text (0=left, 1=center, 2=right)
|
||||
* - ESC E n (1B 45 n) - Bold on/off (0=off, 1=on)
|
||||
* - GS V m (1D 56 m) - Cut paper (0=full, 1=partial)
|
||||
* - ESC p m t1 t2 (1B 70 m t1 t2) - Kick drawer
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ESCPOSBuilder } from '../hardware/ESCPOSBuilder';
|
||||
import { ESC, GS, LF, DEFAULT_RECEIPT_WIDTH, SEPARATORS } from '../hardware/constants';
|
||||
|
||||
describe('ESCPOSBuilder', () => {
|
||||
let builder: ESCPOSBuilder;
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new ESCPOSBuilder();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should use default receipt width', () => {
|
||||
const b = new ESCPOSBuilder();
|
||||
expect(b).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom receipt width', () => {
|
||||
const b = new ESCPOSBuilder(32);
|
||||
expect(b).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('init()', () => {
|
||||
it('should produce ESC @ command', () => {
|
||||
const result = builder.init().build();
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toBe(0x1B); // ESC
|
||||
expect(result[1]).toBe(0x40); // @
|
||||
});
|
||||
|
||||
it('should allow chaining', () => {
|
||||
const returnValue = builder.init();
|
||||
expect(returnValue).toBe(builder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset()', () => {
|
||||
it('should clear the buffer', () => {
|
||||
builder.init().text('Hello');
|
||||
expect(builder.length).toBeGreaterThan(0);
|
||||
|
||||
builder.reset();
|
||||
expect(builder.length).toBe(0);
|
||||
expect(builder.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow chaining', () => {
|
||||
const returnValue = builder.reset();
|
||||
expect(returnValue).toBe(builder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text()', () => {
|
||||
it('should encode text without line feed', () => {
|
||||
const result = builder.text('Hello').build();
|
||||
|
||||
expect(result.length).toBe(5);
|
||||
expect(result[0]).toBe(0x48); // 'H'
|
||||
});
|
||||
|
||||
it('should encode UTF-8 characters', () => {
|
||||
const result = builder.text('Test').build();
|
||||
|
||||
expect(result[0]).toBe(0x54); // 'T'
|
||||
expect(result[1]).toBe(0x65); // 'e'
|
||||
expect(result[2]).toBe(0x73); // 's'
|
||||
expect(result[3]).toBe(0x74); // 't'
|
||||
});
|
||||
});
|
||||
|
||||
describe('newline()', () => {
|
||||
it('should add single line feed', () => {
|
||||
const result = builder.newline().build();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe(0x0A);
|
||||
});
|
||||
});
|
||||
|
||||
describe('textLine()', () => {
|
||||
it('should add text followed by newline', () => {
|
||||
const result = builder.textLine('Hello').build();
|
||||
|
||||
expect(result.length).toBe(6);
|
||||
expect(result[5]).toBe(0x0A); // LF at end
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignCenter()', () => {
|
||||
it('should produce ESC a 1 command', () => {
|
||||
const result = builder.alignCenter().build();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0]).toBe(0x1B); // ESC
|
||||
expect(result[1]).toBe(0x61); // 'a'
|
||||
expect(result[2]).toBe(0x01); // center
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignLeft()', () => {
|
||||
it('should produce ESC a 0 command', () => {
|
||||
const result = builder.alignLeft().build();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0]).toBe(0x1B); // ESC
|
||||
expect(result[1]).toBe(0x61); // 'a'
|
||||
expect(result[2]).toBe(0x00); // left
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignRight()', () => {
|
||||
it('should produce ESC a 2 command', () => {
|
||||
const result = builder.alignRight().build();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0]).toBe(0x1B); // ESC
|
||||
expect(result[1]).toBe(0x61); // 'a'
|
||||
expect(result[2]).toBe(0x02); // right
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold()', () => {
|
||||
it('should enable bold with ESC E 1', () => {
|
||||
const result = builder.bold(true).build();
|
||||
|
||||
expect(result[0]).toBe(0x1B);
|
||||
expect(result[1]).toBe(0x45);
|
||||
expect(result[2]).toBe(0x01);
|
||||
});
|
||||
|
||||
it('should disable bold with ESC E 0', () => {
|
||||
const result = builder.bold(false).build();
|
||||
|
||||
expect(result[0]).toBe(0x1B);
|
||||
expect(result[1]).toBe(0x45);
|
||||
expect(result[2]).toBe(0x00);
|
||||
});
|
||||
|
||||
it('should default to enabled', () => {
|
||||
const result = builder.bold().build();
|
||||
|
||||
expect(result[2]).toBe(0x01);
|
||||
});
|
||||
});
|
||||
|
||||
describe('underline()', () => {
|
||||
it('should enable normal underline', () => {
|
||||
const result = builder.underline(true, false).build();
|
||||
|
||||
expect(result[0]).toBe(0x1B);
|
||||
expect(result[1]).toBe(0x2D);
|
||||
expect(result[2]).toBe(0x01);
|
||||
});
|
||||
|
||||
it('should enable thick underline', () => {
|
||||
const result = builder.underline(true, true).build();
|
||||
|
||||
expect(result[2]).toBe(0x02);
|
||||
});
|
||||
|
||||
it('should disable underline', () => {
|
||||
const result = builder.underline(false).build();
|
||||
|
||||
expect(result[2]).toBe(0x00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doubleHeight()', () => {
|
||||
it('should enable double height', () => {
|
||||
const result = builder.doubleHeight(true).build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x21); // !
|
||||
expect(result[2]).toBe(0x10);
|
||||
});
|
||||
|
||||
it('should disable double height', () => {
|
||||
const result = builder.doubleHeight(false).build();
|
||||
|
||||
expect(result[2]).toBe(0x00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doubleWidth()', () => {
|
||||
it('should enable double width', () => {
|
||||
const result = builder.doubleWidth(true).build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x21); // !
|
||||
expect(result[2]).toBe(0x20);
|
||||
});
|
||||
|
||||
it('should disable double width', () => {
|
||||
const result = builder.doubleWidth(false).build();
|
||||
|
||||
expect(result[2]).toBe(0x00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSize()', () => {
|
||||
it('should set character size with GS !', () => {
|
||||
const result = builder.setSize(2, 2).build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x21); // '!'
|
||||
// Width 2 (index 1) | Height 2 (index 1) << 4 = 0x11
|
||||
expect(result[2]).toBe(0x11);
|
||||
});
|
||||
|
||||
it('should handle double width', () => {
|
||||
const result = builder.setSize(2, 1).build();
|
||||
|
||||
expect(result[2]).toBe(0x01);
|
||||
});
|
||||
|
||||
it('should handle double height', () => {
|
||||
const result = builder.setSize(1, 2).build();
|
||||
|
||||
expect(result[2]).toBe(0x10);
|
||||
});
|
||||
|
||||
it('should clamp values to valid range', () => {
|
||||
const result = builder.setSize(10, 10).build();
|
||||
|
||||
// Max is 7, so (7 << 4) | 7 = 0x77
|
||||
expect(result[2]).toBe(0x77);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalSize()', () => {
|
||||
it('should reset to 1x1 size', () => {
|
||||
const result = builder.normalSize().build();
|
||||
|
||||
expect(result[0]).toBe(0x1D);
|
||||
expect(result[1]).toBe(0x21);
|
||||
expect(result[2]).toBe(0x00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inverse()', () => {
|
||||
it('should enable inverse mode', () => {
|
||||
const result = builder.inverse(true).build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x42); // B
|
||||
expect(result[2]).toBe(0x01);
|
||||
});
|
||||
|
||||
it('should disable inverse mode', () => {
|
||||
const result = builder.inverse(false).build();
|
||||
|
||||
expect(result[2]).toBe(0x00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('separator()', () => {
|
||||
it('should create line of default character', () => {
|
||||
const result = builder.separator().build();
|
||||
|
||||
// Default width is 42, plus LF
|
||||
expect(result.length).toBe(43);
|
||||
});
|
||||
|
||||
it('should use custom character and width', () => {
|
||||
const result = builder.separator('=', 10).build();
|
||||
|
||||
expect(result.length).toBe(11); // 10 chars + LF
|
||||
expect(result[0]).toBe(0x3D); // '='
|
||||
});
|
||||
});
|
||||
|
||||
describe('doubleSeparator()', () => {
|
||||
it('should use double-line separator', () => {
|
||||
const result = builder.doubleSeparator().build();
|
||||
|
||||
expect(result.length).toBe(43);
|
||||
expect(result[0]).toBe(0x3D); // '='
|
||||
});
|
||||
});
|
||||
|
||||
describe('columns()', () => {
|
||||
it('should format two columns', () => {
|
||||
const result = builder.columns('Left', 'Right').build();
|
||||
|
||||
expect(result.length).toBe(43); // 42 + LF
|
||||
});
|
||||
|
||||
it('should truncate if text too long', () => {
|
||||
const left = 'A'.repeat(40);
|
||||
const right = '$10.00';
|
||||
const result = builder.columns(left, right).build();
|
||||
|
||||
// Should truncate left and still fit
|
||||
expect(result.length).toBe(43);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threeColumns()', () => {
|
||||
it('should format three columns', () => {
|
||||
const result = builder.threeColumns('Left', 'Center', 'Right').build();
|
||||
|
||||
expect(result.length).toBe(43); // 42 + LF
|
||||
});
|
||||
|
||||
it('should fall back to two columns if too wide', () => {
|
||||
const left = 'A'.repeat(20);
|
||||
const center = 'B'.repeat(20);
|
||||
const right = 'C'.repeat(20);
|
||||
const result = builder.threeColumns(left, center, right).build();
|
||||
|
||||
// Should still produce output
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emptyLine()', () => {
|
||||
it('should add single empty line by default', () => {
|
||||
const result = builder.emptyLine().build();
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe(0x0A);
|
||||
});
|
||||
|
||||
it('should add multiple empty lines', () => {
|
||||
const result = builder.emptyLine(3).build();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feed()', () => {
|
||||
it('should feed paper by default 3 lines', () => {
|
||||
const result = builder.feed().build();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should feed paper by specified lines', () => {
|
||||
const result = builder.feed(5).build();
|
||||
|
||||
expect(result.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cut()', () => {
|
||||
it('should produce full cut command', () => {
|
||||
const result = builder.cut().build();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x56); // 'V'
|
||||
expect(result[2]).toBe(0x00); // full cut
|
||||
});
|
||||
});
|
||||
|
||||
describe('partialCut()', () => {
|
||||
it('should produce partial cut command', () => {
|
||||
const result = builder.partialCut().build();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x56); // 'V'
|
||||
expect(result[2]).toBe(0x01); // partial cut
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedAndCut()', () => {
|
||||
it('should feed and then cut', () => {
|
||||
const result = builder.feedAndCut(2).build();
|
||||
|
||||
// 2 line feeds + cut command (3 bytes)
|
||||
expect(result.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kickDrawer()', () => {
|
||||
it('should produce ESC p command for pin 0', () => {
|
||||
const result = builder.kickDrawer(0).build();
|
||||
|
||||
expect(result[0]).toBe(0x1B); // ESC
|
||||
expect(result[1]).toBe(0x70); // 'p'
|
||||
expect(result[2]).toBe(0x00); // pin 0
|
||||
expect(result[3]).toBe(0x19); // t1
|
||||
expect(result[4]).toBe(0xFA); // t2
|
||||
});
|
||||
|
||||
it('should support pin 1', () => {
|
||||
const result = builder.kickDrawer(1).build();
|
||||
|
||||
expect(result[2]).toBe(0x01);
|
||||
});
|
||||
});
|
||||
|
||||
describe('barcodeHeight()', () => {
|
||||
it('should set barcode height', () => {
|
||||
const result = builder.barcodeHeight(80).build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x68); // h
|
||||
expect(result[2]).toBe(80);
|
||||
});
|
||||
|
||||
it('should clamp height to valid range', () => {
|
||||
const result = builder.barcodeHeight(300).build();
|
||||
|
||||
expect(result[2]).toBe(255); // max
|
||||
});
|
||||
});
|
||||
|
||||
describe('barcodeWidth()', () => {
|
||||
it('should set barcode width', () => {
|
||||
const result = builder.barcodeWidth(3).build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x77); // w
|
||||
expect(result[2]).toBe(3);
|
||||
});
|
||||
|
||||
it('should clamp width to valid range', () => {
|
||||
const result = builder.barcodeWidth(10).build();
|
||||
|
||||
expect(result[2]).toBe(6); // max
|
||||
});
|
||||
});
|
||||
|
||||
describe('barcodeTextPosition()', () => {
|
||||
it('should set text position below barcode', () => {
|
||||
const result = builder.barcodeTextPosition(2).build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x48); // H
|
||||
expect(result[2]).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('barcode128()', () => {
|
||||
it('should print Code 128 barcode', () => {
|
||||
const result = builder.barcode128('12345').build();
|
||||
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
expect(result[1]).toBe(0x6B); // k
|
||||
expect(result[2]).toBe(73); // Code 128 type
|
||||
expect(result[3]).toBe(5); // data length
|
||||
});
|
||||
});
|
||||
|
||||
describe('qrCode()', () => {
|
||||
it('should print QR code', () => {
|
||||
const result = builder.qrCode('https://example.com', 4).build();
|
||||
|
||||
// Should produce QR code commands
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
expect(result[0]).toBe(0x1D); // GS
|
||||
});
|
||||
|
||||
it('should clamp size to valid range', () => {
|
||||
const result = builder.qrCode('test', 20).build();
|
||||
|
||||
// Should still work with clamped size
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw()', () => {
|
||||
it('should add raw bytes from array', () => {
|
||||
const result = builder.raw([0x1B, 0x40]).build();
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toBe(0x1B);
|
||||
expect(result[1]).toBe(0x40);
|
||||
});
|
||||
|
||||
it('should add raw bytes from Uint8Array', () => {
|
||||
const result = builder.raw(new Uint8Array([0x1B, 0x40])).build();
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toBe(0x1B);
|
||||
expect(result[1]).toBe(0x40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('command()', () => {
|
||||
it('should add raw command', () => {
|
||||
const result = builder.command(new Uint8Array([0x1B, 0x40])).build();
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('build()', () => {
|
||||
it('should return Uint8Array', () => {
|
||||
const result = builder.init().text('Test').build();
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it('should combine multiple commands', () => {
|
||||
const result = builder
|
||||
.init()
|
||||
.alignCenter()
|
||||
.bold()
|
||||
.text('RECEIPT')
|
||||
.bold(false)
|
||||
.alignLeft()
|
||||
.build();
|
||||
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('length property', () => {
|
||||
it('should return current buffer length', () => {
|
||||
expect(builder.length).toBe(0);
|
||||
|
||||
builder.init();
|
||||
expect(builder.length).toBe(2);
|
||||
|
||||
builder.text('Test');
|
||||
expect(builder.length).toBe(6); // 2 + 4
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmpty property', () => {
|
||||
it('should return true for empty buffer', () => {
|
||||
expect(builder.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-empty buffer', () => {
|
||||
builder.init();
|
||||
expect(builder.isEmpty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chaining', () => {
|
||||
it('should support fluent interface for all methods', () => {
|
||||
const result = builder
|
||||
.init()
|
||||
.alignCenter()
|
||||
.bold()
|
||||
.setSize(2, 2)
|
||||
.textLine('STORE NAME')
|
||||
.normalSize()
|
||||
.textLine('123 Main Street')
|
||||
.newline()
|
||||
.separator()
|
||||
.columns('Item 1', '$10.00')
|
||||
.columns('Item 2', '$5.00')
|
||||
.separator()
|
||||
.columns('Total:', '$15.00')
|
||||
.emptyLine(2)
|
||||
.alignCenter()
|
||||
.textLine('Thank you!')
|
||||
.feed(3)
|
||||
.cut()
|
||||
.kickDrawer()
|
||||
.build();
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receipt generation example', () => {
|
||||
it('should generate valid receipt bytes', () => {
|
||||
const receipt = new ESCPOSBuilder()
|
||||
.init()
|
||||
.alignCenter()
|
||||
.bold()
|
||||
.textLine('SMOOTHSCHEDULE')
|
||||
.bold(false)
|
||||
.textLine('123 Business Ave')
|
||||
.textLine('(555) 123-4567')
|
||||
.newline()
|
||||
.alignLeft()
|
||||
.separator('=')
|
||||
.textLine('Order #: POS-20241226-1234')
|
||||
.textLine('Date: Dec 26, 2024 2:30 PM')
|
||||
.separator()
|
||||
.columns('Product A x1', '$25.00')
|
||||
.columns('Product B x2', '$10.00')
|
||||
.separator()
|
||||
.columns('Subtotal:', '$35.00')
|
||||
.columns('Tax (8%):', '$2.80')
|
||||
.columns('Tip:', '$7.00')
|
||||
.bold()
|
||||
.columns('TOTAL:', '$44.80')
|
||||
.bold(false)
|
||||
.newline()
|
||||
.alignCenter()
|
||||
.textLine('Thank you for your business!')
|
||||
.feedAndCut(3)
|
||||
.build();
|
||||
|
||||
expect(receipt).toBeInstanceOf(Uint8Array);
|
||||
expect(receipt.length).toBeGreaterThan(200);
|
||||
|
||||
// Verify it starts with init
|
||||
expect(receipt[0]).toBe(0x1B);
|
||||
expect(receipt[1]).toBe(0x40);
|
||||
});
|
||||
});
|
||||
});
|
||||
696
frontend/src/pos/__tests__/POSContext.test.tsx
Normal file
696
frontend/src/pos/__tests__/POSContext.test.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* Tests for POSContext operations
|
||||
*
|
||||
* Tests cover cart operations including adding items, removing items,
|
||||
* updating quantities, applying discounts, and calculating totals.
|
||||
*/
|
||||
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { POSProvider, usePOS } from '../context/POSContext';
|
||||
import type { POSProduct, POSService, POSDiscount } from '../types';
|
||||
|
||||
// Clear localStorage before each test to prevent state leakage
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// Create wrapper component for hooks
|
||||
const createWrapper = (initialLocationId?: number) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<POSProvider initialLocationId={initialLocationId ?? null}>{children}</POSProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Mock product - matches POSProduct interface from types.ts
|
||||
const mockProduct: POSProduct = {
|
||||
id: 1,
|
||||
name: 'Test Product',
|
||||
sku: 'TEST-001',
|
||||
barcode: '123456789',
|
||||
description: 'A test product',
|
||||
price_cents: 1000,
|
||||
cost_cents: 500,
|
||||
tax_rate: 0.08,
|
||||
is_taxable: true,
|
||||
category_id: 1,
|
||||
display_order: 1,
|
||||
image_url: null,
|
||||
color: '#3B82F6',
|
||||
status: 'active',
|
||||
track_inventory: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
// Mock service - matches POSService interface from types.ts
|
||||
const mockService: POSService = {
|
||||
id: 2,
|
||||
name: 'Test Service',
|
||||
description: 'A test service',
|
||||
price_cents: 2500,
|
||||
duration_minutes: 60,
|
||||
};
|
||||
|
||||
describe('POSContext - Initial State', () => {
|
||||
it('should start with empty cart', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items).toHaveLength(0);
|
||||
expect(result.current.isCartEmpty).toBe(true);
|
||||
expect(result.current.itemCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should start with zero totals', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.subtotalCents).toBe(0);
|
||||
expect(result.current.state.cart.taxCents).toBe(0);
|
||||
expect(result.current.state.cart.tipCents).toBe(0);
|
||||
expect(result.current.state.cart.discountCents).toBe(0);
|
||||
expect(result.current.state.cart.totalCents).toBe(0);
|
||||
});
|
||||
|
||||
it('should accept initial location ID', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(42),
|
||||
});
|
||||
|
||||
expect(result.current.state.selectedLocationId).toBe(42);
|
||||
});
|
||||
|
||||
it('should start with no customer', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.customer).toBeNull();
|
||||
});
|
||||
|
||||
it('should start with no active shift', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.state.activeShift).toBeNull();
|
||||
});
|
||||
|
||||
it('should start with printer disconnected', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.state.printerStatus).toBe('disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Adding Items', () => {
|
||||
it('should add a product to cart', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items).toHaveLength(1);
|
||||
expect(result.current.state.cart.items[0].name).toBe('Test Product');
|
||||
expect(result.current.state.cart.items[0].unitPriceCents).toBe(1000);
|
||||
expect(result.current.state.cart.items[0].quantity).toBe(1);
|
||||
});
|
||||
|
||||
it('should add a service to cart', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockService, 1, 'service');
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items).toHaveLength(1);
|
||||
expect(result.current.state.cart.items[0].name).toBe('Test Service');
|
||||
expect(result.current.state.cart.items[0].unitPriceCents).toBe(2500);
|
||||
expect(result.current.state.cart.items[0].itemType).toBe('service');
|
||||
});
|
||||
|
||||
it('should increment quantity for existing item', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
// Should still be one item with quantity 2
|
||||
expect(result.current.state.cart.items).toHaveLength(1);
|
||||
expect(result.current.state.cart.items[0].quantity).toBe(2);
|
||||
});
|
||||
|
||||
it('should add multiple quantities at once', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 5, 'product');
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items[0].quantity).toBe(5);
|
||||
expect(result.current.itemCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should update item count correctly', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product');
|
||||
result.current.addItem(mockService, 1, 'service');
|
||||
});
|
||||
|
||||
expect(result.current.itemCount).toBe(3);
|
||||
expect(result.current.isCartEmpty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Removing Items', () => {
|
||||
it('should remove item from cart', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
const itemId = result.current.state.cart.items[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.removeItem(itemId);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items).toHaveLength(0);
|
||||
expect(result.current.isCartEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent item gracefully', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeItem('non-existent-id');
|
||||
});
|
||||
|
||||
// Should still have the original item
|
||||
expect(result.current.state.cart.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should recalculate totals after removal', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
result.current.addItem(mockService, 1, 'service');
|
||||
});
|
||||
|
||||
const productItemId = result.current.state.cart.items[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.removeItem(productItemId);
|
||||
});
|
||||
|
||||
// Should only have service now
|
||||
expect(result.current.state.cart.subtotalCents).toBe(2500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Updating Quantities', () => {
|
||||
it('should update item quantity', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
const itemId = result.current.state.cart.items[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuantity(itemId, 5);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items[0].quantity).toBe(5);
|
||||
expect(result.current.itemCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should remove item when quantity set to zero', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 3, 'product');
|
||||
});
|
||||
|
||||
const itemId = result.current.state.cart.items[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuantity(itemId, 0);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove item when quantity is negative', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product');
|
||||
});
|
||||
|
||||
const itemId = result.current.state.cart.items[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuantity(itemId, -1);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should recalculate totals after quantity update', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
const initialSubtotal = result.current.state.cart.subtotalCents;
|
||||
const itemId = result.current.state.cart.items[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuantity(itemId, 3);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.subtotalCents).toBe(initialSubtotal * 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Applying Discounts', () => {
|
||||
it('should apply percentage discount to cart', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product');
|
||||
});
|
||||
|
||||
const discount: POSDiscount = {
|
||||
percent: 10,
|
||||
reason: 'Loyalty discount',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.applyDiscount(discount);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.discount).toEqual(discount);
|
||||
// 10% of $20.00 = $2.00 = 200 cents
|
||||
expect(result.current.state.cart.discountCents).toBe(200);
|
||||
});
|
||||
|
||||
it('should apply fixed amount discount to cart', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product');
|
||||
});
|
||||
|
||||
const discount: POSDiscount = {
|
||||
amountCents: 500,
|
||||
reason: 'Promo code',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.applyDiscount(discount);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.discountCents).toBe(500);
|
||||
});
|
||||
|
||||
it('should clear discount', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product');
|
||||
result.current.applyDiscount({ percent: 10 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearDiscount();
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.discount).toBeNull();
|
||||
expect(result.current.state.cart.discountCents).toBe(0);
|
||||
});
|
||||
|
||||
it('should apply item-level discount', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
const itemId = result.current.state.cart.items[0].id;
|
||||
|
||||
act(() => {
|
||||
result.current.setItemDiscount(itemId, undefined, 20); // 20% off
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items[0].discountPercent).toBe(20);
|
||||
// Subtotal should reflect the discount
|
||||
expect(result.current.state.cart.subtotalCents).toBe(800); // $10 - 20% = $8
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Setting Tip', () => {
|
||||
it('should set tip amount', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setTip(200);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.tipCents).toBe(200);
|
||||
});
|
||||
|
||||
it('should include tip in total', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
});
|
||||
|
||||
const totalBeforeTip = result.current.state.cart.totalCents;
|
||||
|
||||
act(() => {
|
||||
result.current.setTip(300);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.totalCents).toBe(totalBeforeTip + 300);
|
||||
});
|
||||
|
||||
it('should handle zero tip', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product');
|
||||
result.current.setTip(500);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setTip(0);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.tipCents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Calculating Totals', () => {
|
||||
it('should calculate subtotal correctly', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product'); // $10 x 2 = $20
|
||||
result.current.addItem(mockService, 1, 'service'); // $25
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.subtotalCents).toBe(4500); // $45
|
||||
});
|
||||
|
||||
it('should calculate tax correctly', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product'); // $10 @ 8% = $0.80 tax
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.taxCents).toBe(80);
|
||||
});
|
||||
|
||||
it('should calculate total correctly', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product'); // $10 + $0.80 tax
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.totalCents).toBe(1080);
|
||||
});
|
||||
|
||||
it('should calculate total with discount and tip', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product'); // $20 subtotal + $1.60 tax
|
||||
result.current.applyDiscount({ amountCents: 500 }); // -$5 discount
|
||||
result.current.setTip(300); // +$3 tip
|
||||
});
|
||||
|
||||
// Total: $20 + $1.60 - $5 + $3 = $19.60
|
||||
expect(result.current.state.cart.totalCents).toBe(1960);
|
||||
});
|
||||
|
||||
it('should not allow negative total', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 1, 'product'); // $10.80 total
|
||||
result.current.applyDiscount({ amountCents: 2000 }); // $20 discount (exceeds total)
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.totalCents).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Customer Management', () => {
|
||||
it('should set customer', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const customer = {
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '555-123-4567',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setCustomer(customer);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.customer).toEqual(customer);
|
||||
});
|
||||
|
||||
it('should clear customer', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setCustomer({ id: 1, name: 'John', email: '', phone: '' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setCustomer(null);
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.customer).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Clearing Cart', () => {
|
||||
it('should clear all items from cart', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addItem(mockProduct, 2, 'product');
|
||||
result.current.addItem(mockService, 1, 'service');
|
||||
result.current.setTip(500);
|
||||
result.current.applyDiscount({ percent: 10 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearCart();
|
||||
});
|
||||
|
||||
expect(result.current.state.cart.items).toHaveLength(0);
|
||||
expect(result.current.state.cart.subtotalCents).toBe(0);
|
||||
expect(result.current.state.cart.taxCents).toBe(0);
|
||||
expect(result.current.state.cart.tipCents).toBe(0);
|
||||
expect(result.current.state.cart.discountCents).toBe(0);
|
||||
expect(result.current.state.cart.totalCents).toBe(0);
|
||||
expect(result.current.state.cart.discount).toBeNull();
|
||||
expect(result.current.state.cart.customer).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Active Shift', () => {
|
||||
it('should set active shift', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const shift = {
|
||||
id: 1,
|
||||
location: 1,
|
||||
opened_by: 1,
|
||||
closed_by: null,
|
||||
opening_balance_cents: 10000,
|
||||
expected_balance_cents: 15000,
|
||||
actual_balance_cents: null,
|
||||
variance_cents: null,
|
||||
cash_breakdown: {},
|
||||
status: 'open' as const,
|
||||
opened_at: '2024-01-01T09:00:00Z',
|
||||
closed_at: null,
|
||||
opening_notes: '',
|
||||
closing_notes: '',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveShift(shift);
|
||||
});
|
||||
|
||||
expect(result.current.state.activeShift).toEqual(shift);
|
||||
});
|
||||
|
||||
it('should clear active shift', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveShift({ id: 1 } as any);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveShift(null);
|
||||
});
|
||||
|
||||
expect(result.current.state.activeShift).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Printer Status', () => {
|
||||
it('should update printer status', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPrinterStatus('connecting');
|
||||
});
|
||||
|
||||
expect(result.current.state.printerStatus).toBe('connecting');
|
||||
|
||||
act(() => {
|
||||
result.current.setPrinterStatus('connected');
|
||||
});
|
||||
|
||||
expect(result.current.state.printerStatus).toBe('connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - Location', () => {
|
||||
it('should set location', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setLocation(123);
|
||||
});
|
||||
|
||||
expect(result.current.state.selectedLocationId).toBe(123);
|
||||
});
|
||||
|
||||
it('should clear location', () => {
|
||||
const { result } = renderHook(() => usePOS(), {
|
||||
wrapper: createWrapper(42),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setLocation(null);
|
||||
});
|
||||
|
||||
expect(result.current.state.selectedLocationId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POSContext - usePOS Hook Error', () => {
|
||||
it('should throw error when used outside provider', () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => usePOS());
|
||||
}).toThrow('usePOS must be used within a POSProvider');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
550
frontend/src/pos/__tests__/utils.test.ts
Normal file
550
frontend/src/pos/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Tests for POS utility functions
|
||||
*
|
||||
* Tests cover price formatting, tax calculation, tip calculation,
|
||||
* change calculation, and gift card validation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatCents,
|
||||
parseCents,
|
||||
calculateTax,
|
||||
calculateTip,
|
||||
calculateChange,
|
||||
calculateLineTotal,
|
||||
calculateDiscountAmount,
|
||||
calculateOrderTotals,
|
||||
formatTaxRate,
|
||||
suggestTenderAmounts,
|
||||
isValidGiftCardCode,
|
||||
generateGiftCardCode,
|
||||
generateOrderNumber,
|
||||
formatPhoneNumber,
|
||||
truncate,
|
||||
padReceiptLine,
|
||||
} from '../utils';
|
||||
|
||||
describe('formatCents', () => {
|
||||
it('formats positive cents to currency string', () => {
|
||||
expect(formatCents(1000)).toBe('$10.00');
|
||||
expect(formatCents(1050)).toBe('$10.50');
|
||||
expect(formatCents(99)).toBe('$0.99');
|
||||
expect(formatCents(1)).toBe('$0.01');
|
||||
});
|
||||
|
||||
it('formats zero correctly', () => {
|
||||
expect(formatCents(0)).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('formats large amounts with commas', () => {
|
||||
expect(formatCents(100000)).toBe('$1,000.00');
|
||||
expect(formatCents(1234567)).toBe('$12,345.67');
|
||||
});
|
||||
|
||||
it('handles different currencies', () => {
|
||||
expect(formatCents(1000, 'EUR', 'de-DE')).toContain('10');
|
||||
expect(formatCents(1000, 'GBP', 'en-GB')).toContain('10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCents', () => {
|
||||
it('parses currency strings to cents', () => {
|
||||
expect(parseCents('$10.00')).toBe(1000);
|
||||
expect(parseCents('10.50')).toBe(1050);
|
||||
expect(parseCents('$0.99')).toBe(99);
|
||||
});
|
||||
|
||||
it('handles strings with commas', () => {
|
||||
expect(parseCents('$1,234.56')).toBe(123456);
|
||||
expect(parseCents('1,000.00')).toBe(100000);
|
||||
});
|
||||
|
||||
it('handles strings without decimal', () => {
|
||||
expect(parseCents('10')).toBe(1000);
|
||||
expect(parseCents('$100')).toBe(10000);
|
||||
});
|
||||
|
||||
it('handles negative values', () => {
|
||||
expect(parseCents('-$5.00')).toBe(-500);
|
||||
expect(parseCents('-10.50')).toBe(-1050);
|
||||
});
|
||||
|
||||
it('returns 0 for empty or invalid input', () => {
|
||||
expect(parseCents('')).toBe(0);
|
||||
expect(parseCents('invalid')).toBe(0);
|
||||
expect(parseCents('abc')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles null and undefined gracefully', () => {
|
||||
expect(parseCents(null as unknown as string)).toBe(0);
|
||||
expect(parseCents(undefined as unknown as string)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTax', () => {
|
||||
it('calculates tax at standard rate', () => {
|
||||
// 8.25% of $10.00 = $0.825, rounds to $0.83
|
||||
expect(calculateTax(1000, 0.0825)).toBe(83);
|
||||
});
|
||||
|
||||
it('calculates tax at 10%', () => {
|
||||
expect(calculateTax(1000, 0.10)).toBe(100);
|
||||
});
|
||||
|
||||
it('rounds to nearest cent', () => {
|
||||
// 8.25% of $9.99 = $0.824175, rounds to $0.82
|
||||
expect(calculateTax(999, 0.0825)).toBe(82);
|
||||
});
|
||||
|
||||
it('returns 0 for zero amount', () => {
|
||||
expect(calculateTax(0, 0.0825)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for zero rate', () => {
|
||||
expect(calculateTax(1000, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for negative amount', () => {
|
||||
expect(calculateTax(-1000, 0.0825)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for negative rate', () => {
|
||||
expect(calculateTax(1000, -0.05)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTip', () => {
|
||||
it('calculates 15% tip', () => {
|
||||
expect(calculateTip(1000, 15)).toBe(150);
|
||||
});
|
||||
|
||||
it('calculates 18% tip', () => {
|
||||
expect(calculateTip(1000, 18)).toBe(180);
|
||||
});
|
||||
|
||||
it('calculates 20% tip', () => {
|
||||
expect(calculateTip(1000, 20)).toBe(200);
|
||||
});
|
||||
|
||||
it('rounds to nearest cent', () => {
|
||||
// 18% of $25.50 = $4.59
|
||||
expect(calculateTip(2550, 18)).toBe(459);
|
||||
});
|
||||
|
||||
it('returns 0 for zero subtotal', () => {
|
||||
expect(calculateTip(0, 20)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for zero percentage', () => {
|
||||
expect(calculateTip(1000, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for negative values', () => {
|
||||
expect(calculateTip(-1000, 20)).toBe(0);
|
||||
expect(calculateTip(1000, -15)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateChange', () => {
|
||||
it('calculates change for overpayment', () => {
|
||||
// $20.00 tendered for $10.50 = $9.50 change
|
||||
expect(calculateChange(1050, 2000)).toBe(950);
|
||||
});
|
||||
|
||||
it('returns 0 for exact payment', () => {
|
||||
expect(calculateChange(1050, 1050)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for insufficient payment', () => {
|
||||
expect(calculateChange(1050, 1000)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles large payments', () => {
|
||||
// $100.00 tendered for $23.75 = $76.25 change
|
||||
expect(calculateChange(2375, 10000)).toBe(7625);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateLineTotal', () => {
|
||||
it('calculates line total without discount', () => {
|
||||
expect(calculateLineTotal(1000, 2)).toBe(2000);
|
||||
expect(calculateLineTotal(1500, 3)).toBe(4500);
|
||||
});
|
||||
|
||||
it('applies percentage discount', () => {
|
||||
// $10.00 x 2 = $20.00, 10% off = $18.00
|
||||
expect(calculateLineTotal(1000, 2, 10)).toBe(1800);
|
||||
});
|
||||
|
||||
it('applies 50% discount', () => {
|
||||
expect(calculateLineTotal(1000, 2, 50)).toBe(1000);
|
||||
});
|
||||
|
||||
it('handles zero discount', () => {
|
||||
expect(calculateLineTotal(1000, 2, 0)).toBe(2000);
|
||||
});
|
||||
|
||||
it('handles single item', () => {
|
||||
expect(calculateLineTotal(1500, 1)).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDiscountAmount', () => {
|
||||
it('calculates discount amount', () => {
|
||||
// 10% of $20.00 = $2.00
|
||||
expect(calculateDiscountAmount(2000, 10)).toBe(200);
|
||||
});
|
||||
|
||||
it('rounds to nearest cent', () => {
|
||||
// 15% of $15.50 = $2.325, rounds to $2.33
|
||||
expect(calculateDiscountAmount(1550, 15)).toBe(233);
|
||||
});
|
||||
|
||||
it('returns 0 for zero amount', () => {
|
||||
expect(calculateDiscountAmount(0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for zero discount', () => {
|
||||
expect(calculateDiscountAmount(2000, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for negative values', () => {
|
||||
expect(calculateDiscountAmount(-1000, 10)).toBe(0);
|
||||
expect(calculateDiscountAmount(1000, -10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateOrderTotals', () => {
|
||||
it('calculates totals for simple order', () => {
|
||||
const items = [
|
||||
{ unit_price_cents: 1000, quantity: 2, tax_rate: 0.08, is_taxable: true, discount_percent: 0 },
|
||||
];
|
||||
|
||||
const result = calculateOrderTotals(items);
|
||||
|
||||
expect(result.subtotal).toBe(2000);
|
||||
expect(result.tax).toBe(160); // 8% of $20.00
|
||||
expect(result.discount).toBe(0);
|
||||
expect(result.tip).toBe(0);
|
||||
expect(result.total).toBe(2160);
|
||||
});
|
||||
|
||||
it('calculates totals for multiple items', () => {
|
||||
const items = [
|
||||
{ unit_price_cents: 1000, quantity: 1, tax_rate: 0.08, is_taxable: true, discount_percent: 0 },
|
||||
{ unit_price_cents: 500, quantity: 2, tax_rate: 0.08, is_taxable: true, discount_percent: 0 },
|
||||
];
|
||||
|
||||
const result = calculateOrderTotals(items);
|
||||
|
||||
expect(result.subtotal).toBe(2000); // $10 + $10
|
||||
expect(result.tax).toBe(160);
|
||||
expect(result.total).toBe(2160);
|
||||
});
|
||||
|
||||
it('handles item-level discounts', () => {
|
||||
const items = [
|
||||
{ unit_price_cents: 1000, quantity: 2, tax_rate: 0.08, is_taxable: true, discount_percent: 10 },
|
||||
];
|
||||
|
||||
const result = calculateOrderTotals(items);
|
||||
|
||||
// Subtotal: $20 - 10% = $18
|
||||
expect(result.subtotal).toBe(1800);
|
||||
expect(result.tax).toBe(144); // 8% of $18
|
||||
expect(result.total).toBe(1944);
|
||||
});
|
||||
|
||||
it('applies order-level discount', () => {
|
||||
const items = [
|
||||
{ unit_price_cents: 1000, quantity: 2, tax_rate: 0.08, is_taxable: true, discount_percent: 0 },
|
||||
];
|
||||
|
||||
const result = calculateOrderTotals(items, 500); // $5 discount
|
||||
|
||||
expect(result.subtotal).toBe(2000);
|
||||
expect(result.discount).toBe(500);
|
||||
expect(result.total).toBe(1660); // 2000 + 160 - 500
|
||||
});
|
||||
|
||||
it('adds tip to total', () => {
|
||||
const items = [
|
||||
{ unit_price_cents: 1000, quantity: 1, tax_rate: 0.08, is_taxable: true, discount_percent: 0 },
|
||||
];
|
||||
|
||||
const result = calculateOrderTotals(items, 0, 200); // $2 tip
|
||||
|
||||
expect(result.tip).toBe(200);
|
||||
expect(result.total).toBe(1280); // 1000 + 80 + 200
|
||||
});
|
||||
|
||||
it('handles non-taxable items', () => {
|
||||
const items = [
|
||||
{ unit_price_cents: 1000, quantity: 1, tax_rate: 0.08, is_taxable: false, discount_percent: 0 },
|
||||
];
|
||||
|
||||
const result = calculateOrderTotals(items);
|
||||
|
||||
expect(result.subtotal).toBe(1000);
|
||||
expect(result.tax).toBe(0);
|
||||
expect(result.total).toBe(1000);
|
||||
});
|
||||
|
||||
it('handles empty cart', () => {
|
||||
const result = calculateOrderTotals([]);
|
||||
|
||||
expect(result.subtotal).toBe(0);
|
||||
expect(result.tax).toBe(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it('prevents negative total by capping discount to subtotal', () => {
|
||||
const items = [
|
||||
{ unit_price_cents: 500, quantity: 1, tax_rate: 0.08, is_taxable: true, discount_percent: 0 },
|
||||
];
|
||||
|
||||
// $5 subtotal with $10 discount - discount capped to $5
|
||||
const result = calculateOrderTotals(items, 1000);
|
||||
|
||||
// Discount is capped to subtotal ($5), but tax ($0.40) is still owed
|
||||
expect(result.discount).toBe(500); // Capped to subtotal
|
||||
expect(result.total).toBe(40); // 500 + 40 - 500 = 40
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTaxRate', () => {
|
||||
it('formats standard tax rates', () => {
|
||||
expect(formatTaxRate(0.0825)).toBe('8.25%');
|
||||
expect(formatTaxRate(0.10)).toBe('10%');
|
||||
expect(formatTaxRate(0.065)).toBe('6.5%');
|
||||
});
|
||||
|
||||
it('handles zero rate', () => {
|
||||
expect(formatTaxRate(0)).toBe('0%');
|
||||
});
|
||||
|
||||
it('handles whole number rates', () => {
|
||||
expect(formatTaxRate(0.05)).toBe('5%');
|
||||
expect(formatTaxRate(0.07)).toBe('7%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestTenderAmounts', () => {
|
||||
it('includes exact amount first', () => {
|
||||
const suggestions = suggestTenderAmounts(1050);
|
||||
expect(suggestions[0]).toBe(1050);
|
||||
});
|
||||
|
||||
it('suggests round dollar amounts', () => {
|
||||
const suggestions = suggestTenderAmounts(1050);
|
||||
expect(suggestions).toContain(1100); // Next dollar
|
||||
});
|
||||
|
||||
it('suggests common bill denominations', () => {
|
||||
const suggestions = suggestTenderAmounts(1050);
|
||||
expect(suggestions).toContain(2000); // $20
|
||||
});
|
||||
|
||||
it('limits to 5 suggestions', () => {
|
||||
const suggestions = suggestTenderAmounts(1050);
|
||||
expect(suggestions.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('handles small amounts', () => {
|
||||
const suggestions = suggestTenderAmounts(150);
|
||||
expect(suggestions[0]).toBe(150);
|
||||
expect(suggestions).toContain(200); // Next dollar
|
||||
expect(suggestions).toContain(500); // $5
|
||||
});
|
||||
|
||||
it('handles amounts exactly on dollar', () => {
|
||||
const suggestions = suggestTenderAmounts(1000);
|
||||
expect(suggestions[0]).toBe(1000);
|
||||
// Should not include $10 twice
|
||||
const tenDollarCount = suggestions.filter(s => s === 1000).length;
|
||||
expect(tenDollarCount).toBe(1);
|
||||
});
|
||||
|
||||
it('suggests appropriate bills for larger amounts', () => {
|
||||
const suggestions = suggestTenderAmounts(2375);
|
||||
expect(suggestions).toContain(2375); // Exact
|
||||
expect(suggestions).toContain(2400); // Next dollar
|
||||
// Should include at least one bill larger than amount
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidGiftCardCode', () => {
|
||||
it('accepts valid 16-character alphanumeric codes', () => {
|
||||
expect(isValidGiftCardCode('ABCD1234EFGH5678')).toBe(true);
|
||||
expect(isValidGiftCardCode('0000000000000000')).toBe(true);
|
||||
expect(isValidGiftCardCode('ZZZZZZZZZZZZZZZZ')).toBe(true);
|
||||
expect(isValidGiftCardCode('A1B2C3D4E5F6G7H8')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts lowercase codes (case insensitive)', () => {
|
||||
expect(isValidGiftCardCode('abcd1234efgh5678')).toBe(true);
|
||||
expect(isValidGiftCardCode('AbCd1234EfGh5678')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects codes that are too short', () => {
|
||||
expect(isValidGiftCardCode('ABC123')).toBe(false);
|
||||
expect(isValidGiftCardCode('ABCD1234EFGH567')).toBe(false); // 15 chars
|
||||
});
|
||||
|
||||
it('rejects codes that are too long', () => {
|
||||
expect(isValidGiftCardCode('ABCD1234EFGH56789')).toBe(false); // 17 chars
|
||||
});
|
||||
|
||||
it('rejects codes with special characters', () => {
|
||||
expect(isValidGiftCardCode('ABCD-1234-EFGH-56')).toBe(false);
|
||||
expect(isValidGiftCardCode('ABCD_1234_EFGH_56')).toBe(false);
|
||||
expect(isValidGiftCardCode('ABCD 1234 EFGH 56')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty or null codes', () => {
|
||||
expect(isValidGiftCardCode('')).toBe(false);
|
||||
expect(isValidGiftCardCode(null as unknown as string)).toBe(false);
|
||||
expect(isValidGiftCardCode(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateOrderNumber', () => {
|
||||
it('generates order number in correct format', () => {
|
||||
const orderNumber = generateOrderNumber();
|
||||
// Format: POS-YYYYMMDD-XXXX
|
||||
expect(orderNumber).toMatch(/^POS-\d{8}-\d{4}$/);
|
||||
});
|
||||
|
||||
it('uses current date', () => {
|
||||
const orderNumber = generateOrderNumber();
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
const expectedDate = `${year}${month}${day}`;
|
||||
|
||||
expect(orderNumber).toContain(`POS-${expectedDate}-`);
|
||||
});
|
||||
|
||||
it('generates 4-digit random suffix', () => {
|
||||
const orderNumber = generateOrderNumber();
|
||||
const suffix = orderNumber.split('-')[2];
|
||||
expect(suffix).toMatch(/^\d{4}$/);
|
||||
const num = parseInt(suffix, 10);
|
||||
expect(num).toBeGreaterThanOrEqual(1000);
|
||||
expect(num).toBeLessThanOrEqual(9999);
|
||||
});
|
||||
|
||||
it('generates unique order numbers', () => {
|
||||
const orderNumbers = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
orderNumbers.add(generateOrderNumber());
|
||||
}
|
||||
// Should be highly unlikely to have duplicates
|
||||
expect(orderNumbers.size).toBeGreaterThan(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateGiftCardCode', () => {
|
||||
it('generates 16-character code', () => {
|
||||
const code = generateGiftCardCode();
|
||||
expect(code).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('generates alphanumeric code', () => {
|
||||
const code = generateGiftCardCode();
|
||||
expect(code).toMatch(/^[A-Z0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('generates valid codes', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const code = generateGiftCardCode();
|
||||
expect(isValidGiftCardCode(code)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates unique codes', () => {
|
||||
const codes = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
codes.add(generateGiftCardCode());
|
||||
}
|
||||
// Should be highly unlikely to have duplicates
|
||||
expect(codes.size).toBeGreaterThan(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPhoneNumber', () => {
|
||||
it('formats 10-digit phone numbers', () => {
|
||||
expect(formatPhoneNumber('5551234567')).toBe('(555) 123-4567');
|
||||
});
|
||||
|
||||
it('handles phone with country code', () => {
|
||||
expect(formatPhoneNumber('15551234567')).toBe('(555) 123-4567');
|
||||
expect(formatPhoneNumber('+15551234567')).toBe('(555) 123-4567');
|
||||
});
|
||||
|
||||
it('strips existing formatting', () => {
|
||||
expect(formatPhoneNumber('(555) 123-4567')).toBe('(555) 123-4567');
|
||||
expect(formatPhoneNumber('555-123-4567')).toBe('(555) 123-4567');
|
||||
expect(formatPhoneNumber('555.123.4567')).toBe('(555) 123-4567');
|
||||
});
|
||||
|
||||
it('returns original for non-standard numbers', () => {
|
||||
expect(formatPhoneNumber('123')).toBe('123');
|
||||
expect(formatPhoneNumber('12345678901234')).toBe('12345678901234');
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(formatPhoneNumber('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncate', () => {
|
||||
it('truncates long strings with ellipsis', () => {
|
||||
expect(truncate('This is a long product name', 20)).toBe('This is a long pr...');
|
||||
});
|
||||
|
||||
it('does not truncate short strings', () => {
|
||||
expect(truncate('Short', 20)).toBe('Short');
|
||||
});
|
||||
|
||||
it('handles exact length strings', () => {
|
||||
expect(truncate('Exactly20Characters!', 20)).toBe('Exactly20Characters!');
|
||||
});
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(truncate('', 20)).toBe('');
|
||||
});
|
||||
|
||||
it('handles null/undefined', () => {
|
||||
expect(truncate(null as unknown as string, 20)).toBe('');
|
||||
expect(truncate(undefined as unknown as string, 20)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('padReceiptLine', () => {
|
||||
it('pads line to full width', () => {
|
||||
const result = padReceiptLine('Subtotal:', '$10.00', 42);
|
||||
expect(result).toHaveLength(42);
|
||||
expect(result.startsWith('Subtotal:')).toBe(true);
|
||||
expect(result.endsWith('$10.00')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles short content', () => {
|
||||
const result = padReceiptLine('Tax:', '$0.80', 42);
|
||||
expect(result).toHaveLength(42);
|
||||
});
|
||||
|
||||
it('handles default width', () => {
|
||||
const result = padReceiptLine('Total:', '$15.80');
|
||||
expect(result).toHaveLength(42); // Default width
|
||||
});
|
||||
|
||||
it('handles content exceeding width', () => {
|
||||
const result = padReceiptLine('Very Long Product Name That Exceeds Width', '$100.00', 42);
|
||||
expect(result).toHaveLength(42);
|
||||
// Should truncate left side to fit
|
||||
expect(result.endsWith('$100.00')).toBe(true);
|
||||
});
|
||||
});
|
||||
220
frontend/src/pos/components/BarcodeScannerStatus.example.tsx
Normal file
220
frontend/src/pos/components/BarcodeScannerStatus.example.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* BarcodeScannerStatus Usage Examples
|
||||
*
|
||||
* This file demonstrates how to use the barcode scanner integration in the POS module.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { BarcodeScannerStatus } from './BarcodeScannerStatus';
|
||||
import { useCart } from '../hooks/useCart';
|
||||
import { useBarcodeScanner } from '../hooks/usePOSProducts';
|
||||
|
||||
/**
|
||||
* Example 1: Basic Scanner with Manual Entry
|
||||
* Shows scanner status and allows manual barcode input
|
||||
*/
|
||||
export const BasicScannerExample: React.FC = () => {
|
||||
const handleScan = (barcode: string) => {
|
||||
console.log('Barcode scanned:', barcode);
|
||||
// Your logic here - look up product, add to cart, etc.
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>POS Terminal</h2>
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
showManualEntry={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 2: Auto-Add to Cart
|
||||
* Automatically looks up and adds products to cart when scanned
|
||||
*/
|
||||
export const AutoAddToCartExample: React.FC = () => {
|
||||
const handleScan = (barcode: string) => {
|
||||
console.log('Product scanned:', barcode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Quick Checkout</h2>
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
autoAddToCart={true} // Automatically add products to cart
|
||||
showManualEntry={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 3: Compact Mode
|
||||
* Shows a small scanner indicator with tooltip
|
||||
*/
|
||||
export const CompactScannerExample: React.FC = () => {
|
||||
const handleScan = (barcode: string) => {
|
||||
console.log('Scanned:', barcode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<h3>Checkout</h3>
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
compact={true} // Small icon only
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 4: Custom Scanner Configuration
|
||||
* Adjust timing parameters for different scanner hardware
|
||||
*/
|
||||
export const CustomConfigExample: React.FC = () => {
|
||||
const handleScan = (barcode: string) => {
|
||||
console.log('Scanned:', barcode);
|
||||
};
|
||||
|
||||
return (
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
keystrokeThreshold={150} // Allow slower scanners (150ms between chars)
|
||||
timeout={300} // Wait 300ms after last char
|
||||
minLength={5} // Require at least 5 characters
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 5: Integration with Cart
|
||||
* Full POS integration with product lookup and cart management
|
||||
*/
|
||||
export const FullPOSIntegrationExample: React.FC = () => {
|
||||
const { addProduct } = useCart();
|
||||
const { lookupBarcode } = useBarcodeScanner();
|
||||
const [lastScanResult, setLastScanResult] = React.useState<string>('');
|
||||
|
||||
const handleScan = async (barcode: string) => {
|
||||
try {
|
||||
// Look up product by barcode
|
||||
const product = await lookupBarcode(barcode);
|
||||
|
||||
if (product) {
|
||||
// Add to cart
|
||||
addProduct(product);
|
||||
setLastScanResult(`Added: ${product.name}`);
|
||||
} else {
|
||||
setLastScanResult(`Product not found: ${barcode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Barcode lookup failed:', error);
|
||||
setLastScanResult(`Error scanning: ${barcode}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Point of Sale</h2>
|
||||
|
||||
{/* Scanner status */}
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
showManualEntry={true}
|
||||
/>
|
||||
|
||||
{/* Last scan result */}
|
||||
{lastScanResult && (
|
||||
<div style={{ marginTop: '12px', padding: '12px', background: '#f0f0f0', borderRadius: '6px' }}>
|
||||
{lastScanResult}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 6: Conditional Scanner
|
||||
* Enable/disable scanner based on application state
|
||||
*/
|
||||
export const ConditionalScannerExample: React.FC = () => {
|
||||
const [isCheckingOut, setIsCheckingOut] = React.useState(false);
|
||||
const [isPaused, setIsPaused] = React.useState(false);
|
||||
|
||||
const handleScan = (barcode: string) => {
|
||||
if (isCheckingOut) {
|
||||
console.log('Processing checkout, scanner paused');
|
||||
return;
|
||||
}
|
||||
console.log('Scanned:', barcode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>POS Terminal</h2>
|
||||
|
||||
<BarcodeScannerStatus
|
||||
enabled={!isCheckingOut && !isPaused}
|
||||
onScan={handleScan}
|
||||
showManualEntry={true}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<button onClick={() => setIsPaused(!isPaused)}>
|
||||
{isPaused ? 'Resume Scanner' : 'Pause Scanner'}
|
||||
</button>
|
||||
<button onClick={() => setIsCheckingOut(!isCheckingOut)}>
|
||||
{isCheckingOut ? 'Cancel Checkout' : 'Start Checkout'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 7: Scanner with Notifications
|
||||
* Show toast notifications for scan results
|
||||
*/
|
||||
export const ScannerWithNotificationsExample: React.FC = () => {
|
||||
const { addProduct } = useCart();
|
||||
const { lookupBarcode } = useBarcodeScanner();
|
||||
|
||||
const handleScan = async (barcode: string) => {
|
||||
try {
|
||||
const product = await lookupBarcode(barcode);
|
||||
|
||||
if (product) {
|
||||
addProduct(product);
|
||||
// Show success notification (use your notification library)
|
||||
console.log('Success:', `Added ${product.name} to cart`);
|
||||
} else {
|
||||
// Show error notification
|
||||
console.log('Error:', `Product not found: ${barcode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error notification
|
||||
console.log('Error:', 'Failed to process barcode');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Scan Products</h2>
|
||||
<BarcodeScannerStatus
|
||||
enabled={true}
|
||||
onScan={handleScan}
|
||||
autoAddToCart={false}
|
||||
showManualEntry={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
462
frontend/src/pos/components/BarcodeScannerStatus.tsx
Normal file
462
frontend/src/pos/components/BarcodeScannerStatus.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* BarcodeScannerStatus Component
|
||||
*
|
||||
* Displays barcode scanner status and provides manual barcode entry fallback.
|
||||
* Shows visual feedback when scanner is active and receiving input.
|
||||
*/
|
||||
|
||||
import React, { useState, KeyboardEvent } from 'react';
|
||||
import { useBarcodeScanner } from '../hooks/useBarcodeScanner';
|
||||
import { useBarcodeScanner as useProductLookup } from '../hooks/usePOSProducts';
|
||||
import { useCart } from '../hooks/useCart';
|
||||
|
||||
interface BarcodeScannerStatusProps {
|
||||
/**
|
||||
* Enable/disable scanner
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Callback when barcode is scanned (manually or via scanner)
|
||||
*/
|
||||
onScan: (barcode: string) => void;
|
||||
|
||||
/**
|
||||
* Show manual entry input
|
||||
* @default true
|
||||
*/
|
||||
showManualEntry?: boolean;
|
||||
|
||||
/**
|
||||
* Compact mode - icon only
|
||||
* @default false
|
||||
*/
|
||||
compact?: boolean;
|
||||
|
||||
/**
|
||||
* Custom keystroke threshold (ms)
|
||||
*/
|
||||
keystrokeThreshold?: number;
|
||||
|
||||
/**
|
||||
* Custom timeout (ms)
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* Minimum barcode length
|
||||
*/
|
||||
minLength?: number;
|
||||
|
||||
/**
|
||||
* Auto-add products to cart when scanned
|
||||
* @default false
|
||||
*/
|
||||
autoAddToCart?: boolean;
|
||||
}
|
||||
|
||||
export const BarcodeScannerStatus: React.FC<BarcodeScannerStatusProps> = ({
|
||||
enabled,
|
||||
onScan,
|
||||
showManualEntry = true,
|
||||
compact = false,
|
||||
keystrokeThreshold,
|
||||
timeout,
|
||||
minLength,
|
||||
autoAddToCart = false,
|
||||
}) => {
|
||||
const [manualBarcode, setManualBarcode] = useState('');
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [lastScanSuccess, setLastScanSuccess] = useState(false);
|
||||
|
||||
const { lookupBarcode } = useProductLookup();
|
||||
const { addProduct } = useCart();
|
||||
|
||||
/**
|
||||
* Handle barcode scan from hardware scanner or manual entry
|
||||
*/
|
||||
const handleScan = async (barcode: string) => {
|
||||
// Call the callback
|
||||
onScan(barcode);
|
||||
|
||||
// Auto-add to cart if enabled
|
||||
if (autoAddToCart) {
|
||||
try {
|
||||
const product = await lookupBarcode(barcode);
|
||||
if (product) {
|
||||
addProduct(product);
|
||||
setLastScanSuccess(true);
|
||||
setTimeout(() => setLastScanSuccess(false), 1000);
|
||||
} else {
|
||||
setLastScanSuccess(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Barcode lookup failed:', error);
|
||||
setLastScanSuccess(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Hook into hardware scanner
|
||||
const { buffer, isScanning } = useBarcodeScanner({
|
||||
onScan: handleScan,
|
||||
enabled,
|
||||
keystrokeThreshold,
|
||||
timeout,
|
||||
minLength,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle manual barcode entry
|
||||
*/
|
||||
const handleManualSubmit = () => {
|
||||
const trimmed = manualBarcode.trim();
|
||||
if (trimmed) {
|
||||
handleScan(trimmed);
|
||||
setManualBarcode('');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Enter key in manual input
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleManualSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status text
|
||||
*/
|
||||
const getStatusText = () => {
|
||||
if (isScanning) return 'Scanning...';
|
||||
if (!enabled) return 'Scanner Inactive';
|
||||
return 'Scanner Active';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status color class
|
||||
*/
|
||||
const getStatusClass = () => {
|
||||
if (isScanning) return 'scanning';
|
||||
if (lastScanSuccess) return 'success';
|
||||
if (!enabled) return 'inactive';
|
||||
return 'active';
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={`barcode-scanner-compact ${getStatusClass()} compact`}
|
||||
aria-label="Barcode scanner status"
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<div className="scanner-icon">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M8 7v10" />
|
||||
<path d="M12 7v10" />
|
||||
<path d="M16 7v10" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{showTooltip && (
|
||||
<div className="scanner-tooltip" role="tooltip">
|
||||
{getStatusText()}
|
||||
{buffer && <div className="buffer-display">Code: {buffer}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.barcode-scanner-compact {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.barcode-scanner-compact.inactive {
|
||||
color: #9CA3AF;
|
||||
background: #F3F4F6;
|
||||
}
|
||||
|
||||
.barcode-scanner-compact.active {
|
||||
color: #10B981;
|
||||
background: #D1FAE5;
|
||||
}
|
||||
|
||||
.barcode-scanner-compact.scanning {
|
||||
color: #3B82F6;
|
||||
background: #DBEAFE;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.barcode-scanner-compact.success {
|
||||
color: #10B981;
|
||||
background: #D1FAE5;
|
||||
animation: flash 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.scanner-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1F2937;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.scanner-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-bottom-color: #1F2937;
|
||||
}
|
||||
|
||||
.buffer-display {
|
||||
margin-top: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #60A5FA;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`barcode-scanner-status ${getStatusClass()}`} aria-label="Barcode scanner status">
|
||||
<div className="scanner-indicator">
|
||||
<div className="status-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M8 7v10" />
|
||||
<path d="M12 7v10" />
|
||||
<path d="M16 7v10" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="status-text">
|
||||
<div className="status-label">{getStatusText()}</div>
|
||||
{buffer && <div className="buffer-display">Reading: {buffer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showManualEntry && (
|
||||
<div className="manual-entry">
|
||||
<input
|
||||
type="text"
|
||||
value={manualBarcode}
|
||||
onChange={(e) => setManualBarcode(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter barcode manually"
|
||||
className="manual-input"
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<button
|
||||
onClick={handleManualSubmit}
|
||||
className="manual-submit"
|
||||
disabled={!enabled || !manualBarcode.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.barcode-scanner-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border: 2px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.inactive {
|
||||
border-color: #E5E7EB;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.active {
|
||||
border-color: #10B981;
|
||||
background: #F0FDF4;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.scanning {
|
||||
border-color: #3B82F6;
|
||||
background: #EFF6FF;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.success {
|
||||
border-color: #10B981;
|
||||
background: #D1FAE5;
|
||||
animation: flash 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.scanner-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.inactive .status-icon {
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.active .status-icon {
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.scanning .status-icon {
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
.barcode-scanner-status.success .status-icon {
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.buffer-display {
|
||||
font-size: 12px;
|
||||
color: #6B7280;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.manual-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.manual-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #D1D5DB;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.manual-input:focus {
|
||||
outline: none;
|
||||
border-color: #3B82F6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.manual-input:disabled {
|
||||
background: #F3F4F6;
|
||||
color: #9CA3AF;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.manual-submit {
|
||||
padding: 8px 16px;
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.manual-submit:hover:not(:disabled) {
|
||||
background: #2563EB;
|
||||
}
|
||||
|
||||
.manual-submit:disabled {
|
||||
background: #D1D5DB;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
25%, 75% { opacity: 0.7; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarcodeScannerStatus;
|
||||
157
frontend/src/pos/components/CartItem.tsx
Normal file
157
frontend/src/pos/components/CartItem.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import { Plus, Minus, X, Tag } from 'lucide-react';
|
||||
|
||||
interface CartItemData {
|
||||
id: string;
|
||||
product_id: string;
|
||||
name: string;
|
||||
unit_price_cents: number;
|
||||
quantity: number;
|
||||
discount_cents?: number;
|
||||
discount_percent?: number;
|
||||
line_total_cents: number;
|
||||
}
|
||||
|
||||
interface CartItemProps {
|
||||
item: CartItemData;
|
||||
onUpdateQuantity: (itemId: string, quantity: number) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onApplyDiscount?: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CartItem - Individual line item in the cart
|
||||
*
|
||||
* Features:
|
||||
* - Item name and unit price
|
||||
* - Large +/- quantity buttons (min 48px)
|
||||
* - Remove button (X)
|
||||
* - Discount indicator
|
||||
* - Line total
|
||||
* - Touch-friendly controls
|
||||
*
|
||||
* Design principles:
|
||||
* - Clear visual hierarchy
|
||||
* - Large touch targets
|
||||
* - Immediate feedback
|
||||
* - Accessible
|
||||
*/
|
||||
const CartItem: React.FC<CartItemProps> = ({
|
||||
item,
|
||||
onUpdateQuantity,
|
||||
onRemove,
|
||||
onApplyDiscount,
|
||||
}) => {
|
||||
// Format price in dollars
|
||||
const formatPrice = (cents: number) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
// Handle quantity change
|
||||
const handleIncrement = () => {
|
||||
onUpdateQuantity(item.id, item.quantity + 1);
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (item.quantity > 1) {
|
||||
onUpdateQuantity(item.id, item.quantity - 1);
|
||||
} else {
|
||||
// Remove item if quantity would become 0
|
||||
onRemove(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const hasDiscount = (item.discount_cents || 0) > 0 || (item.discount_percent || 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 bg-white hover:bg-gray-50 rounded-lg border border-gray-200 transition-colors">
|
||||
{/* Item Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Item Name */}
|
||||
<h4 className="font-medium text-gray-900 truncate mb-1">
|
||||
{item.name}
|
||||
</h4>
|
||||
|
||||
{/* Unit Price */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
|
||||
<span>{formatPrice(item.unit_price_cents)}</span>
|
||||
{hasDiscount && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<button
|
||||
onClick={() => onApplyDiscount?.(item.id)}
|
||||
className="inline-flex items-center bg-green-100 hover:bg-green-200 text-green-800 px-2 py-0.5 text-xs font-medium rounded transition-colors"
|
||||
title="Click to edit discount"
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{item.discount_percent
|
||||
? `${item.discount_percent}% off`
|
||||
: `${formatPrice(item.discount_cents!)} off`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDecrement}
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 active:scale-95"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus className="w-5 h-5 text-gray-700" />
|
||||
</button>
|
||||
|
||||
<span className="w-12 text-center font-bold text-lg text-gray-900">
|
||||
{item.quantity}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleIncrement}
|
||||
className="flex items-center justify-center w-10 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 active:scale-95"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Discount Button (if handler provided) */}
|
||||
{onApplyDiscount && !hasDiscount && (
|
||||
<button
|
||||
onClick={() => onApplyDiscount(item.id)}
|
||||
className="mt-2 text-xs text-blue-600 hover:text-blue-700 font-medium"
|
||||
aria-label="Apply discount to this item"
|
||||
>
|
||||
Apply Discount
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side: Line Total and Remove */}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{/* Line Total */}
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-lg text-gray-900">
|
||||
{formatPrice(item.line_total_cents)}
|
||||
</div>
|
||||
{hasDiscount && (
|
||||
<div className="text-xs text-gray-500 line-through">
|
||||
{formatPrice(item.unit_price_cents * item.quantity)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="flex items-center justify-center w-8 h-8 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 active:scale-95"
|
||||
aria-label={`Remove ${item.name} from cart`}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartItem;
|
||||
261
frontend/src/pos/components/CartPanel.tsx
Normal file
261
frontend/src/pos/components/CartPanel.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ShoppingCart, User, Trash2, DollarSign, CreditCard } from 'lucide-react';
|
||||
import CartItem from './CartItem';
|
||||
import { Button, Badge } from '../../components/ui';
|
||||
|
||||
interface CartItemData {
|
||||
id: string;
|
||||
product_id: string;
|
||||
name: string;
|
||||
unit_price_cents: number;
|
||||
quantity: number;
|
||||
discount_cents?: number;
|
||||
discount_percent?: number;
|
||||
line_total_cents: number;
|
||||
}
|
||||
|
||||
interface CartPanelProps {
|
||||
items?: CartItemData[];
|
||||
customer?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
onUpdateQuantity?: (itemId: string, quantity: number) => void;
|
||||
onRemoveItem?: (itemId: string) => void;
|
||||
onClearCart?: () => void;
|
||||
onSelectCustomer?: () => void;
|
||||
onApplyDiscount?: (itemId: string) => void;
|
||||
onApplyOrderDiscount?: () => void;
|
||||
onAddTip?: () => void;
|
||||
onCheckout?: () => void;
|
||||
taxRate?: number; // e.g., 0.0825 for 8.25%
|
||||
discount_cents?: number;
|
||||
tip_cents?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CartPanel - Right sidebar cart with totals
|
||||
*
|
||||
* Features:
|
||||
* - Scrollable item list
|
||||
* - Customer assignment button
|
||||
* - Subtotal, discount, tax, tip display
|
||||
* - Running total (large, bold)
|
||||
* - Clear cart button (with confirmation)
|
||||
* - Large PAY button at bottom
|
||||
* - Touch-friendly controls
|
||||
*
|
||||
* Design principles:
|
||||
* - Always visible (fixed panel)
|
||||
* - Clear visual hierarchy
|
||||
* - Large touch targets
|
||||
* - Immediate feedback
|
||||
*/
|
||||
const CartPanel: React.FC<CartPanelProps> = ({
|
||||
items = [],
|
||||
customer,
|
||||
onUpdateQuantity = () => {},
|
||||
onRemoveItem = () => {},
|
||||
onClearCart = () => {},
|
||||
onSelectCustomer = () => {},
|
||||
onApplyDiscount = () => {},
|
||||
onApplyOrderDiscount = () => {},
|
||||
onAddTip = () => {},
|
||||
onCheckout = () => {},
|
||||
taxRate = 0.0825, // Default 8.25% tax
|
||||
discount_cents = 0,
|
||||
tip_cents = 0,
|
||||
}) => {
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
|
||||
// Calculate totals
|
||||
const subtotal_cents = items.reduce((sum, item) => sum + item.line_total_cents, 0);
|
||||
const tax_cents = Math.round(subtotal_cents * taxRate);
|
||||
const total_cents = subtotal_cents - discount_cents + tax_cents + tip_cents;
|
||||
|
||||
// Format price in dollars
|
||||
const formatPrice = (cents: number) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const isEmpty = items.length === 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
Cart
|
||||
{items.length > 0 && (
|
||||
<Badge className="bg-blue-600 text-white px-2 py-0.5 text-sm">
|
||||
{items.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
{/* Clear Cart Button - always visible when cart has items */}
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showClearConfirm) {
|
||||
onClearCart();
|
||||
setShowClearConfirm(false);
|
||||
} else {
|
||||
setShowClearConfirm(true);
|
||||
setTimeout(() => setShowClearConfirm(false), 3000);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors flex items-center gap-1.5 ${
|
||||
showClearConfirm
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'text-red-600 hover:bg-red-50 border border-red-200'
|
||||
}`}
|
||||
aria-label="Clear cart"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{showClearConfirm ? 'Confirm' : 'Clear'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Customer Assignment */}
|
||||
<button
|
||||
onClick={onSelectCustomer}
|
||||
className={`w-full flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
customer
|
||||
? 'bg-blue-50 hover:bg-blue-100 border-blue-200'
|
||||
: 'bg-gray-50 hover:bg-gray-100 border-gray-200'
|
||||
}`}
|
||||
aria-label={customer ? 'Change customer' : 'Assign customer'}
|
||||
>
|
||||
<User className={`w-5 h-5 ${customer ? 'text-blue-600' : 'text-gray-600'}`} />
|
||||
<span className={`text-sm font-medium flex-1 text-left ${customer ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
{customer ? customer.name : 'Walk-in Customer'}
|
||||
</span>
|
||||
{!customer && (
|
||||
<span className="text-xs text-gray-500">Tap to lookup</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cart Items - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<ShoppingCart className="w-16 h-16 mb-3 text-gray-400" />
|
||||
<p className="text-sm font-medium">Cart is empty</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Add products to get started</p>
|
||||
{/* Show clear button if customer is selected */}
|
||||
{customer && (
|
||||
<button
|
||||
onClick={onClearCart}
|
||||
className="mt-4 px-4 py-2 text-sm text-red-600 hover:text-red-700 font-medium flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear Customer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onUpdateQuantity={onUpdateQuantity}
|
||||
onRemove={onRemoveItem}
|
||||
onApplyDiscount={onApplyDiscount}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals Section */}
|
||||
{!isEmpty && (
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Subtotal */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatPrice(subtotal_cents)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Discount */}
|
||||
{discount_cents > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Discount</span>
|
||||
<span className="font-medium text-green-600">
|
||||
-{formatPrice(discount_cents)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply Discount Button */}
|
||||
{discount_cents === 0 && (
|
||||
<button
|
||||
onClick={onApplyOrderDiscount}
|
||||
className="w-full py-2 text-sm text-blue-600 hover:text-blue-700 font-medium text-left"
|
||||
aria-label="Apply discount to entire order"
|
||||
>
|
||||
+ Apply Discount
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tax */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">
|
||||
Tax ({(taxRate * 100).toFixed(2)}%)
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatPrice(tax_cents)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tip */}
|
||||
{tip_cents > 0 ? (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Tip</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatPrice(tip_cents)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onAddTip}
|
||||
className="w-full py-2 text-sm text-blue-600 hover:text-blue-700 font-medium text-left"
|
||||
aria-label="Add tip"
|
||||
>
|
||||
+ Add Tip
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
|
||||
<span className="text-lg font-bold text-gray-900">TOTAL</span>
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{formatPrice(total_cents)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout Button */}
|
||||
<div className="p-4 pt-0">
|
||||
<Button
|
||||
onClick={onCheckout}
|
||||
disabled={isEmpty}
|
||||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold text-lg rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 active:scale-98 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label={`Pay ${formatPrice(total_cents)}`}
|
||||
>
|
||||
<CreditCard className="w-6 h-6 inline mr-2" />
|
||||
Pay {formatPrice(total_cents)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartPanel;
|
||||
191
frontend/src/pos/components/CashDrawerPanel.tsx
Normal file
191
frontend/src/pos/components/CashDrawerPanel.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Cash Drawer Panel Component
|
||||
*
|
||||
* Shows current shift status and provides controls for opening/closing shifts
|
||||
* and kicking the cash drawer.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useCashDrawer, useKickDrawer } from '../hooks/useCashDrawer';
|
||||
import { formatCents } from '../utils';
|
||||
import type { CashShift } from '../types';
|
||||
|
||||
interface CashDrawerPanelProps {
|
||||
locationId: number | null;
|
||||
onOpenShift?: () => void;
|
||||
onCloseShift?: () => void;
|
||||
}
|
||||
|
||||
const CashDrawerPanel: React.FC<CashDrawerPanelProps> = ({
|
||||
locationId,
|
||||
onOpenShift,
|
||||
onCloseShift,
|
||||
}) => {
|
||||
const { data: currentShift, isLoading } = useCashDrawer(locationId);
|
||||
const kickDrawer = useKickDrawer();
|
||||
|
||||
const handleKickDrawer = () => {
|
||||
kickDrawer.mutate();
|
||||
};
|
||||
|
||||
// No location selected
|
||||
if (!locationId) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Please select a location to view cash drawer status</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Loading drawer status...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No shift open
|
||||
if (!currentShift) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-3">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">No Shift Open</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Open a cash drawer shift to begin taking payments
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenShift}
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
Open Drawer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shift is open
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full mr-3 animate-pulse"></div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Shift Open</h3>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Opened {new Date(currentShift.opened_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Information */}
|
||||
<div className="px-6 py-4 grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-600 mb-1">Opening Balance</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{formatCents(currentShift.opening_balance_cents)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="text-sm text-blue-600 mb-1">Expected Balance</div>
|
||||
<div className="text-2xl font-bold text-blue-900">
|
||||
{formatCents(currentShift.expected_balance_cents)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Opening Notes */}
|
||||
{currentShift.opening_notes && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="text-sm text-gray-600 mb-1">Notes</div>
|
||||
<div className="text-sm text-gray-900 bg-gray-50 rounded p-3">
|
||||
{currentShift.opening_notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-gray-200 px-6 py-4 flex gap-3">
|
||||
<button
|
||||
onClick={handleKickDrawer}
|
||||
disabled={kickDrawer.isPending}
|
||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
Kick Drawer
|
||||
</button>
|
||||
<button
|
||||
onClick={onCloseShift}
|
||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Close Shift
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashDrawerPanel;
|
||||
291
frontend/src/pos/components/CashPaymentPanel.tsx
Normal file
291
frontend/src/pos/components/CashPaymentPanel.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* CashPaymentPanel Component
|
||||
*
|
||||
* Cash payment interface with:
|
||||
* - Amount due display
|
||||
* - NumPad for cash tendered entry
|
||||
* - Quick amount buttons ($1, $5, $10, $20, $50, $100, Exact)
|
||||
* - Automatic change calculation
|
||||
* - Large, clear display
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DollarSign, CheckCircle } from 'lucide-react';
|
||||
import NumPad from './NumPad';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
|
||||
interface CashPaymentPanelProps {
|
||||
/** Amount due in cents */
|
||||
amountDueCents: number;
|
||||
/** Callback when payment is completed */
|
||||
onComplete: (tenderedCents: number, changeCents: number) => void;
|
||||
/** Callback when cancelled */
|
||||
onCancel?: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CashPaymentPanel: React.FC<CashPaymentPanelProps> = ({
|
||||
amountDueCents,
|
||||
onComplete,
|
||||
onCancel,
|
||||
className = '',
|
||||
}) => {
|
||||
const [tenderedCents, setTenderedCents] = useState<number>(amountDueCents);
|
||||
const [showNumPad, setShowNumPad] = useState(false);
|
||||
|
||||
// Calculate change
|
||||
const changeCents = Math.max(0, tenderedCents - amountDueCents);
|
||||
const isValid = tenderedCents >= amountDueCents;
|
||||
|
||||
// Reset tendered amount when amount due changes
|
||||
useEffect(() => {
|
||||
setTenderedCents(amountDueCents);
|
||||
}, [amountDueCents]);
|
||||
|
||||
/**
|
||||
* Format cents as currency
|
||||
*/
|
||||
const formatCents = (cents: number): string => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Quick amount buttons
|
||||
*/
|
||||
const quickAmounts = [
|
||||
{ label: '$1', cents: 100 },
|
||||
{ label: '$5', cents: 500 },
|
||||
{ label: '$10', cents: 1000 },
|
||||
{ label: '$20', cents: 2000 },
|
||||
{ label: '$50', cents: 5000 },
|
||||
{ label: '$100', cents: 10000 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Handle quick amount button click
|
||||
*/
|
||||
const handleQuickAmount = (cents: number) => {
|
||||
setShowNumPad(false);
|
||||
|
||||
// Calculate how many of this denomination needed
|
||||
const count = Math.ceil(amountDueCents / cents);
|
||||
const total = count * cents;
|
||||
|
||||
setTenderedCents(total);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle "Exact" button - customer gives exact change
|
||||
*/
|
||||
const handleExact = () => {
|
||||
setShowNumPad(false);
|
||||
setTenderedCents(amountDueCents);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle "Custom" button - show numpad
|
||||
*/
|
||||
const handleCustom = () => {
|
||||
setShowNumPad(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle complete payment
|
||||
*/
|
||||
const handleComplete = () => {
|
||||
if (isValid) {
|
||||
onComplete(tenderedCents, changeCents);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{/* Amount Due - Large Display */}
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-lg p-6 mb-6">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
Amount Due
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCents(amountDueCents)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{showNumPad ? (
|
||||
/* NumPad Mode */
|
||||
<div className="space-y-4">
|
||||
<NumPad
|
||||
value={tenderedCents}
|
||||
onChange={setTenderedCents}
|
||||
label="Cash Tendered"
|
||||
showCurrency={true}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onClick={() => setShowNumPad(false)}
|
||||
>
|
||||
Back to Quick Amounts
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Quick Buttons Mode */
|
||||
<div className="space-y-6">
|
||||
{/* Quick amount buttons */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Quick Amounts
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{quickAmounts.map((amount) => (
|
||||
<button
|
||||
key={amount.cents}
|
||||
onClick={() => handleQuickAmount(amount.cents)}
|
||||
className="
|
||||
h-20 rounded-lg border-2 border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700
|
||||
hover:border-brand-500 dark:hover:border-brand-400
|
||||
hover:bg-brand-50 dark:hover:bg-brand-900/20
|
||||
active:bg-brand-100 dark:active:bg-brand-900/40
|
||||
transition-all
|
||||
touch-manipulation select-none
|
||||
"
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{amount.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exact and Custom buttons */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={handleExact}
|
||||
className="
|
||||
h-20 rounded-lg border-2
|
||||
border-green-500 dark:border-green-600
|
||||
bg-green-50 dark:bg-green-900/20
|
||||
hover:bg-green-100 dark:hover:bg-green-900/40
|
||||
active:bg-green-200 dark:active:bg-green-900/60
|
||||
transition-all
|
||||
touch-manipulation select-none
|
||||
"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="text-xl font-bold text-green-700 dark:text-green-400">
|
||||
Exact Amount
|
||||
</div>
|
||||
<div className="text-sm text-green-600 dark:text-green-500">
|
||||
{formatCents(amountDueCents)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCustom}
|
||||
className="
|
||||
h-20 rounded-lg border-2
|
||||
border-brand-500 dark:border-brand-600
|
||||
bg-brand-50 dark:bg-brand-900/20
|
||||
hover:bg-brand-100 dark:hover:bg-brand-900/40
|
||||
active:bg-brand-200 dark:active:bg-brand-900/60
|
||||
transition-all
|
||||
touch-manipulation select-none
|
||||
"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<DollarSign className="h-8 w-8 text-brand-600 dark:text-brand-400 mb-1" />
|
||||
<div className="text-lg font-bold text-brand-700 dark:text-brand-400">
|
||||
Custom Amount
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current selection display */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Cash Tendered
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCents(tenderedCents)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Change Due
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
changeCents > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{formatCents(changeCents)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Display - Prominent */}
|
||||
{changeCents > 0 && (
|
||||
<div className="bg-gradient-to-r from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-6 my-6 border-2 border-green-500 dark:border-green-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-green-700 dark:text-green-400 mb-1">
|
||||
Change to Return
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-green-700 dark:text-green-300">
|
||||
{formatCents(changeCents)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckCircle className="h-16 w-16 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t dark:border-gray-700">
|
||||
{onCancel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onCancel}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="success"
|
||||
size="lg"
|
||||
onClick={handleComplete}
|
||||
disabled={!isValid}
|
||||
fullWidth
|
||||
leftIcon={<CheckCircle className="h-5 w-5" />}
|
||||
className={onCancel ? '' : 'col-span-2'}
|
||||
>
|
||||
Complete Payment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Validation message */}
|
||||
{!isValid && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 text-center mt-2">
|
||||
Tendered amount must be at least {formatCents(amountDueCents)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashPaymentPanel;
|
||||
341
frontend/src/pos/components/CategoryManagerModal.tsx
Normal file
341
frontend/src/pos/components/CategoryManagerModal.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* CategoryManagerModal Component
|
||||
*
|
||||
* Modal for managing product categories (add, edit, delete).
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Pencil, Trash2, Plus, GripVertical } from 'lucide-react';
|
||||
import {
|
||||
Modal,
|
||||
ModalFooter,
|
||||
FormInput,
|
||||
FormTextarea,
|
||||
Button,
|
||||
ErrorMessage,
|
||||
Badge,
|
||||
} from '../../components/ui';
|
||||
import { useProductCategories } from '../hooks/usePOSProducts';
|
||||
import {
|
||||
useCreateCategory,
|
||||
useUpdateCategory,
|
||||
useDeleteCategory,
|
||||
} from '../hooks/useProductMutations';
|
||||
import type { POSProductCategory } from '../types';
|
||||
|
||||
interface CategoryManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CategoryFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const initialFormData: CategoryFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#6B7280',
|
||||
};
|
||||
|
||||
// Preset colors for categories
|
||||
const PRESET_COLORS = [
|
||||
'#6B7280', // Gray
|
||||
'#EF4444', // Red
|
||||
'#F97316', // Orange
|
||||
'#F59E0B', // Amber
|
||||
'#EAB308', // Yellow
|
||||
'#84CC16', // Lime
|
||||
'#22C55E', // Green
|
||||
'#10B981', // Emerald
|
||||
'#14B8A6', // Teal
|
||||
'#06B6D4', // Cyan
|
||||
'#0EA5E9', // Sky
|
||||
'#3B82F6', // Blue
|
||||
'#6366F1', // Indigo
|
||||
'#8B5CF6', // Violet
|
||||
'#A855F7', // Purple
|
||||
'#D946EF', // Fuchsia
|
||||
'#EC4899', // Pink
|
||||
'#F43F5E', // Rose
|
||||
];
|
||||
|
||||
export function CategoryManagerModal({ isOpen, onClose }: CategoryManagerModalProps) {
|
||||
const [editingCategory, setEditingCategory] = useState<POSProductCategory | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [formData, setFormData] = useState<CategoryFormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Query hooks
|
||||
const { data: categories, isLoading } = useProductCategories();
|
||||
|
||||
// Mutation hooks
|
||||
const createCategory = useCreateCategory();
|
||||
const updateCategory = useUpdateCategory();
|
||||
const deleteCategory = useDeleteCategory();
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setEditingCategory(null);
|
||||
setIsAdding(false);
|
||||
setFormData(initialFormData);
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (editingCategory) {
|
||||
setFormData({
|
||||
name: editingCategory.name,
|
||||
description: editingCategory.description || '',
|
||||
color: editingCategory.color || '#6B7280',
|
||||
});
|
||||
}
|
||||
}, [editingCategory]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
if (errors[name]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
setFormData((prev) => ({ ...prev, color }));
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Category name is required';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
if (editingCategory) {
|
||||
await updateCategory.mutateAsync({
|
||||
id: editingCategory.id,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
color: formData.color,
|
||||
});
|
||||
} else {
|
||||
await createCategory.mutateAsync({
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
color: formData.color,
|
||||
});
|
||||
}
|
||||
setEditingCategory(null);
|
||||
setIsAdding(false);
|
||||
setFormData(initialFormData);
|
||||
} catch (error: any) {
|
||||
const apiErrors = error.response?.data;
|
||||
if (apiErrors && typeof apiErrors === 'object') {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
Object.entries(apiErrors).forEach(([key, value]) => {
|
||||
fieldErrors[key] = Array.isArray(value) ? value[0] : String(value);
|
||||
});
|
||||
setErrors(fieldErrors);
|
||||
} else {
|
||||
setErrors({ _general: 'Failed to save category. Please try again.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (category: POSProductCategory) => {
|
||||
const productCount = category.product_count || 0;
|
||||
const message = productCount > 0
|
||||
? `Are you sure you want to delete "${category.name}"? ${productCount} product(s) will be uncategorized.`
|
||||
: `Are you sure you want to delete "${category.name}"?`;
|
||||
|
||||
if (!confirm(message)) return;
|
||||
|
||||
try {
|
||||
await deleteCategory.mutateAsync(category.id);
|
||||
} catch {
|
||||
setErrors({ _general: 'Failed to delete category. Please try again.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingCategory(null);
|
||||
setIsAdding(false);
|
||||
setFormData(initialFormData);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handleStartAdd = () => {
|
||||
setEditingCategory(null);
|
||||
setFormData(initialFormData);
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleStartEdit = (category: POSProductCategory) => {
|
||||
setIsAdding(false);
|
||||
setEditingCategory(category);
|
||||
};
|
||||
|
||||
const isEditorOpen = isAdding || editingCategory !== null;
|
||||
const isSaving = createCategory.isPending || updateCategory.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Manage Categories"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{errors._general && <ErrorMessage message={errors._general} />}
|
||||
|
||||
{/* Category Editor Form */}
|
||||
{isEditorOpen && (
|
||||
<form onSubmit={handleSubmit} className="p-4 bg-gray-50 rounded-lg space-y-4">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{editingCategory ? 'Edit Category' : 'Add Category'}
|
||||
</h3>
|
||||
|
||||
<FormInput
|
||||
label="Category Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
error={errors.name}
|
||||
required
|
||||
placeholder="Enter category name"
|
||||
/>
|
||||
|
||||
<FormTextarea
|
||||
label="Description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={2}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => handleColorSelect(color)}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-transform ${
|
||||
formData.color === color
|
||||
? 'border-gray-900 scale-110'
|
||||
: 'border-transparent hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : editingCategory ? 'Save Changes' : 'Add Category'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Add Category Button */}
|
||||
{!isEditorOpen && (
|
||||
<Button onClick={handleStartAdd} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Category
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Category List */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<p className="py-4 text-center text-gray-500">Loading categories...</p>
|
||||
) : categories && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="py-3 flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 cursor-grab" />
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: category.color || '#6B7280' }}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{category.name}</p>
|
||||
{category.description && (
|
||||
<p className="text-sm text-gray-500">{category.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{category.product_count !== undefined && category.product_count > 0 && (
|
||||
<Badge variant="default" size="sm">
|
||||
{category.product_count} product{category.product_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleStartEdit(category)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
aria-label={`Edit ${category.name}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
aria-label={`Delete ${category.name}`}
|
||||
disabled={deleteCategory.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="py-4 text-center text-gray-500">
|
||||
No categories yet. Add your first category above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryManagerModal;
|
||||
102
frontend/src/pos/components/CategoryTabs.tsx
Normal file
102
frontend/src/pos/components/CategoryTabs.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface CategoryTabsProps {
|
||||
categories: Category[];
|
||||
activeCategory: string;
|
||||
onCategoryChange: (categoryId: string) => void;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
/**
|
||||
* CategoryTabs - Touch-friendly category navigation
|
||||
*
|
||||
* Features:
|
||||
* - Large touch targets (min 44px height)
|
||||
* - Horizontal scrolling for many categories
|
||||
* - Vertical layout for sidebar
|
||||
* - Active state styling
|
||||
* - Color-coded categories (optional)
|
||||
*
|
||||
* Design principles:
|
||||
* - High contrast for visibility
|
||||
* - Clear active state
|
||||
* - Smooth scrolling
|
||||
* - Touch feedback
|
||||
*/
|
||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
categories,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
orientation = 'horizontal',
|
||||
}) => {
|
||||
const isHorizontal = orientation === 'horizontal';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
isHorizontal
|
||||
? 'flex overflow-x-auto gap-2 pb-2 scrollbar-hide'
|
||||
: 'flex flex-col gap-1 p-2'
|
||||
}`}
|
||||
role="tablist"
|
||||
aria-orientation={orientation}
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const isActive = category.id === activeCategory;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-label={`Filter by ${category.name}`}
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
className={`
|
||||
${isHorizontal ? 'flex-shrink-0' : 'w-full'}
|
||||
px-6 py-3 rounded-lg font-medium text-sm transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
||||
active:scale-95
|
||||
${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
minHeight: '44px', // Touch target minimum
|
||||
backgroundColor: isActive && category.color ? category.color : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
{category.icon && <span className="text-lg">{category.icon}</span>}
|
||||
{category.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTabs;
|
||||
|
||||
/**
|
||||
* CSS for hiding scrollbar (add to global styles if not already present)
|
||||
*
|
||||
* @layer utilities {
|
||||
* .scrollbar-hide {
|
||||
* -ms-overflow-style: none;
|
||||
* scrollbar-width: none;
|
||||
* }
|
||||
* .scrollbar-hide::-webkit-scrollbar {
|
||||
* display: none;
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
349
frontend/src/pos/components/CloseShiftModal.tsx
Normal file
349
frontend/src/pos/components/CloseShiftModal.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Close Shift Modal Component
|
||||
*
|
||||
* Modal for closing a cash shift with denomination counting and variance calculation.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useCloseShift } from '../hooks/useCashDrawer';
|
||||
import { formatCents } from '../utils';
|
||||
import type { CashShift, CashBreakdown } from '../types';
|
||||
|
||||
interface CloseShiftModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
shift: CashShift;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface DenominationCount {
|
||||
'10000': number; // $100 bills
|
||||
'5000': number; // $50 bills
|
||||
'2000': number; // $20 bills
|
||||
'1000': number; // $10 bills
|
||||
'500': number; // $5 bills
|
||||
'100_bill': number; // $1 bills
|
||||
'25': number; // Quarters
|
||||
'10': number; // Dimes
|
||||
'5': number; // Nickels
|
||||
'1': number; // Pennies
|
||||
}
|
||||
|
||||
const CloseShiftModal: React.FC<CloseShiftModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
shift,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [counts, setCounts] = useState<DenominationCount>({
|
||||
'10000': 0,
|
||||
'5000': 0,
|
||||
'2000': 0,
|
||||
'1000': 0,
|
||||
'500': 0,
|
||||
'100_bill': 0,
|
||||
'25': 0,
|
||||
'10': 0,
|
||||
'5': 0,
|
||||
'1': 0,
|
||||
});
|
||||
const [notes, setNotes] = useState('');
|
||||
const closeShift = useCloseShift();
|
||||
|
||||
// Calculate total from denominations
|
||||
const actualBalanceCents = useMemo(() => {
|
||||
return (
|
||||
counts['10000'] * 10000 +
|
||||
counts['5000'] * 5000 +
|
||||
counts['2000'] * 2000 +
|
||||
counts['1000'] * 1000 +
|
||||
counts['500'] * 500 +
|
||||
counts['100_bill'] * 100 +
|
||||
counts['25'] * 25 +
|
||||
counts['10'] * 10 +
|
||||
counts['5'] * 5 +
|
||||
counts['1'] * 1
|
||||
);
|
||||
}, [counts]);
|
||||
|
||||
// Calculate variance
|
||||
const varianceCents = actualBalanceCents - shift.expected_balance_cents;
|
||||
const isShort = varianceCents < 0;
|
||||
const isExact = varianceCents === 0;
|
||||
|
||||
const handleCountChange = (key: keyof DenominationCount, value: string) => {
|
||||
const numValue = parseInt(value) || 0;
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
[key]: Math.max(0, numValue),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// Build cash breakdown object (only include non-zero counts)
|
||||
const breakdown: CashBreakdown = {};
|
||||
Object.entries(counts).forEach(([key, value]) => {
|
||||
if (value > 0) {
|
||||
breakdown[key as keyof CashBreakdown] = value;
|
||||
}
|
||||
});
|
||||
|
||||
await closeShift.mutateAsync({
|
||||
shiftId: shift.id,
|
||||
actual_balance_cents: actualBalanceCents,
|
||||
cash_breakdown: breakdown,
|
||||
closing_notes: notes,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setCounts({
|
||||
'10000': 0,
|
||||
'5000': 0,
|
||||
'2000': 0,
|
||||
'1000': 0,
|
||||
'500': 0,
|
||||
'100_bill': 0,
|
||||
'25': 0,
|
||||
'10': 0,
|
||||
'5': 0,
|
||||
'1': 0,
|
||||
});
|
||||
setNotes('');
|
||||
|
||||
// Call success callback
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to close shift:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
></div>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 px-6 py-4 sticky top-0 bg-white">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Close Cash Drawer</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Count cash and verify balance</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4">
|
||||
{/* Expected Balance */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm text-blue-600 mb-1">Expected Balance</div>
|
||||
<div className="text-3xl font-bold text-blue-900">
|
||||
{formatCents(shift.expected_balance_cents)}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
Opening: {formatCents(shift.opening_balance_cents)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bills Section */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Bills</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DenominationInput
|
||||
label="$100 bills"
|
||||
value={counts['10000']}
|
||||
onChange={(value) => handleCountChange('10000', value)}
|
||||
amount={counts['10000'] * 10000}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="$50 bills"
|
||||
value={counts['5000']}
|
||||
onChange={(value) => handleCountChange('5000', value)}
|
||||
amount={counts['5000'] * 5000}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="$20 bills"
|
||||
value={counts['2000']}
|
||||
onChange={(value) => handleCountChange('2000', value)}
|
||||
amount={counts['2000'] * 2000}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="$10 bills"
|
||||
value={counts['1000']}
|
||||
onChange={(value) => handleCountChange('1000', value)}
|
||||
amount={counts['1000'] * 1000}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="$5 bills"
|
||||
value={counts['500']}
|
||||
onChange={(value) => handleCountChange('500', value)}
|
||||
amount={counts['500'] * 500}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="$1 bills"
|
||||
value={counts['100_bill']}
|
||||
onChange={(value) => handleCountChange('100_bill', value)}
|
||||
amount={counts['100_bill'] * 100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coins Section */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Coins</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DenominationInput
|
||||
label="Quarters"
|
||||
value={counts['25']}
|
||||
onChange={(value) => handleCountChange('25', value)}
|
||||
amount={counts['25'] * 25}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="Dimes"
|
||||
value={counts['10']}
|
||||
onChange={(value) => handleCountChange('10', value)}
|
||||
amount={counts['10'] * 10}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="Nickels"
|
||||
value={counts['5']}
|
||||
onChange={(value) => handleCountChange('5', value)}
|
||||
amount={counts['5'] * 5}
|
||||
/>
|
||||
<DenominationInput
|
||||
label="Pennies"
|
||||
value={counts['1']}
|
||||
onChange={(value) => handleCountChange('1', value)}
|
||||
amount={counts['1'] * 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-600 mb-1">Actual Balance</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{formatCents(actualBalanceCents)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg p-4 ${
|
||||
isExact
|
||||
? 'bg-green-50'
|
||||
: isShort
|
||||
? 'bg-red-50'
|
||||
: 'bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`text-sm mb-1 ${
|
||||
isExact
|
||||
? 'text-green-600'
|
||||
: isShort
|
||||
? 'text-red-600'
|
||||
: 'text-green-600'
|
||||
}`}
|
||||
>
|
||||
Variance
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
isExact
|
||||
? 'text-green-600'
|
||||
: isShort
|
||||
? 'text-red-600'
|
||||
: 'text-green-600'
|
||||
}`}
|
||||
>
|
||||
{varianceCents > 0 && '+'}
|
||||
{formatCents(varianceCents)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes {isShort && <span className="text-red-600">(Explain variance)</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Optional notes about the shift or variance..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-200 px-6 py-4 flex gap-3 sticky bottom-0 bg-white">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={closeShift.isPending}
|
||||
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={actualBalanceCents === 0 || closeShift.isPending}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{closeShift.isPending ? 'Closing...' : 'Close Shift'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper component for denomination inputs
|
||||
interface DenominationInputProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: string) => void;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const DenominationInput: React.FC<DenominationInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
amount,
|
||||
}) => {
|
||||
// Create a unique ID for the input
|
||||
const inputId = `denom-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor={inputId} className="block text-sm text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
type="number"
|
||||
min="0"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 font-medium min-w-[70px] text-right pt-6">
|
||||
{formatCents(amount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloseShiftModal;
|
||||
159
frontend/src/pos/components/CustomerSelect.example.tsx
Normal file
159
frontend/src/pos/components/CustomerSelect.example.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* CustomerSelect Component - Usage Example
|
||||
*
|
||||
* This file demonstrates how to use the CustomerSelect component in a POS context.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import CustomerSelect from './CustomerSelect';
|
||||
import type { POSCustomer } from '../types';
|
||||
|
||||
/**
|
||||
* Example: Basic Usage in POS Cart
|
||||
*/
|
||||
export const BasicExample: React.FC = () => {
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<POSCustomer | null>(null);
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-md">
|
||||
<h2 className="text-lg font-semibold mb-4">Customer</h2>
|
||||
<CustomerSelect
|
||||
selectedCustomer={selectedCustomer}
|
||||
onCustomerChange={setSelectedCustomer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: With Add Customer Callback
|
||||
*/
|
||||
export const WithCallbackExample: React.FC = () => {
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<POSCustomer | null>(null);
|
||||
|
||||
const handleAddNewCustomer = (customer: POSCustomer) => {
|
||||
console.log('New customer added:', customer);
|
||||
// You could show a success message, update analytics, etc.
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-md">
|
||||
<h2 className="text-lg font-semibold mb-4">Customer</h2>
|
||||
<CustomerSelect
|
||||
selectedCustomer={selectedCustomer}
|
||||
onCustomerChange={setSelectedCustomer}
|
||||
onAddNewCustomer={handleAddNewCustomer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Integration with POS Cart
|
||||
*/
|
||||
export const POSCartIntegration: React.FC = () => {
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<POSCustomer | null>(null);
|
||||
const [cartItems, setCartItems] = useState<any[]>([]);
|
||||
|
||||
const handleCustomerChange = (customer: POSCustomer | null) => {
|
||||
setSelectedCustomer(customer);
|
||||
|
||||
// Optional: Apply customer-specific pricing, discounts, etc.
|
||||
if (customer) {
|
||||
console.log(`Customer selected: ${customer.name}`);
|
||||
// You might want to fetch customer's purchase history,
|
||||
// apply loyalty discounts, or pre-fill email for receipt
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (!selectedCustomer) {
|
||||
alert('Please select a customer or continue as walk-in');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Checkout:', {
|
||||
customer: selectedCustomer,
|
||||
items: cartItems,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-md space-y-4">
|
||||
<h2 className="text-lg font-semibold">Cart</h2>
|
||||
|
||||
{/* Customer Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Customer</h3>
|
||||
<CustomerSelect
|
||||
selectedCustomer={selectedCustomer}
|
||||
onCustomerChange={handleCustomerChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Items</h3>
|
||||
{cartItems.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No items in cart</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Cart items would be rendered here */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Checkout Button */}
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={cartItems.length === 0}
|
||||
className="w-full bg-brand-600 text-white py-3 rounded-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed min-h-12"
|
||||
>
|
||||
Proceed to Payment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component Props Reference
|
||||
*
|
||||
* @interface CustomerSelectProps
|
||||
* @property {POSCustomer | null} selectedCustomer - Currently selected customer (or null for walk-in)
|
||||
* @property {(customer: POSCustomer | null) => void} onCustomerChange - Called when customer selection changes
|
||||
* @property {(customer: POSCustomer) => void} [onAddNewCustomer] - Optional callback when new customer is created
|
||||
*
|
||||
* @interface POSCustomer
|
||||
* @property {number} [id] - Customer ID (optional for walk-in)
|
||||
* @property {string} name - Customer name (required)
|
||||
* @property {string} [email] - Customer email (optional)
|
||||
* @property {string} [phone] - Customer phone (optional)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Features:
|
||||
*
|
||||
* 1. Search Existing Customers
|
||||
* - Search by name, email, or phone
|
||||
* - Debounced search with React Query
|
||||
* - Dropdown shows matching results
|
||||
*
|
||||
* 2. Display Selected Customer
|
||||
* - Shows customer avatar, name, email, phone
|
||||
* - Clear button to deselect
|
||||
*
|
||||
* 3. Add New Customer Inline
|
||||
* - Form with name (required), email, phone
|
||||
* - Validation for required fields
|
||||
* - Automatically selects new customer after creation
|
||||
*
|
||||
* 4. Touch-Friendly Design
|
||||
* - All buttons are min 48px height
|
||||
* - Large tap targets for search results
|
||||
* - Clear visual feedback
|
||||
*
|
||||
* 5. Accessibility
|
||||
* - Proper ARIA labels
|
||||
* - Keyboard navigation support
|
||||
* - Screen reader friendly
|
||||
*/
|
||||
380
frontend/src/pos/components/CustomerSelect.tsx
Normal file
380
frontend/src/pos/components/CustomerSelect.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* CustomerSelect Component
|
||||
*
|
||||
* Touch-friendly customer selection for POS module.
|
||||
* Features:
|
||||
* - Search existing customers by name, email, or phone
|
||||
* - Phone number lookup with formatting
|
||||
* - Display selected customer info with clear option
|
||||
* - Inline add new customer form
|
||||
* - Large touch targets (48px min height)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Search, X, UserPlus, User, Phone } from 'lucide-react';
|
||||
import { useCustomers, useCreateCustomer } from '../../hooks/useCustomers';
|
||||
import { FormInput, Button } from '../../components/ui';
|
||||
import type { POSCustomer } from '../types';
|
||||
|
||||
/**
|
||||
* Format phone number for display
|
||||
*/
|
||||
const formatPhoneDisplay = (phone: string): string => {
|
||||
// Remove all non-digit characters
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
|
||||
// Format as (XXX) XXX-XXXX for 10 digits
|
||||
if (digits.length === 10) {
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
// Format as +1 (XXX) XXX-XXXX for 11 digits starting with 1
|
||||
if (digits.length === 11 && digits[0] === '1') {
|
||||
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
interface CustomerSelectProps {
|
||||
selectedCustomer: POSCustomer | null;
|
||||
onCustomerChange: (customer: POSCustomer | null) => void;
|
||||
onAddNewCustomer?: (customer: POSCustomer) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomerSelect component for POS
|
||||
*/
|
||||
const CustomerSelect: React.FC<CustomerSelectProps> = ({
|
||||
selectedCustomer,
|
||||
onCustomerChange,
|
||||
onAddNewCustomer,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isAddingCustomer, setIsAddingCustomer] = useState(false);
|
||||
const [newCustomerData, setNewCustomerData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch customers based on search query
|
||||
const { data: customers = [], isLoading } = useCustomers(
|
||||
searchQuery.trim().length > 0 ? { search: searchQuery } : undefined
|
||||
);
|
||||
|
||||
const createCustomerMutation = useCreateCustomer();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
/**
|
||||
* Handle search input change
|
||||
*/
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
setIsDropdownOpen(value.length > 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle customer selection from dropdown
|
||||
*/
|
||||
const handleSelectCustomer = (customer: typeof customers[0]) => {
|
||||
const posCustomer: POSCustomer = {
|
||||
id: Number(customer.id),
|
||||
name: customer.name,
|
||||
email: customer.email,
|
||||
phone: customer.phone,
|
||||
};
|
||||
|
||||
onCustomerChange(posCustomer);
|
||||
setSearchQuery('');
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle clear selected customer
|
||||
*/
|
||||
const handleClearCustomer = () => {
|
||||
onCustomerChange(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle open add customer form
|
||||
*/
|
||||
const handleOpenAddForm = () => {
|
||||
setIsAddingCustomer(true);
|
||||
setNewCustomerData({ name: '', email: '', phone: '' });
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle cancel add customer
|
||||
*/
|
||||
const handleCancelAdd = () => {
|
||||
setIsAddingCustomer(false);
|
||||
setNewCustomerData({ name: '', email: '', phone: '' });
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate new customer form
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!newCustomerData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle save new customer
|
||||
*/
|
||||
const handleSaveCustomer = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createCustomerMutation.mutateAsync({
|
||||
name: newCustomerData.name,
|
||||
email: newCustomerData.email || '',
|
||||
phone: newCustomerData.phone || '',
|
||||
});
|
||||
|
||||
const newCustomer: POSCustomer = {
|
||||
id: Number(result.id),
|
||||
name: result.name || newCustomerData.name,
|
||||
email: result.email || newCustomerData.email,
|
||||
phone: result.phone || newCustomerData.phone,
|
||||
};
|
||||
|
||||
onCustomerChange(newCustomer);
|
||||
if (onAddNewCustomer) {
|
||||
onAddNewCustomer(newCustomer);
|
||||
}
|
||||
|
||||
setIsAddingCustomer(false);
|
||||
setNewCustomerData({ name: '', email: '', phone: '' });
|
||||
} catch (error) {
|
||||
setErrors({ submit: 'Failed to create customer. Please try again.' });
|
||||
}
|
||||
};
|
||||
|
||||
// If customer is selected, show customer info
|
||||
if (selectedCustomer && !isAddingCustomer) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-300 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-12 h-12 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 flex-shrink-0">
|
||||
<User className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg text-gray-900 truncate">
|
||||
{selectedCustomer.name}
|
||||
</h3>
|
||||
{selectedCustomer.phone && (
|
||||
<p className="text-sm text-blue-600 font-medium flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
{formatPhoneDisplay(selectedCustomer.phone)}
|
||||
</p>
|
||||
)}
|
||||
{selectedCustomer.email && (
|
||||
<p className="text-sm text-gray-500 truncate">{selectedCustomer.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearCustomer}
|
||||
className="flex-shrink-0"
|
||||
aria-label="Clear customer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add new customer form
|
||||
if (isAddingCustomer) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-300 rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Add New Customer</h3>
|
||||
</div>
|
||||
|
||||
<FormInput
|
||||
label="Name"
|
||||
value={newCustomerData.name}
|
||||
onChange={(e) => setNewCustomerData({ ...newCustomerData, name: e.target.value })}
|
||||
error={errors.name}
|
||||
required
|
||||
placeholder="Customer name"
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={newCustomerData.email}
|
||||
onChange={(e) => setNewCustomerData({ ...newCustomerData, email: e.target.value })}
|
||||
placeholder="customer@example.com (optional)"
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
label="Phone"
|
||||
type="tel"
|
||||
value={newCustomerData.phone}
|
||||
onChange={(e) => setNewCustomerData({ ...newCustomerData, phone: e.target.value })}
|
||||
placeholder="555-1234 (optional)"
|
||||
/>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="text-sm text-red-600 bg-red-50 p-3 rounded-md">{errors.submit}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelAdd}
|
||||
className="flex-1 min-h-12"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveCustomer}
|
||||
isLoading={createCustomerMutation.isPending}
|
||||
className="flex-1 min-h-12"
|
||||
>
|
||||
Save Customer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if search query looks like a phone number
|
||||
const isPhoneSearch = /^\d/.test(searchQuery.replace(/\D/g, ''));
|
||||
|
||||
// Search and add customer UI
|
||||
return (
|
||||
<div className="space-y-3" ref={dropdownRef}>
|
||||
{/* Phone Number Hint */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 bg-blue-50 px-3 py-2 rounded-lg">
|
||||
<Phone className="w-4 h-4 text-blue-600" />
|
||||
<span>Enter phone number to look up existing customer</span>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
{isPhoneSearch ? <Phone className="w-5 h-5" /> : <Search className="w-5 h-5" />}
|
||||
</div>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onFocus={() => searchQuery.length > 0 && setIsDropdownOpen(true)}
|
||||
placeholder="Enter phone number, name, or email..."
|
||||
className="w-full pl-10 pr-4 py-3 min-h-12 border border-gray-300 rounded-lg text-base focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
inputMode={isPhoneSearch ? 'tel' : 'text'}
|
||||
/>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{isDropdownOpen && searchQuery.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-gray-500">Searching...</div>
|
||||
) : customers.length > 0 ? (
|
||||
<div className="py-2">
|
||||
{customers.map((customer) => (
|
||||
<button
|
||||
key={customer.id}
|
||||
onClick={() => handleSelectCustomer(customer)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors min-h-12 flex items-center gap-3"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 flex-shrink-0">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 truncate">{customer.name}</div>
|
||||
{customer.phone && (
|
||||
<div className="text-sm text-blue-600 font-medium flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
{formatPhoneDisplay(customer.phone)}
|
||||
</div>
|
||||
)}
|
||||
{customer.email && (
|
||||
<div className="text-sm text-gray-500 truncate">{customer.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-gray-500 mb-3">No customers found</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Pre-fill phone if search looks like a phone number
|
||||
const digits = searchQuery.replace(/\D/g, '');
|
||||
if (digits.length >= 7) {
|
||||
setNewCustomerData({ name: '', email: '', phone: searchQuery });
|
||||
}
|
||||
setIsAddingCustomer(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
leftIcon={<UserPlus className="w-4 h-4" />}
|
||||
>
|
||||
Add as New Customer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add New Customer Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenAddForm}
|
||||
className="w-full min-h-12"
|
||||
leftIcon={<UserPlus className="w-5 h-5" />}
|
||||
>
|
||||
Add New Customer
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerSelect;
|
||||
320
frontend/src/pos/components/DiscountModal.tsx
Normal file
320
frontend/src/pos/components/DiscountModal.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* DiscountModal Component
|
||||
*
|
||||
* Modal for applying discounts to orders or individual items.
|
||||
* Supports both percentage-based and fixed-amount discounts with
|
||||
* preset buttons for common percentages and custom entry options.
|
||||
*
|
||||
* Features:
|
||||
* - Order-level and item-level discounts
|
||||
* - Preset percentage buttons (10%, 15%, 20%, 25%)
|
||||
* - Custom percentage input (0-100%)
|
||||
* - Custom dollar amount entry with NumPad
|
||||
* - Discount reason tracking
|
||||
* - Real-time discount preview
|
||||
* - Touch-friendly POS interface
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Percent, DollarSign, X } from 'lucide-react';
|
||||
import { Modal } from '../../components/ui/Modal';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { FormInput } from '../../components/ui/FormInput';
|
||||
import NumPad from './NumPad';
|
||||
|
||||
interface DiscountModalProps {
|
||||
/** Is modal open */
|
||||
isOpen: boolean;
|
||||
/** Close modal callback */
|
||||
onClose: () => void;
|
||||
/** Type of discount - order or item level */
|
||||
discountType: 'order' | 'item';
|
||||
/** Item ID for item-level discounts */
|
||||
itemId?: string;
|
||||
/** Item name for display */
|
||||
itemName?: string;
|
||||
/** Callback when discount is applied */
|
||||
onApplyDiscount: (discountCents?: number, discountPercent?: number, reason?: string) => void;
|
||||
/** Current subtotal in cents for percentage calculation */
|
||||
currentSubtotalCents: number;
|
||||
}
|
||||
|
||||
const PRESET_PERCENTAGES = [10, 15, 20, 25];
|
||||
|
||||
export const DiscountModal: React.FC<DiscountModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
discountType,
|
||||
itemId,
|
||||
itemName,
|
||||
onApplyDiscount,
|
||||
currentSubtotalCents,
|
||||
}) => {
|
||||
// State
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
|
||||
const [customPercent, setCustomPercent] = useState<number>(0);
|
||||
const [customAmountCents, setCustomAmountCents] = useState<number>(0);
|
||||
const [reason, setReason] = useState<string>('');
|
||||
|
||||
/**
|
||||
* Reset all inputs
|
||||
*/
|
||||
const handleClear = () => {
|
||||
setSelectedPreset(null);
|
||||
setCustomPercent(0);
|
||||
setCustomAmountCents(0);
|
||||
setReason('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle preset percentage button click
|
||||
*/
|
||||
const handlePresetClick = (percent: number) => {
|
||||
setSelectedPreset(percent);
|
||||
setCustomPercent(0);
|
||||
setCustomAmountCents(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom percentage input
|
||||
*/
|
||||
const handleCustomPercentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value) || 0;
|
||||
// Clamp between 0 and 100
|
||||
const clampedValue = Math.max(0, Math.min(100, value));
|
||||
setCustomPercent(clampedValue);
|
||||
setSelectedPreset(null);
|
||||
setCustomAmountCents(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom amount change from NumPad
|
||||
*/
|
||||
const handleCustomAmountChange = (cents: number) => {
|
||||
// Cap at subtotal amount
|
||||
const cappedCents = Math.min(cents, currentSubtotalCents);
|
||||
setCustomAmountCents(cappedCents);
|
||||
setSelectedPreset(null);
|
||||
setCustomPercent(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the active discount amount in cents
|
||||
*/
|
||||
const getDiscountAmountCents = (): number => {
|
||||
if (customAmountCents > 0) {
|
||||
return customAmountCents;
|
||||
}
|
||||
|
||||
const activePercent = selectedPreset || customPercent;
|
||||
if (activePercent > 0) {
|
||||
return Math.round((currentSubtotalCents * activePercent) / 100);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the active discount percentage
|
||||
*/
|
||||
const getDiscountPercent = (): number | undefined => {
|
||||
if (customAmountCents > 0) {
|
||||
return undefined; // Using fixed amount, not percentage
|
||||
}
|
||||
return selectedPreset || (customPercent > 0 ? customPercent : undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the active discount amount in cents (for fixed amount discounts)
|
||||
*/
|
||||
const getDiscountCents = (): number | undefined => {
|
||||
if (customAmountCents > 0) {
|
||||
return customAmountCents;
|
||||
}
|
||||
return undefined; // Using percentage, not fixed amount
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if discount is valid and can be applied
|
||||
*/
|
||||
const isDiscountValid = (): boolean => {
|
||||
return getDiscountAmountCents() > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply the discount
|
||||
*/
|
||||
const handleApply = () => {
|
||||
if (!isDiscountValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const discountCents = getDiscountCents();
|
||||
const discountPercent = getDiscountPercent();
|
||||
const discountReason = reason.trim() || undefined;
|
||||
|
||||
onApplyDiscount(discountCents, discountPercent, discountReason);
|
||||
onClose();
|
||||
};
|
||||
|
||||
/**
|
||||
* Format cents as currency
|
||||
*/
|
||||
const formatCents = (cents: number): string => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset state when modal closes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
handleClear();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const discountAmountCents = getDiscountAmountCents();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={discountType === 'order' ? 'Order Discount' : 'Item Discount'}
|
||||
size="2xl"
|
||||
closeOnOverlayClick={false}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Item Name (for item-level discounts) */}
|
||||
{discountType === 'item' && itemName && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Discount for:
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{itemName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discount Preview */}
|
||||
{discountAmountCents > 0 && (
|
||||
<div className="bg-gradient-to-r from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-xl p-6 text-center border-2 border-green-500 dark:border-green-600">
|
||||
<div className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
|
||||
Discount Amount
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-green-700 dark:text-green-400">
|
||||
{formatCents(discountAmountCents)}
|
||||
</div>
|
||||
{getDiscountPercent() && (
|
||||
<div className="text-sm text-green-600 dark:text-green-500 mt-2">
|
||||
({getDiscountPercent()}% of {formatCents(currentSubtotalCents)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preset Percentage Buttons */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<Percent className="inline h-4 w-4 mr-1" />
|
||||
Quick Percentages
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{PRESET_PERCENTAGES.map((percent) => (
|
||||
<button
|
||||
key={percent}
|
||||
onClick={() => handlePresetClick(percent)}
|
||||
className={`
|
||||
h-16 rounded-lg border-2 font-semibold text-lg
|
||||
transition-all touch-manipulation select-none
|
||||
${
|
||||
selectedPreset === percent
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-400'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:border-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{percent}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Percentage Input */}
|
||||
<div>
|
||||
<FormInput
|
||||
label="Custom Percent"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={customPercent}
|
||||
onChange={handleCustomPercentChange}
|
||||
placeholder="0"
|
||||
rightIcon={<Percent className="h-5 w-5" />}
|
||||
hint="Enter a custom percentage (0-100)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Amount Entry with NumPad */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<DollarSign className="inline h-4 w-4 mr-1" />
|
||||
Custom Amount
|
||||
</label>
|
||||
<NumPad
|
||||
value={customAmountCents}
|
||||
onChange={handleCustomAmountChange}
|
||||
showCurrency={true}
|
||||
maxCents={currentSubtotalCents}
|
||||
className="max-w-md mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Discount Reason */}
|
||||
<div>
|
||||
<FormInput
|
||||
label="Reason (Optional)"
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g., Manager approval, Employee discount"
|
||||
hint="Enter a reason for this discount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleClear}
|
||||
leftIcon={<X className="h-5 w-5" />}
|
||||
fullWidth
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onClose}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="success"
|
||||
size="lg"
|
||||
onClick={handleApply}
|
||||
disabled={!isDiscountValid()}
|
||||
fullWidth
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscountModal;
|
||||
277
frontend/src/pos/components/GiftCardPaymentPanel.tsx
Normal file
277
frontend/src/pos/components/GiftCardPaymentPanel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* GiftCardPaymentPanel Component
|
||||
*
|
||||
* Panel for processing gift card payments in POS system.
|
||||
*
|
||||
* Features:
|
||||
* - Gift card code input (manual entry or scan)
|
||||
* - Look up button to check balance
|
||||
* - Shows card balance when found
|
||||
* - Amount to redeem input (default: remaining balance or order total)
|
||||
* - Apply button to add gift card payment
|
||||
* - Error handling for invalid/expired cards
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Gift, Search, X } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { FormInput, FormCurrencyInput } from '../../components/ui';
|
||||
import { Alert } from '../../components/ui/Alert';
|
||||
import { useLookupGiftCard } from '../hooks/useGiftCards';
|
||||
import type { GiftCard } from '../types';
|
||||
|
||||
interface GiftCardPaymentPanelProps {
|
||||
/** Amount due in cents */
|
||||
amountDueCents: number;
|
||||
/** Callback when gift card payment is applied */
|
||||
onApply: (payment: {
|
||||
gift_card_code: string;
|
||||
amount_cents: number;
|
||||
gift_card: GiftCard;
|
||||
}) => void;
|
||||
/** Callback to cancel */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const GiftCardPaymentPanel: React.FC<GiftCardPaymentPanelProps> = ({
|
||||
amountDueCents,
|
||||
onApply,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [code, setCode] = useState('');
|
||||
const [amountCents, setAmountCents] = useState(0);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
mutate: lookupGiftCard,
|
||||
isPending: isLookingUp,
|
||||
isSuccess: lookupSuccess,
|
||||
isError: lookupError,
|
||||
data: giftCard,
|
||||
error: lookupErrorMessage,
|
||||
reset: resetLookup,
|
||||
} = useLookupGiftCard();
|
||||
|
||||
/**
|
||||
* Format cents as currency string
|
||||
*/
|
||||
const formatCents = (cents: number): string => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set default amount when gift card is found
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (giftCard && giftCard.status === 'active' && giftCard.current_balance_cents > 0) {
|
||||
// Default to the lesser of: amount due or gift card balance
|
||||
const defaultAmount = Math.min(amountDueCents, giftCard.current_balance_cents);
|
||||
setAmountCents(defaultAmount);
|
||||
}
|
||||
}, [giftCard, amountDueCents]);
|
||||
|
||||
/**
|
||||
* Reset form when code changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (code !== giftCard?.code) {
|
||||
resetLookup();
|
||||
setValidationError(null);
|
||||
}
|
||||
}, [code, giftCard?.code, resetLookup]);
|
||||
|
||||
/**
|
||||
* Handle lookup button click
|
||||
*/
|
||||
const handleLookup = () => {
|
||||
if (!code.trim()) {
|
||||
setValidationError('Please enter a gift card code');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
lookupGiftCard(code.trim());
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle apply button click
|
||||
*/
|
||||
const handleApply = () => {
|
||||
if (!giftCard) {
|
||||
setValidationError('Please lookup a gift card first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (giftCard.status !== 'active') {
|
||||
setValidationError(`Gift card is ${giftCard.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (giftCard.current_balance_cents === 0) {
|
||||
setValidationError('Gift card has no balance remaining');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountCents <= 0) {
|
||||
setValidationError('Please enter an amount to redeem');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountCents > giftCard.current_balance_cents) {
|
||||
setValidationError('Amount exceeds gift card balance');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountCents > amountDueCents) {
|
||||
setValidationError('Amount exceeds amount due');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
onApply({
|
||||
gift_card_code: giftCard.code,
|
||||
amount_cents: amountCents,
|
||||
gift_card: giftCard,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if card is usable
|
||||
*/
|
||||
const isCardUsable = giftCard &&
|
||||
giftCard.status === 'active' &&
|
||||
giftCard.current_balance_cents > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-100 dark:bg-purple-900/20 rounded-full mb-4">
|
||||
<Gift className="h-8 w-8 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Gift Card Payment
|
||||
</h3>
|
||||
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{formatCents(amountDueCents)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Amount Due</div>
|
||||
</div>
|
||||
|
||||
{/* Gift Card Code Input */}
|
||||
<div>
|
||||
<FormInput
|
||||
label="Gift Card Code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="Enter gift card code"
|
||||
className="font-mono text-lg"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && code.trim()) {
|
||||
handleLookup();
|
||||
}
|
||||
}}
|
||||
disabled={isLookingUp}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleLookup}
|
||||
isLoading={isLookingUp}
|
||||
disabled={!code.trim() || isLookingUp}
|
||||
leftIcon={<Search className="h-5 w-5" />}
|
||||
fullWidth
|
||||
className="mt-2"
|
||||
>
|
||||
Lookup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Messages */}
|
||||
{lookupError && (
|
||||
<Alert
|
||||
variant="error"
|
||||
message={(lookupErrorMessage as Error)?.message || 'Gift card not found'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{validationError && (
|
||||
<Alert variant="error" message={validationError} />
|
||||
)}
|
||||
|
||||
{/* Gift Card Info - Expired */}
|
||||
{giftCard && giftCard.status === 'expired' && (
|
||||
<Alert
|
||||
variant="error"
|
||||
message={`This gift card expired on ${
|
||||
giftCard.expires_at
|
||||
? new Date(giftCard.expires_at).toLocaleDateString()
|
||||
: 'an unknown date'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gift Card Info - Cancelled */}
|
||||
{giftCard && giftCard.status === 'cancelled' && (
|
||||
<Alert variant="error" message="This gift card has been cancelled" />
|
||||
)}
|
||||
|
||||
{/* Gift Card Info - Depleted */}
|
||||
{giftCard && giftCard.status === 'depleted' && (
|
||||
<Alert variant="warning" message="This gift card has no balance remaining" />
|
||||
)}
|
||||
|
||||
{/* Gift Card Info - Active with Balance */}
|
||||
{isCardUsable && (
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-6 border-2 border-purple-300 dark:border-purple-600">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Card Code</div>
|
||||
<div className="text-lg font-mono font-semibold text-gray-900 dark:text-white">
|
||||
{giftCard.code}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Available Balance</div>
|
||||
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{formatCents(giftCard.current_balance_cents)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount to Redeem */}
|
||||
<FormCurrencyInput
|
||||
label="Amount to Redeem"
|
||||
value={amountCents}
|
||||
onChange={setAmountCents}
|
||||
hint={`Max: ${formatCents(Math.min(amountDueCents, giftCard.current_balance_cents))}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onCancel}
|
||||
leftIcon={<X className="h-5 w-5" />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isCardUsable && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleApply}
|
||||
disabled={!amountCents || amountCents <= 0}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GiftCardPaymentPanel;
|
||||
356
frontend/src/pos/components/GiftCardPurchaseModal.tsx
Normal file
356
frontend/src/pos/components/GiftCardPurchaseModal.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* GiftCardPurchaseModal Component
|
||||
*
|
||||
* Modal for purchasing/creating new gift cards in POS system.
|
||||
*
|
||||
* Features:
|
||||
* - Amount selection (preset amounts: $25, $50, $75, $100, custom)
|
||||
* - Optional recipient name and email
|
||||
* - Generate gift card on purchase
|
||||
* - Display generated code
|
||||
* - Option to print gift card
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Gift, Printer, X, Check } from 'lucide-react';
|
||||
import { Modal } from '../../components/ui/Modal';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { FormInput, FormCurrencyInput } from '../../components/ui';
|
||||
import { Alert } from '../../components/ui/Alert';
|
||||
import { useCreateGiftCard } from '../hooks/useGiftCards';
|
||||
import type { GiftCard } from '../types';
|
||||
|
||||
interface GiftCardPurchaseModalProps {
|
||||
/** Is modal open */
|
||||
isOpen: boolean;
|
||||
/** Close modal callback */
|
||||
onClose: () => void;
|
||||
/** Success callback with created gift card */
|
||||
onSuccess?: (giftCard: GiftCard) => void;
|
||||
}
|
||||
|
||||
// Preset amounts in cents
|
||||
const PRESET_AMOUNTS = [
|
||||
{ label: '$25', value: 2500 },
|
||||
{ label: '$50', value: 5000 },
|
||||
{ label: '$75', value: 7500 },
|
||||
{ label: '$100', value: 10000 },
|
||||
] as const;
|
||||
|
||||
const GiftCardPurchaseModal: React.FC<GiftCardPurchaseModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
|
||||
const [isCustomAmount, setIsCustomAmount] = useState(false);
|
||||
const [customAmount, setCustomAmount] = useState(0);
|
||||
const [recipientName, setRecipientName] = useState('');
|
||||
const [recipientEmail, setRecipientEmail] = useState('');
|
||||
|
||||
const {
|
||||
mutate: createGiftCard,
|
||||
isPending,
|
||||
isSuccess,
|
||||
isError,
|
||||
data: createdGiftCard,
|
||||
error,
|
||||
reset: resetMutation,
|
||||
} = useCreateGiftCard();
|
||||
|
||||
/**
|
||||
* Reset form when modal opens/closes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedAmount(null);
|
||||
setIsCustomAmount(false);
|
||||
setCustomAmount(0);
|
||||
setRecipientName('');
|
||||
setRecipientEmail('');
|
||||
resetMutation();
|
||||
}
|
||||
}, [isOpen, resetMutation]);
|
||||
|
||||
/**
|
||||
* Call onSuccess when gift card is created
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isSuccess && createdGiftCard && onSuccess) {
|
||||
onSuccess(createdGiftCard);
|
||||
}
|
||||
}, [isSuccess, createdGiftCard, onSuccess]);
|
||||
|
||||
/**
|
||||
* Get final amount in cents
|
||||
*/
|
||||
const getFinalAmount = (): number => {
|
||||
if (isCustomAmount) {
|
||||
return customAmount;
|
||||
}
|
||||
return selectedAmount || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format cents as currency string
|
||||
*/
|
||||
const formatCents = (cents: number): string => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle preset amount selection
|
||||
*/
|
||||
const handlePresetAmount = (amount: number) => {
|
||||
setSelectedAmount(amount);
|
||||
setIsCustomAmount(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom amount selection
|
||||
*/
|
||||
const handleCustomAmountClick = () => {
|
||||
setIsCustomAmount(true);
|
||||
setSelectedAmount(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle purchase button click
|
||||
*/
|
||||
const handlePurchase = () => {
|
||||
const finalAmount = getFinalAmount();
|
||||
|
||||
if (finalAmount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
createGiftCard({
|
||||
initial_balance_cents: finalAmount,
|
||||
recipient_name: recipientName.trim() || undefined,
|
||||
recipient_email: recipientEmail.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle print gift card
|
||||
*/
|
||||
const handlePrint = () => {
|
||||
// TODO: Implement print functionality
|
||||
console.log('Print gift card:', createdGiftCard);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle close after success
|
||||
*/
|
||||
const handleCloseAfterSuccess = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
/**
|
||||
* Render success state
|
||||
*/
|
||||
if (isSuccess && createdGiftCard) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleCloseAfterSuccess}
|
||||
title="Gift Card Created"
|
||||
size="lg"
|
||||
showCloseButton={true}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Success Message */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-6 text-center border-2 border-green-500 dark:border-green-600">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 dark:bg-green-900/40 rounded-full mb-4">
|
||||
<Check className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Gift Card Created Successfully!
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Present this code at checkout
|
||||
</div>
|
||||
|
||||
{/* Gift Card Code */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">Gift Card Code</div>
|
||||
<div className="text-3xl font-mono font-bold text-brand-600 dark:text-brand-400 mb-4">
|
||||
{createdGiftCard.code}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCents(createdGiftCard.initial_balance_cents)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipient Info */}
|
||||
{(createdGiftCard.recipient_name || createdGiftCard.recipient_email) && (
|
||||
<div className="mt-4 text-left bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Recipient
|
||||
</div>
|
||||
{createdGiftCard.recipient_name && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{createdGiftCard.recipient_name}
|
||||
</div>
|
||||
)}
|
||||
{createdGiftCard.recipient_email && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{createdGiftCard.recipient_email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handlePrint}
|
||||
leftIcon={<Printer className="h-5 w-5" />}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleCloseAfterSuccess}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render purchase form
|
||||
*/
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Purchase Gift Card"
|
||||
size="lg"
|
||||
showCloseButton={true}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{isError && (
|
||||
<Alert
|
||||
variant="error"
|
||||
message={(error as Error)?.message || 'Failed to create gift card'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Amount Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Select Amount
|
||||
</label>
|
||||
|
||||
{/* Preset Amounts */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||
{PRESET_AMOUNTS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => handlePresetAmount(preset.value)}
|
||||
className={`
|
||||
h-20 rounded-lg border-2
|
||||
${selectedAmount === preset.value
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700'
|
||||
}
|
||||
hover:border-brand-400 dark:hover:border-brand-500
|
||||
transition-all touch-manipulation
|
||||
`}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{preset.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom Amount Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomAmountClick}
|
||||
className={`
|
||||
h-20 rounded-lg border-2
|
||||
${isCustomAmount
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700'
|
||||
}
|
||||
hover:border-brand-400 dark:hover:border-brand-500
|
||||
transition-all touch-manipulation
|
||||
`}
|
||||
>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
Custom
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom Amount Input */}
|
||||
{isCustomAmount && (
|
||||
<FormCurrencyInput
|
||||
label="Custom Amount"
|
||||
value={customAmount}
|
||||
onChange={setCustomAmount}
|
||||
placeholder="$0.00"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipient Information (Optional) */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Recipient Information (Optional)
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FormInput
|
||||
label=""
|
||||
value={recipientName}
|
||||
onChange={(e) => setRecipientName(e.target.value)}
|
||||
placeholder="Recipient name"
|
||||
/>
|
||||
<FormInput
|
||||
label=""
|
||||
value={recipientEmail}
|
||||
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||
placeholder="Recipient email"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handlePurchase}
|
||||
disabled={getFinalAmount() <= 0 || isPending}
|
||||
isLoading={isPending}
|
||||
>
|
||||
Purchase
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GiftCardPurchaseModal;
|
||||
271
frontend/src/pos/components/InventoryTransferModal.tsx
Normal file
271
frontend/src/pos/components/InventoryTransferModal.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Inventory Transfer Modal
|
||||
*
|
||||
* Component for transferring inventory between locations.
|
||||
* Validates quantity against available stock and prevents transfers to the same location.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Modal, FormSelect, FormInput, FormTextarea, ErrorMessage, SuccessMessage } from '../../components/ui';
|
||||
import { useProducts } from '../hooks/usePOSProducts';
|
||||
import { useLocations } from '../../hooks/useLocations';
|
||||
import { useLocationInventory, useTransferInventory } from '../hooks/useInventory';
|
||||
|
||||
interface InventoryTransferModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
productId?: number;
|
||||
}
|
||||
|
||||
export default function InventoryTransferModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
productId: initialProductId,
|
||||
}: InventoryTransferModalProps) {
|
||||
// Form state
|
||||
const [productId, setProductId] = useState<number | null>(initialProductId ?? null);
|
||||
const [fromLocationId, setFromLocationId] = useState<number | null>(null);
|
||||
const [toLocationId, setToLocationId] = useState<number | null>(null);
|
||||
const [quantity, setQuantity] = useState<number>(1);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// Query hooks
|
||||
const { data: products, isLoading: loadingProducts } = useProducts({ status: 'active' });
|
||||
const { data: locations, isLoading: loadingLocations } = useLocations();
|
||||
const { data: inventory } = useLocationInventory(fromLocationId ?? undefined);
|
||||
const transferMutation = useTransferInventory();
|
||||
|
||||
// Find available stock for selected product at from location
|
||||
const availableStock = useMemo(() => {
|
||||
if (!productId || !fromLocationId || !inventory) return null;
|
||||
|
||||
const inventoryRecord = inventory.find((inv) => inv.product === productId);
|
||||
return inventoryRecord?.quantity ?? 0;
|
||||
}, [productId, fromLocationId, inventory]);
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!isOpen | ||||