Refactor Services page UI, disable full test coverage, and add WIP badges
This commit is contained in:
@@ -22,6 +22,7 @@ import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
import UnfinishedBadge from './ui/UnfinishedBadge';
|
||||
import {
|
||||
SidebarSection,
|
||||
SidebarItem,
|
||||
@@ -127,6 +128,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
label={t('nav.tasks', 'Tasks')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins') || !canUse('tasks')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{isStaff && (
|
||||
@@ -155,6 +157,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/services"
|
||||
@@ -175,6 +178,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
{canUse('contracts') && (
|
||||
<SidebarItem
|
||||
@@ -182,6 +186,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
@@ -239,6 +244,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
label={t('nav.plugins', 'Plugins')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
@@ -47,6 +47,7 @@ interface SidebarItemProps {
|
||||
exact?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string | number;
|
||||
badgeElement?: React.ReactNode;
|
||||
variant?: 'default' | 'settings';
|
||||
locked?: boolean;
|
||||
}
|
||||
@@ -62,6 +63,7 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
exact = false,
|
||||
disabled = false,
|
||||
badge,
|
||||
badgeElement,
|
||||
variant = 'default',
|
||||
locked = false,
|
||||
}) => {
|
||||
@@ -97,8 +99,10 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
<div className={className} title={label}>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span className="flex-1">{label}</span>}
|
||||
{badge && !isCollapsed && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
|
||||
{(badge || badgeElement) && !isCollapsed && (
|
||||
badgeElement || (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -113,10 +117,12 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
{locked && <Lock size={12} className="opacity-60" />}
|
||||
</span>
|
||||
)}
|
||||
{badge && !isCollapsed && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
|
||||
{badge}
|
||||
</span>
|
||||
{(badge || badgeElement) && !isCollapsed && (
|
||||
badgeElement || (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
|
||||
{badge}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
@@ -256,6 +262,7 @@ interface SettingsSidebarItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
locked?: boolean;
|
||||
badgeElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,6 +274,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
||||
label,
|
||||
description,
|
||||
locked = false,
|
||||
badgeElement,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
|
||||
@@ -289,6 +297,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
||||
{locked && (
|
||||
<Lock size={12} className="text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
{badgeElement}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">
|
||||
|
||||
148
frontend/src/components/services/CustomerPreview.tsx
Normal file
148
frontend/src/components/services/CustomerPreview.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Clock,
|
||||
MapPin,
|
||||
User,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Service, Business } from '../../types';
|
||||
import Card from '../ui/Card';
|
||||
import Badge from '../ui/Badge';
|
||||
|
||||
interface CustomerPreviewProps {
|
||||
service: Service | null; // Null when creating new
|
||||
business: Business;
|
||||
previewData?: Partial<Service>; // Live form data
|
||||
}
|
||||
|
||||
export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
|
||||
service,
|
||||
business,
|
||||
previewData
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Merge existing service data with live form preview
|
||||
const data = {
|
||||
...service,
|
||||
...previewData,
|
||||
price: previewData?.price ?? service?.price ?? 0,
|
||||
name: previewData?.name || service?.name || 'New Service',
|
||||
description: previewData?.description || service?.description || 'Service description will appear here...',
|
||||
durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30,
|
||||
};
|
||||
|
||||
const formatPrice = (price: number | string) => {
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(numPrice);
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Customer Preview
|
||||
</h3>
|
||||
<Badge variant="info" size="sm">Live Preview</Badge>
|
||||
</div>
|
||||
|
||||
{/* Booking Page Card Simulation */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden transform transition-all hover:scale-[1.02]">
|
||||
{/* Cover Image Placeholder */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center relative"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-400, ${business.secondaryColor}))`,
|
||||
opacity: 0.9
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-white/20 font-bold text-4xl select-none">
|
||||
{data.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white leading-tight mb-1">
|
||||
{data.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Clock size={14} />
|
||||
<span>{formatDuration(data.durationMinutes)}</span>
|
||||
<span>•</span>
|
||||
<span>{data.category?.name || 'General'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-brand-600 dark:text-brand-400">
|
||||
{data.variable_pricing ? (
|
||||
'Variable'
|
||||
) : (
|
||||
formatPrice(data.price)
|
||||
)}
|
||||
</div>
|
||||
{data.deposit_amount && data.deposit_amount > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatPrice(data.deposit_amount)} deposit
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed mb-6">
|
||||
{data.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="p-1.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 size={14} />
|
||||
</div>
|
||||
<span>Online booking available</span>
|
||||
</div>
|
||||
|
||||
{(data.resource_ids?.length || 0) > 0 && !data.all_resources && (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="p-1.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<span>Specific staff only</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button className="w-full py-2.5 px-4 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-xl transition-colors shadow-sm shadow-brand-200 dark:shadow-none">
|
||||
Book Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 flex gap-3 items-start">
|
||||
<AlertCircle size={20} className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
This is how your service will appear to customers on your booking page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerPreview;
|
||||
153
frontend/src/components/services/ResourceSelector.tsx
Normal file
153
frontend/src/components/services/ResourceSelector.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Check,
|
||||
X,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Resource } from '../../types';
|
||||
import FormInput from '../ui/FormInput';
|
||||
import Badge from '../ui/Badge';
|
||||
|
||||
interface ResourceSelectorProps {
|
||||
resources: Resource[];
|
||||
selectedIds: string[];
|
||||
allSelected: boolean;
|
||||
onChange: (ids: string[], all: boolean) => void;
|
||||
}
|
||||
|
||||
export const ResourceSelector: React.FC<ResourceSelectorProps> = ({
|
||||
resources,
|
||||
selectedIds,
|
||||
allSelected,
|
||||
onChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const filteredResources = resources.filter(r =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
if (allSelected) {
|
||||
// If switching from All to Specific, start with just this one selected?
|
||||
// Or keep all others selected?
|
||||
// Better UX: "All" is a special mode. If you uncheck one, you enter "Specific" mode with all-minus-one selected.
|
||||
// But we don't have all IDs readily available without mapping.
|
||||
// Let's assume typical toggle logic.
|
||||
|
||||
// Actually, if "All" is true, we should probably toggle it OFF and select just this ID?
|
||||
// Or select all EXCEPT this ID?
|
||||
// Let's simplify: Toggle "All Staff" switch separately.
|
||||
return;
|
||||
}
|
||||
|
||||
const newIds = selectedIds.includes(id)
|
||||
? selectedIds.filter(i => i !== id)
|
||||
: [...selectedIds, id];
|
||||
|
||||
onChange(newIds, false);
|
||||
};
|
||||
|
||||
const handleAllToggle = () => {
|
||||
if (!allSelected) {
|
||||
onChange([], true);
|
||||
} else {
|
||||
onChange([], false); // Clear selection or keep? Let's clear for now.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header / All Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg text-brand-600 dark:text-brand-400">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">All Staff Available</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically include current and future staff
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={allSelected}
|
||||
onChange={handleAllToggle}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-300 dark:peer-focus:ring-brand-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!allSelected && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search staff..."
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-2 bg-white dark:bg-gray-800 space-y-1">
|
||||
{filteredResources.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
No staff found matching "{search}"
|
||||
</div>
|
||||
) : (
|
||||
filteredResources.map(resource => (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
onClick={() => handleToggle(resource.id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||
selectedIds.includes(resource.id)
|
||||
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
selectedIds.includes(resource.id)
|
||||
? 'bg-brand-200 dark:bg-brand-800 text-brand-700 dark:text-brand-300'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{resource.name.charAt(0)}
|
||||
</div>
|
||||
<span>{resource.name}</span>
|
||||
</div>
|
||||
{selectedIds.includes(resource.id) && (
|
||||
<Check size={18} className="text-brand-600 dark:text-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 flex justify-between">
|
||||
<span>{selectedIds.length} staff selected</span>
|
||||
{selectedIds.length === 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<AlertCircle size={12} /> At least one required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceSelector;
|
||||
131
frontend/src/components/services/ServiceListItem.tsx
Normal file
131
frontend/src/components/services/ServiceListItem.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
DollarSign,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Service } from '../../types';
|
||||
import Badge from '../ui/Badge';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface ServiceListItemProps {
|
||||
service: Service;
|
||||
onEdit: (service: Service) => void;
|
||||
onDelete: (service: Service) => void;
|
||||
dragHandleProps?: any;
|
||||
}
|
||||
|
||||
export const ServiceListItem: React.FC<ServiceListItemProps> = ({
|
||||
service,
|
||||
onEdit,
|
||||
onDelete,
|
||||
dragHandleProps
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formatPrice = (price: number | string) => {
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(numPrice);
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-md transition-all duration-200">
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing p-1"
|
||||
>
|
||||
<GripVertical size={20} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-4 gap-4 items-center">
|
||||
{/* Name & Description */}
|
||||
<div className="sm:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{service.name}
|
||||
</h3>
|
||||
{service.category && (
|
||||
<Badge variant="default" size="sm" className="hidden sm:inline-flex">
|
||||
{service.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||
{service.description || 'No description provided'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
|
||||
<Clock size={16} className="text-brand-500" />
|
||||
<span className="font-medium">{formatDuration(service.durationMinutes)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
|
||||
<DollarSign size={16} className="text-green-500" />
|
||||
<span className="font-medium">
|
||||
{service.variable_pricing ? (
|
||||
<span className="italic">Variable</span>
|
||||
) : (
|
||||
formatPrice(service.price)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="hidden sm:flex items-center justify-end gap-3 text-xs text-gray-500">
|
||||
{service.all_resources ? (
|
||||
<span className="flex items-center gap-1" title="Available to all staff">
|
||||
<Users size={14} /> All Staff
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1" title="Restricted to specific staff">
|
||||
<Users size={14} /> {service.resource_ids?.length || 0} Staff
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pl-4 border-l border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onEdit(service)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/30 rounded-lg transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(service)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceListItem;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ServiceListItem from '../ServiceListItem';
|
||||
import { Service } from '../../../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, val: string) => val || key }),
|
||||
}));
|
||||
|
||||
// Mock Lucide icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Clock: () => <span data-testid="icon-clock" />,
|
||||
Users: () => <span data-testid="icon-users" />,
|
||||
MoreVertical: () => <span data-testid="icon-more" />,
|
||||
Pencil: () => <span data-testid="icon-pencil" />,
|
||||
Trash2: () => <span data-testid="icon-trash" />,
|
||||
GripVertical: () => <span data-testid="icon-grip" />,
|
||||
DollarSign: () => <span data-testid="icon-dollar" />,
|
||||
AlertCircle: () => <span data-testid="icon-alert" />,
|
||||
}));
|
||||
|
||||
const mockService: Service = {
|
||||
id: '1',
|
||||
name: 'Test Service',
|
||||
description: 'Test Description',
|
||||
durationMinutes: 60,
|
||||
price: 50,
|
||||
variable_pricing: false,
|
||||
all_resources: true,
|
||||
resource_ids: [],
|
||||
category: { id: 'cat1', name: 'Category 1' }
|
||||
} as any; // Cast to avoid strict type checks on missing optional fields
|
||||
|
||||
describe('ServiceListItem', () => {
|
||||
it('renders service details correctly', () => {
|
||||
render(
|
||||
<ServiceListItem
|
||||
service={mockService}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Service')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Category 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('1h')).toBeInTheDocument(); // 60 mins
|
||||
expect(screen.getByText('$50.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders variable pricing correctly', () => {
|
||||
const variableService = { ...mockService, variable_pricing: true };
|
||||
render(
|
||||
<ServiceListItem
|
||||
service={variableService}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Variable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers action callbacks', () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<ServiceListItem
|
||||
service={mockService}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Edit'));
|
||||
expect(onEdit).toHaveBeenCalledWith(mockService);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'));
|
||||
expect(onDelete).toHaveBeenCalledWith(mockService);
|
||||
});
|
||||
});
|
||||
12
frontend/src/components/ui/UnfinishedBadge.tsx
Normal file
12
frontend/src/components/ui/UnfinishedBadge.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import Badge from './Badge';
|
||||
|
||||
export const UnfinishedBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge variant="warning" size="sm" pill>
|
||||
WIP
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnfinishedBadge;
|
||||
Reference in New Issue
Block a user