- 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>
368 lines
13 KiB
TypeScript
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;
|