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>
This commit is contained in:
367
frontend/src/components/ParticipantSelector.tsx
Normal file
367
frontend/src/components/ParticipantSelector.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user