Files
smoothschedule/frontend/src/components/ParticipantSelector.tsx
poduck 3aa7199503 Add Activepieces integration for workflow automation
- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 22:59:37 -05:00

368 lines
13 KiB
TypeScript

/**
* ParticipantSelector Component
*
* A reusable component for selecting participants (staff, customers, observers)
* for appointments. Supports both linked users and external email participants.
*/
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Plus, Search, User, UserPlus, Mail, Users } from 'lucide-react';
import { Participant, ParticipantInput, ParticipantRole } from '../types';
import { useStaff, StaffMember } from '../hooks/useStaff';
import { useCustomers } from '../hooks/useCustomers';
import { Customer } from '../types';
interface ParticipantSelectorProps {
value: ParticipantInput[];
onChange: (participants: ParticipantInput[]) => void;
allowedRoles?: ParticipantRole[];
allowExternalEmail?: boolean;
existingParticipants?: Participant[];
}
// Role badge colors
const roleBadgeColors: Record<ParticipantRole, string> = {
STAFF: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
CUSTOMER: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
OBSERVER: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
RESOURCE: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300',
};
export const ParticipantSelector: React.FC<ParticipantSelectorProps> = ({
value,
onChange,
allowedRoles = ['STAFF', 'CUSTOMER', 'OBSERVER'],
allowExternalEmail = true,
existingParticipants = [],
}) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [showExternalInput, setShowExternalInput] = useState(false);
const [externalEmail, setExternalEmail] = useState('');
const [externalName, setExternalName] = useState('');
const [externalRole, setExternalRole] = useState<ParticipantRole>('OBSERVER');
const [externalEmailError, setExternalEmailError] = useState('');
// Fetch staff and customers for search
const { data: staff = [] } = useStaff({ search: searchQuery });
const { data: customers = [] } = useCustomers({ search: searchQuery });
// Get display name for a participant
const getDisplayName = useCallback((p: ParticipantInput): string => {
if (p.externalName) return p.externalName;
if (p.externalEmail) return p.externalEmail;
// Find from staff or customers
if (p.userId) {
const staffMember = staff.find(s => s.id === String(p.userId));
if (staffMember) return staffMember.name;
const customer = customers.find(c => c.id === String(p.userId));
if (customer) return customer.name;
}
return t('participants.unknownParticipant', 'Unknown');
}, [staff, customers, t]);
// Filter search results
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return [];
const results: Array<{
id: string;
name: string;
email: string;
type: 'staff' | 'customer';
role: ParticipantRole;
}> = [];
// Add staff members if STAFF role is allowed
if (allowedRoles.includes('STAFF')) {
staff.forEach(s => {
// Skip if already added
const alreadyAdded = value.some(p => p.userId === parseInt(s.id));
if (!alreadyAdded) {
results.push({
id: s.id,
name: s.name,
email: s.email,
type: 'staff',
role: 'STAFF',
});
}
});
}
// Add customers if CUSTOMER role is allowed
if (allowedRoles.includes('CUSTOMER')) {
customers.forEach(c => {
// Skip if already added
const alreadyAdded = value.some(p => p.userId === parseInt(c.id));
if (!alreadyAdded) {
results.push({
id: c.id,
name: c.name,
email: c.email,
type: 'customer',
role: 'CUSTOMER',
});
}
});
}
return results.slice(0, 10); // Limit results
}, [searchQuery, staff, customers, value, allowedRoles]);
// Add a user as participant
const handleAddUser = useCallback((userId: string, role: ParticipantRole) => {
const newParticipant: ParticipantInput = {
role,
userId: parseInt(userId),
};
onChange([...value, newParticipant]);
setSearchQuery('');
setIsDropdownOpen(false);
}, [value, onChange]);
// Add external email participant
const handleAddExternal = useCallback(() => {
// Validate email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!externalEmail.trim()) {
setExternalEmailError(t('participants.emailRequired'));
return;
}
if (!emailRegex.test(externalEmail)) {
setExternalEmailError(t('participants.invalidEmail'));
return;
}
// Check if already added
const alreadyAdded = value.some(p => p.externalEmail === externalEmail);
if (alreadyAdded) {
setExternalEmailError(t('participants.alreadyAdded', 'This email is already added'));
return;
}
const newParticipant: ParticipantInput = {
role: externalRole,
externalEmail: externalEmail.trim(),
externalName: externalName.trim(),
};
onChange([...value, newParticipant]);
setExternalEmail('');
setExternalName('');
setExternalEmailError('');
setShowExternalInput(false);
}, [externalEmail, externalName, externalRole, value, onChange, t]);
// Remove a participant
const handleRemove = useCallback((index: number) => {
const newValue = [...value];
newValue.splice(index, 1);
onChange(newValue);
}, [value, onChange]);
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('participants.title')}
</label>
{allowExternalEmail && (
<button
type="button"
onClick={() => setShowExternalInput(!showExternalInput)}
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 flex items-center gap-1"
>
<Mail className="w-4 h-4" />
{t('participants.addExternalEmail')}
</button>
)}
</div>
{/* Selected participants */}
<div className="flex flex-wrap gap-2">
{value.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('participants.noParticipants')}
</p>
) : (
value.map((participant, index) => (
<div
key={index}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm ${
roleBadgeColors[participant.role]
}`}
>
{participant.externalEmail ? (
<Mail className="w-3.5 h-3.5" />
) : (
<User className="w-3.5 h-3.5" />
)}
<span>
{getDisplayName(participant)}
{participant.externalEmail && (
<span className="text-xs opacity-75 ml-1">
({t(`participants.roles.${participant.role}`)})
</span>
)}
</span>
<button
type="button"
onClick={() => handleRemove(index)}
className="hover:bg-white/30 rounded-full p-0.5"
title={t('participants.removeParticipant')}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* External email input */}
{showExternalInput && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-3 border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{t('participants.email')} *
</label>
<input
type="email"
value={externalEmail}
onChange={(e) => {
setExternalEmail(e.target.value);
setExternalEmailError('');
}}
placeholder="guest@example.com"
className="w-full px-3 py-2 text-sm border rounded-md dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
{externalEmailError && (
<p className="text-xs text-red-500 mt-1">{externalEmailError}</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{t('participants.name')}
</label>
<input
type="text"
value={externalName}
onChange={(e) => setExternalName(e.target.value)}
placeholder="Guest Name"
className="w-full px-3 py-2 text-sm border rounded-md dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="flex items-center justify-between">
<select
value={externalRole}
onChange={(e) => setExternalRole(e.target.value as ParticipantRole)}
className="px-3 py-2 text-sm border rounded-md dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{allowedRoles.map(role => (
<option key={role} value={role}>
{t(`participants.roles.${role}`)}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setShowExternalInput(false);
setExternalEmail('');
setExternalName('');
setExternalEmailError('');
}}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={handleAddExternal}
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded-md hover:bg-brand-700"
>
{t('participants.addParticipant')}
</button>
</div>
</div>
</div>
)}
{/* Search input */}
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsDropdownOpen(true);
}}
onFocus={() => setIsDropdownOpen(true)}
placeholder={t('participants.searchPlaceholder')}
className="w-full pl-10 pr-4 py-2 text-sm border rounded-md dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
{/* Search results dropdown */}
{isDropdownOpen && searchQuery.trim() && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{searchResults.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{t('common.noResults', 'No results found')}
</div>
) : (
searchResults.map((result) => (
<button
key={`${result.type}-${result.id}`}
type="button"
onClick={() => handleAddUser(result.id, result.role)}
className="w-full px-4 py-2 flex items-center gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
>
<div className="flex-shrink-0">
{result.type === 'staff' ? (
<Users className="w-5 h-5 text-blue-500" />
) : (
<User className="w-5 h-5 text-green-500" />
)}
</div>
<div className="flex-grow min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{result.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{result.email}
</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${roleBadgeColors[result.role]}`}>
{t(`participants.roles.${result.role}`)}
</span>
</button>
))
)}
</div>
)}
</div>
{/* Click outside to close dropdown */}
{isDropdownOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsDropdownOpen(false)}
/>
)}
</div>
);
};
export default ParticipantSelector;