import React, { useMemo, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import PromptModal from '../../../components/PromptModal';
import FileBrowser from '../../../components/FileBrowser';
import type { FileBrowserApi } from '../../../components/FileBrowser';
import { skillApi } from '../../../services/api';
import { fetchJson } from '../utils/fetchJson';
import { useAuthStore } from '../../../stores';
import { buildCompanyRegions, CompanyRegion } from '../../../utils/companyRegions';
import { IconCheck } from '@tabler/icons-react';
import { useToast } from '../../../components/Toast/ToastProvider';
export default function SkillsTab() {
const { t } = useTranslation();
const [refreshKey, setRefreshKey] = useState(0);
const [showClawhubModal, setShowClawhubModal] = useState(false);
const [showUrlModal, setShowUrlModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [searching, setSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [installing, setInstalling] = useState<string | null>(null);
const [urlInput, setUrlInput] = useState('');
const [urlPreview, setUrlPreview] = useState<any | null>(null);
const [urlPreviewing, setUrlPreviewing] = useState(false);
const [urlImporting, setUrlImporting] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [tokenStatus, setTokenStatus] = useState<{ configured: boolean; source: string; masked: string; clawhub_configured?: boolean; clawhub_masked?: string } | null>(null);
const [savingToken, setSavingToken] = useState(false);
const [clawhubKeyInput, setClawhubKeyInput] = useState('');
const [savingClawhubKey, setSavingClawhubKey] = useState(false);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 4000);
};
const adapter: FileBrowserApi = useMemo(() => ({
list: (path: string) => skillApi.browse.list(path),
read: (path: string) => skillApi.browse.read(path),
write: (path: string, content: string) => skillApi.browse.write(path, content),
delete: (path: string) => skillApi.browse.delete(path),
}), []);
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setSearching(true);
setSearchResults([]);
setHasSearched(true);
try {
const results = await skillApi.clawhub.search(searchQuery);
setSearchResults(results);
} catch (e: any) {
showToast(e.message || 'Search failed', 'error');
}
setSearching(false);
};
const handleInstall = async (slug: string) => {
setInstalling(slug);
try {
const result = await skillApi.clawhub.install(slug);
const tierLabel = result.tier === 1 ? 'Tier 1 (Pure Prompt)' : result.tier === 2 ? 'Tier 2 (CLI/API)' : 'Tier 3 (OpenClaw Native)';
showToast(`Installed "${result.name}" — ${tierLabel}, ${result.file_count} files`);
setRefreshKey(k => k + 1);
setSearchResults(prev => prev.filter(r => r.slug !== slug));
} catch (e: any) {
showToast(e.message || 'Install failed', 'error');
}
setInstalling(null);
};
const handleUrlPreview = async () => {
if (!urlInput.trim()) return;
setUrlPreviewing(true);
setUrlPreview(null);
try {
const preview = await skillApi.previewUrl(urlInput);
setUrlPreview(preview);
} catch (e: any) {
showToast(e.message || 'Preview failed', 'error');
}
setUrlPreviewing(false);
};
const handleUrlImport = async () => {
if (!urlInput.trim()) return;
setUrlImporting(true);
try {
const result = await skillApi.importFromUrl(urlInput);
showToast(`Imported "${result.name}" — ${result.file_count} files`);
setRefreshKey(k => k + 1);
setShowUrlModal(false);
setUrlInput('');
setUrlPreview(null);
} catch (e: any) {
showToast(e.message || 'Import failed', 'error');
}
setUrlImporting(false);
};
const tierBadge = (tier: number) => {
const styles: Record<number, { bg: string; color: string; label: string }> = {
1: { bg: 'rgba(52,199,89,0.12)', color: 'var(--success, #34c759)', label: 'Tier 1 · Pure Prompt' },
2: { bg: 'rgba(255,159,10,0.12)', color: 'var(--warning, #ff9f0a)', label: 'Tier 2 · CLI/API' },
3: { bg: 'rgba(255,59,48,0.12)', color: 'var(--error, #ff3b30)', label: 'Tier 3 · OpenClaw Native' },
};
const s = styles[tier] || styles[1];
return (
<span style={{ padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 500, background: s.bg, color: s.color }}>
{s.label}
</span>
);
};
return (
<div>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h3>{t('enterprise.tabs.skills', 'Skill Registry')}</h3>
<p style={{ fontSize: '13px', color: 'var(--text-tertiary)', marginTop: '4px' }}>
{t('enterprise.tools.manageGlobalSkills')}
</p>
</div>
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
<button
className="btn btn-secondary"
style={{ fontSize: '13px', padding: '6px 10px', minWidth: 'auto' }}
onClick={async () => {
setShowSettings(s => !s);
if (!tokenStatus) {
try {
const status = await skillApi.settings.getToken();
setTokenStatus(status);
} catch { /* ignore */ }
}
}}
title="Settings"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
<button
className="btn btn-secondary"
style={{ fontSize: '13px' }}
onClick={() => { setShowUrlModal(true); setUrlInput(''); setUrlPreview(null); }}
>
{t('enterprise.tools.importFromUrl')}
</button>
<button
className="btn btn-primary"
style={{ fontSize: '13px' }}
onClick={() => { setShowClawhubModal(true); setSearchQuery(''); setSearchResults([]); setHasSearched(false); }}
>
{t('enterprise.tools.browseClawhub')}
</button>
</div>
</div>
{/* GitHub Token Settings Panel */}
{showSettings && (
<div style={{
marginBottom: '16px', padding: '16px', borderRadius: '8px',
border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary, rgba(255,255,255,0.02))',
}}>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px' }}>
{t('enterprise.tools.githubToken')}
<span className="metric-tooltip-trigger" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'help', color: 'var(--text-tertiary)' }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="8" cy="8" r="6.5" /><path d="M8 7v4M8 5.5v0" /></svg>
<span className="metric-tooltip" style={{ width: '300px', bottom: 'auto', top: 'calc(100% + 6px)', left: '-8px', fontWeight: 400 }}>
<div style={{ marginBottom: '6px', fontWeight: 500 }}>{t('enterprise.tools.howToGenerateGithubToken')}</div>
{t('enterprise.tools.githubTokenStep1')}<br />
{t('enterprise.tools.githubTokenStep2')}<br />
{t('enterprise.tools.githubTokenStep3')}<br />
{t('enterprise.tools.githubTokenStep4')}<br />
{t('enterprise.tools.githubTokenStep5')}<br />
<div style={{ marginTop: '6px', fontSize: '11px', color: 'var(--text-tertiary)' }}>
{t('enterprise.tools.orVisit')}
</div>
</span>
</span>
</div>
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginBottom: '12px' }}>
{t('enterprise.tools.githubTokenDesc')}
</p>
{tokenStatus?.configured && (
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '8px' }}>
{t('enterprise.tools.currentToken')} <code style={{ padding: '2px 6px', borderRadius: '4px', background: 'var(--bg-tertiary)', fontSize: '11px' }}>{tokenStatus.masked}</code>
<span style={{ marginLeft: '8px', color: 'var(--text-tertiary)' }}>({tokenStatus.source})</span>
</div>
)}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{/* Hidden inputs to absorb browser autofill */}
<input type="text" name="prevent_autofill_user" style={{ display: 'none' }} tabIndex={-1} />
<input type="password" name="prevent_autofill_pass" style={{ display: 'none' }} tabIndex={-1} />
<input
type="text"
className="input"
autoComplete="off"
data-form-type="other"
placeholder="ghp_xxxxxxxxxxxx"
value={tokenInput}
onChange={e => setTokenInput(e.target.value)}
style={{ flex: 1, fontSize: '13px', fontFamily: 'monospace', WebkitTextSecurity: 'disc' } as React.CSSProperties}
/>
<button
className="btn btn-primary"
style={{ fontSize: '13px' }}
disabled={!tokenInput.trim() || savingToken}
onClick={async () => {
setSavingToken(true);
try {
await skillApi.settings.setToken(tokenInput.trim());
const status = await skillApi.settings.getToken();
setTokenStatus(status);
setTokenInput('');
showToast(t('enterprise.tools.githubTokenSaved'));
} catch (e: any) {
showToast(e.message || t('enterprise.tools.failedToSave'), 'error');
}
setSavingToken(false);
}}
>
{savingToken ? t('enterprise.tools.saving') : t('enterprise.tools.save')}
</button>
{tokenStatus?.configured && tokenStatus.source === 'tenant' && (
<button
className="btn btn-secondary"
style={{ fontSize: '13px' }}
onClick={async () => {
try {
await skillApi.settings.setToken('');
const status = await skillApi.settings.getToken();
setTokenStatus(status);
showToast(t('enterprise.tools.tokenCleared'));
} catch (e: any) {
showToast(e.message || t('enterprise.tools.failed'), 'error');
}
}}
>
{t('enterprise.tools.clear')}
</button>
)}
</div>
{/* ClawHub API Key */}
<div style={{ marginTop: '16px' }}>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px' }}>
{t('enterprise.tools.clawhubApiKey')}
<span className="metric-tooltip-trigger" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'help', color: 'var(--text-tertiary)' }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="8" cy="8" r="6.5" /><path d="M8 7v4M8 5.5v0" /></svg>
<span className="metric-tooltip" style={{ width: '280px', bottom: 'auto', top: 'calc(100% + 6px)', left: '-8px', fontWeight: 400 }}>
{t('enterprise.tools.clawhubApiKeyDesc')}
</span>
</span>
</div>
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginBottom: '12px' }}>
{t('enterprise.tools.authenticatedRequestsGetHigherRateLimits')}
</p>
{tokenStatus?.clawhub_configured && (
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '8px' }}>
{t('enterprise.tools.currentKey')} <code style={{ padding: '2px 6px', borderRadius: '4px', background: 'var(--bg-tertiary)', fontSize: '11px' }}>{tokenStatus.clawhub_masked}</code>
</div>
)}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input type="text" name="prevent_autofill_ch_user" style={{ display: 'none' }} tabIndex={-1} />
<input type="password" name="prevent_autofill_ch_pass" style={{ display: 'none' }} tabIndex={-1} />
<input
type="text"
className="input"
autoComplete="off"
data-form-type="other"
placeholder="sk-ant-xxxxxxxxxxxx"
value={clawhubKeyInput}
onChange={e => setClawhubKeyInput(e.target.value)}
style={{ flex: 1, fontSize: '13px', fontFamily: 'monospace', WebkitTextSecurity: 'disc' } as React.CSSProperties}
/>
<button
className="btn btn-primary"
style={{ fontSize: '13px' }}
disabled={!clawhubKeyInput.trim() || savingClawhubKey}
onClick={async () => {
setSavingClawhubKey(true);
try {
await skillApi.settings.setClawhubKey(clawhubKeyInput.trim());
const status = await skillApi.settings.getToken();
setTokenStatus(status);
setClawhubKeyInput('');
showToast(t('enterprise.tools.clawhubApiKeySaved'));
} catch (e: any) {
showToast(e.message || t('enterprise.tools.failedToSave'), 'error');
}
setSavingClawhubKey(false);
}}
>
{savingClawhubKey ? t('enterprise.tools.saving') : t('enterprise.tools.save')}
</button>
{tokenStatus?.clawhub_configured && (
<button
className="btn btn-secondary"
style={{ fontSize: '13px' }}
onClick={async () => {
try {
await skillApi.settings.setClawhubKey('');
const status = await skillApi.settings.getToken();
setTokenStatus(status);
showToast(t('enterprise.tools.tokenCleared'));
} catch (e: any) {
showToast(e.message || t('enterprise.tools.failed'), 'error');
}
}}
>
{t('enterprise.tools.clear')}
</button>
)}
</div>
</div>
</div>
)}
<FileBrowser
key={refreshKey}
api={adapter}
features={{ newFile: true, newFolder: true, edit: true, delete: true, directoryNavigation: true }}
title={t('agent.skills.skillFiles', 'Skill Files')}
onRefresh={() => setRefreshKey(k => k + 1)}
/>
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', bottom: '24px', right: '24px', zIndex: 10000,
padding: '12px 20px', borderRadius: '8px', fontSize: '13px', fontWeight: 500,
background: toast.type === 'error' ? 'rgba(255,59,48,0.95)' : 'rgba(52,199,89,0.95)',
color: '#fff', boxShadow: '0 4px 16px rgba(0,0,0,0.2)', maxWidth: '400px',
animation: 'fadeIn 200ms ease',
}}>
{toast.message}
</div>
)}
{/* ClawHub Search Modal */}
{showClawhubModal && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setShowClawhubModal(false)}>
<div style={{
background: 'var(--bg-primary)', borderRadius: '12px', width: '640px', maxHeight: '80vh',
display: 'flex', flexDirection: 'column', border: '1px solid var(--border-default)',
boxShadow: '0 16px 48px rgba(0,0,0,0.2)',
}} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-subtle)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px' }}>{t('enterprise.tools.browseClawhub')}</h3>
<button className="btn btn-ghost" onClick={() => setShowClawhubModal(false)} style={{ padding: '4px 8px', fontSize: '16px', lineHeight: 1 }}>x</button>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
className="input"
placeholder={t('enterprise.tools.searchSkills')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
autoFocus
style={{ flex: 1, fontSize: '13px' }}
/>
<button className="btn btn-primary" onClick={handleSearch} disabled={searching} style={{ fontSize: '13px' }}>
{searching ? t('enterprise.tools.searching') : t('enterprise.tools.search')}
</button>
</div>
</div>
{/* Results */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 24px' }}>
{searchResults.length === 0 && !searching && (
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-tertiary)', fontSize: '13px' }}>
{hasSearched ? t('enterprise.tools.noResultsFound') : t('enterprise.tools.searchForSkills')}
</div>
)}
{searching && (
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-tertiary)', fontSize: '13px' }}>
{t('enterprise.tools.searchingClawhub')}
</div>
)}
{searchResults.map((r: any) => (
<div key={r.slug} style={{
padding: '12px 0', borderBottom: '1px solid var(--border-subtle)',
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '12px',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}>{r.displayName}</span>
<span style={{ fontSize: '11px', color: 'var(--text-tertiary)', fontFamily: 'var(--font-mono)' }}>{r.slug}</span>
{r.version && <span style={{ fontSize: '10px', color: 'var(--accent-text)', background: 'var(--accent-subtle)', padding: '1px 6px', borderRadius: '4px' }}>v{r.version}</span>}
</div>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: '1.4' }}>
{r.summary?.slice(0, 160)}{r.summary?.length > 160 ? '...' : ''}
</div>
{r.updatedAt && <div style={{ fontSize: '11px', color: 'var(--text-tertiary)', marginTop: '4px' }}>Updated {new Date(r.updatedAt).toLocaleDateString()}</div>}
</div>
<button
className="btn btn-secondary"
style={{ fontSize: '12px', flexShrink: 0 }}
disabled={installing === r.slug}
onClick={() => handleInstall(r.slug)}
>
{installing === r.slug ? t('enterprise.tools.installing') : t('enterprise.tools.install')}
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* URL Import Modal */}
{showUrlModal && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setShowUrlModal(false)}>
<div style={{
background: 'var(--bg-primary)', borderRadius: '12px', width: '560px',
border: '1px solid var(--border-default)', boxShadow: '0 16px 48px rgba(0,0,0,0.2)',
}} onClick={e => e.stopPropagation()}>
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-subtle)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px' }}>{t('enterprise.tools.importFromUrl')}</h3>
<button className="btn btn-ghost" onClick={() => setShowUrlModal(false)} style={{ padding: '4px 8px', fontSize: '16px', lineHeight: 1 }}>x</button>
</div>
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', margin: '0 0 12px' }}>
{t('enterprise.tools.pasteGithubUrl')}
</p>
<div style={{ display: 'flex', gap: '8px' }}>
<input
className="input"
placeholder={t('enterprise.tools.githubUrlPlaceholder')}
value={urlInput}
onChange={e => { setUrlInput(e.target.value); setUrlPreview(null); }}
autoFocus
style={{ flex: 1, fontSize: '13px', fontFamily: 'var(--font-mono)' }}
onKeyDown={e => e.key === 'Enter' && handleUrlPreview()}
/>
<button className="btn btn-secondary" onClick={handleUrlPreview} disabled={urlPreviewing || !urlInput.trim()} style={{ fontSize: '12px' }}>
{urlPreviewing ? t('enterprise.tools.loading') : t('enterprise.tools.preview')}
</button>
</div>
</div>
{/* Preview result */}
{urlPreview && (
<div style={{ padding: '16px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}>{urlPreview.name}</span>
{tierBadge(urlPreview.tier)}
{urlPreview.has_scripts && (
<span style={{ padding: '2px 8px', borderRadius: '4px', fontSize: '11px', background: 'rgba(255,59,48,0.1)', color: 'var(--error, #ff3b30)' }}>
Contains scripts
</span>
)}
</div>
{urlPreview.description && (
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', margin: '0 0 8px' }}>{urlPreview.description}</p>
)}
<div style={{ fontSize: '11px', color: 'var(--text-tertiary)', marginBottom: '12px' }}>
{urlPreview.files?.length} files, {(urlPreview.total_size / 1024).toFixed(1)} KB
</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setShowUrlModal(false)} style={{ fontSize: '13px' }}>Cancel</button>
<button className="btn btn-primary" onClick={handleUrlImport} disabled={urlImporting} style={{ fontSize: '13px' }}>
{urlImporting ? 'Importing...' : 'Import'}
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
function CompanyLogoCropModal({ imageUrl, imageName, onCancel, onSave }: {
imageUrl: string;
imageName: string;
onCancel: () => void;
onSave: (blob: Blob) => void;
}) {
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement>(null);
const [naturalSize, setNaturalSize] = useState({ width: 1, height: 1 });
const [zoom, setZoom] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [dragStart, setDragStart] = useState<{ x: number; y: number; ox: number; oy: number } | null>(null);
const cropSize = 240;
const clampOffset = (next: { x: number; y: number }, nextZoom = zoom) => {
const baseScale = Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height);
const displayW = naturalSize.width * baseScale * nextZoom;
const displayH = naturalSize.height * baseScale * nextZoom;
const maxX = Math.max(0, (displayW - cropSize) / 2);
const maxY = Math.max(0, (displayH - cropSize) / 2);
return {
x: Math.min(maxX, Math.max(-maxX, next.x)),
y: Math.min(maxY, Math.max(-maxY, next.y)),
};
};
const handleSave = () => {
const img = imgRef.current;
if (!img) return;
const outputSize = 512;
const ratio = outputSize / cropSize;
const baseScale = Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height);
const displayW = naturalSize.width * baseScale * zoom;
const displayH = naturalSize.height * baseScale * zoom;
const dx = ((cropSize - displayW) / 2 + offset.x) * ratio;
const dy = ((cropSize - displayH) / 2 + offset.y) * ratio;
const canvas = document.createElement('canvas');
canvas.width = outputSize;
canvas.height = outputSize;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, outputSize, outputSize);
ctx.drawImage(img, dx, dy, displayW * ratio, displayH * ratio);
canvas.toBlob((blob) => {
if (blob) onSave(blob);
}, 'image/png');
};
return (
<div className="tenant-logo-crop-backdrop" onClick={onCancel}>
<div className="tenant-logo-crop-modal" onClick={e => e.stopPropagation()}>
<div className="tenant-logo-crop-header">
<div>
<h3>{t('enterprise.logo.cropTitle', 'Crop company logo')}</h3>
<p>{imageName}</p>
</div>
<button type="button" onClick={onCancel}>×</button>
</div>
<div
className="tenant-logo-crop-stage"
onPointerDown={e => {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDragStart({ x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y });
}}
onPointerMove={e => {
if (!dragStart) return;
setOffset(clampOffset({
x: dragStart.ox + e.clientX - dragStart.x,
y: dragStart.oy + e.clientY - dragStart.y,
}));
}}
onPointerUp={() => setDragStart(null)}
onPointerCancel={() => setDragStart(null)}
>
<img
ref={imgRef}
src={imageUrl}
alt=""
draggable={false}
onLoad={e => {
const img = e.currentTarget;
setNaturalSize({ width: img.naturalWidth || 1, height: img.naturalHeight || 1 });
setOffset({ x: 0, y: 0 });
setZoom(1);
}}
style={{
width: `${naturalSize.width * Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height)}px`,
height: `${naturalSize.height * Math.max(cropSize / naturalSize.width, cropSize / naturalSize.height)}px`,
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
}}
/>
</div>
<div className="tenant-logo-crop-controls">
<span>{t('enterprise.logo.zoom', 'Zoom')}</span>
<input
type="range"
min="1"
max="3"
step="0.01"
value={zoom}
onChange={e => {
const nextZoom = Number(e.target.value);
setZoom(nextZoom);
setOffset(prev => clampOffset(prev, nextZoom));
}}
/>
</div>
<div className="tenant-logo-crop-actions">
<button className="btn btn-secondary" type="button" onClick={onCancel}>{t('common.cancel', 'Cancel')}</button>
<button className="btn btn-primary" type="button" onClick={handleSave}>{t('common.save', 'Save')}</button>
</div>
</div>
</div>
);
}
function CompanyLogoEditor() {
const { t } = useTranslation();
const qc = useQueryClient();
const tenantId = localStorage.getItem('current_tenant_id') || '';
const [name, setName] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [logoError, setLogoError] = useState('');
const [logoSaving, setLogoSaving] = useState(false);
const [cropSource, setCropSource] = useState<{ url: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!tenantId) return;
fetchJson<any>(`/tenants/${tenantId}`)
.then(d => {
if (d?.name) setName(d.name);
setLogoUrl(d?.logo_url || '');
})
.catch(() => { });
}, [tenantId]);
const handleLogoFile = (file: File | undefined) => {
setLogoError('');
if (!file) return;
if (file.size > 1024 * 1024) {
setLogoError(t('enterprise.logo.tooLarge', 'Logo image must be 1 MB or smaller.'));
return;
}
if (!file.type.startsWith('image/')) {
setLogoError(t('enterprise.logo.invalidType', 'Please choose an image file.'));
return;
}
setCropSource({ url: URL.createObjectURL(file), name: file.name });
};
const uploadCroppedLogo = async (blob: Blob) => {
if (!tenantId) return;
setLogoError('');
if (blob.size > 1024 * 1024) {
setLogoError(t('enterprise.logo.croppedTooLarge', 'Cropped logo is still larger than 1 MB.'));
return;
}
setLogoSaving(true);
try {
const form = new FormData();
form.append('file', blob, 'company-logo.png');
const res = await fetch(`/api/tenants/${tenantId}/logo`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token') || ''}` },
body: form,
});
if (!res.ok) {
throw new Error(t('enterprise.logo.uploadFailed', 'Failed to upload logo.'));
}
const tenant = await res.json();
setLogoUrl(tenant.logo_url || '');
setCropSource(null);
qc.invalidateQueries({ queryKey: ['tenant', tenantId] });
qc.invalidateQueries({ queryKey: ['my-tenants'] });
} catch (e: any) {
setLogoError(e.message || t('enterprise.logo.uploadFailed', 'Failed to upload logo.'));
} finally {
setLogoSaving(false);
}
};
const resetLogo = async () => {
if (!tenantId || !logoUrl) return;
setLogoError('');
setLogoSaving(true);
try {
const res = await fetch(`/api/tenants/${tenantId}/logo`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${localStorage.getItem('token') || ''}` },
});
if (!res.ok) {
throw new Error(t('enterprise.logo.resetFailed', 'Failed to reset logo.'));
}
setLogoUrl('');
qc.invalidateQueries({ queryKey: ['tenant', tenantId] });
qc.invalidateQueries({ queryKey: ['my-tenants'] });
} catch (e: any) {
setLogoError(e.message || t('enterprise.logo.resetFailed', 'Failed to reset logo.'));
} finally {
setLogoSaving(false);
}
};
return (
<div className="card" style={{ padding: '16px', marginBottom: '24px' }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px' }}>
{t('enterprise.logo.title', 'Company Logo')}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-tertiary)', marginBottom: '14px' }}>
{t('enterprise.logo.description', 'Used in the sidebar workspace switcher and company selection menus.')}
</div>
<div className="company-identity-logo-row">
<div className="company-identity-logo-preview">
{logoUrl ? <img src={logoUrl} alt="" /> : <span>{(Array.from(name.trim())[0] as string | undefined)?.toUpperCase() || 'C'}</span>}
</div>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }}
onChange={e => {
handleLogoFile(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
<button className="btn btn-secondary" type="button" onClick={() => fileInputRef.current?.click()} disabled={logoSaving}>
{logoSaving ? t('common.loading') : t('enterprise.logo.upload', 'Upload logo')}
</button>
{logoUrl && (
<button
className="btn btn-ghost"
type="button"
onClick={resetLogo}
disabled={logoSaving}
style={{ marginLeft: '8px' }}
>
{t('enterprise.logo.reset', 'Reset to default')}
</button>
)}
<div style={{ fontSize: '11px', color: 'var(--text-tertiary)', marginTop: '6px' }}>
{t('enterprise.logo.hint', 'PNG, JPG, or WebP. Max 1 MB. You will crop it to a square before saving.')}
</div>
{logoError && <div style={{ fontSize: '12px', color: 'var(--error)', marginTop: '6px' }}>{logoError}</div>}
</div>
</div>
{cropSource && (
<CompanyLogoCropModal
imageUrl={cropSource.url}
imageName={cropSource.name}
onCancel={() => setCropSource(null)}
onSave={uploadCroppedLogo}
/>
)}
</div>
);
}
function CompanyNameEditor() {
const { t } = useTranslation();
const qc = useQueryClient();
const tenantId = localStorage.getItem('current_tenant_id') || '';
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!tenantId) return;
fetchJson<any>(`/tenants/${tenantId}`)
.then(d => { if (d?.name) setName(d.name); })
.catch(() => { });
}, [tenantId]);
const handleSave = async () => {
if (!tenantId || !name.trim()) return;
setSaving(true);
try {
await fetchJson(`/tenants/${tenantId}`, {
method: 'PUT', body: JSON.stringify({ name: name.trim() }),
});
qc.invalidateQueries({ queryKey: ['tenants'] });
qc.invalidateQueries({ queryKey: ['tenant', tenantId] });
qc.invalidateQueries({ queryKey: ['my-tenants'] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) { }
setSaving(false);
};
return (
<div className="card" style={{ padding: '16px', marginBottom: '24px' }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '10px' }}>
{t('enterprise.companyName.title', 'Company Name')}
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<input
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('enterprise.companyName.placeholder', 'Enter company name')}
style={{ flex: 1, fontSize: '14px' }}
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? t('common.loading') : t('common.save', 'Save')}
</button>
{saved && <IconCheck size={15} stroke={2} style={{ color: 'var(--success)' }} />}
</div>
</div>
);
}
function CompanyTimezoneEditor() {
const { t, i18n } = useTranslation();
const user = useAuthStore((s) => s.user);
const tenantId = user?.tenant_id || localStorage.getItem('current_tenant_id') || '';
const regionPickerRef = useRef<HTMLDivElement>(null);
const [timezone, setTimezone] = useState('UTC');
const [countryRegion, setCountryRegion] = useState('001');
const [regionInput, setRegionInput] = useState('');
const [regionOpen, setRegionOpen] = useState(false);
const [highlightedRegion, setHighlightedRegion] = useState(0);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState('');
const zh = i18n.language?.startsWith('zh');
const companyRegions = useMemo(() => buildCompanyRegions(zh ? 'zh-Hans' : 'en'), [zh]);
const regionLabel = (r: CompanyRegion) => zh ? r.zh : r.en;
const selectedRegion = companyRegions.find(r => r.code === countryRegion) || companyRegions[0];
const filteredRegions = useMemo(() => {
const query = regionInput.trim().toLowerCase();
if (!query || (!regionOpen && regionInput === regionLabel(selectedRegion))) return companyRegions;
return companyRegions.filter(r => {
const localName = regionLabel(r).toLowerCase();
const altName = (zh ? r.en : r.zh).toLowerCase();
return localName.includes(query)
|| altName.includes(query)
|| r.code.toLowerCase().includes(query)
|| r.timezone.toLowerCase().includes(query);
});
}, [companyRegions, regionInput, regionOpen, selectedRegion, zh]);
useEffect(() => {
setRegionInput(regionLabel(selectedRegion));
}, [countryRegion, zh]);
useEffect(() => {
if (!regionOpen) return;
const handlePointerDown = (e: MouseEvent) => {
if (!regionPickerRef.current?.contains(e.target as Node)) {
setRegionOpen(false);
setRegionInput(regionLabel(selectedRegion));
setHighlightedRegion(0);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [regionOpen, selectedRegion, zh]);
useEffect(() => {
setHighlightedRegion(0);
}, [regionInput]);
useEffect(() => {
if (!tenantId) return;
fetchJson<any>(`/tenants/${tenantId}`)
.then(d => {
if (d?.timezone) setTimezone(d.timezone);
if (d?.country_region) setCountryRegion(d.country_region);
})
.catch((e: any) => setError(e.message || 'Failed to load timezone'));
}, [tenantId]);
const handleSave = async (regionCode: string) => {
if (!tenantId) return;
const region = companyRegions.find(r => r.code === regionCode) || companyRegions[0];
setCountryRegion(region.code);
setTimezone(region.timezone);
setSaving(true);
setError('');
try {
await fetchJson(`/tenants/${tenantId}`, {
method: 'PUT',
body: JSON.stringify({
country_region: region.code,
timezone: region.timezone,
}),
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e: any) {
setError(e.message || 'Failed to save timezone');
}
setSaving(false);
};
const selectRegion = (region: CompanyRegion) => {
setRegionInput(regionLabel(region));
setRegionOpen(false);
setHighlightedRegion(0);
if (region.code !== countryRegion) {
handleSave(region.code);
}
};
return (
<div className="card" style={{ padding: '16px', marginBottom: '24px' }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px' }}>
{zh ? '公司所在国家或地区' : 'Company Country or Region'}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-tertiary)', marginBottom: '12px' }}>
{zh
? `用于自动设置公司默认时区和 OKR 休息日规则。当前时区:${timezone}`
: `Used to set the company timezone and OKR non-workday rules. Current timezone: ${timezone}`}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '10px', width: '100%' }}>
<div ref={regionPickerRef} style={{ position: 'relative', width: 'min(420px, 100%)' }}>
<input
className="form-input"
value={regionInput}
onChange={e => {
setRegionInput(e.target.value);
setRegionOpen(true);
}}
onFocus={() => {
setRegionOpen(true);
setRegionInput('');
}}
onKeyDown={e => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setRegionOpen(true);
setHighlightedRegion(i => Math.min(i + 1, Math.max(filteredRegions.length - 1, 0)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedRegion(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const region = filteredRegions[highlightedRegion];
if (region) selectRegion(region);
} else if (e.key === 'Escape') {
setRegionOpen(false);
setRegionInput(regionLabel(selectedRegion));
}
}}
placeholder={zh ? '搜索国家或地区、代码或时区' : 'Search country, code, or timezone'}
style={{
width: '100%',
fontSize: '13px',
paddingRight: '42px',
cursor: saving || !tenantId ? 'not-allowed' : 'text',
}}
disabled={saving || !tenantId}
role="combobox"
aria-expanded={regionOpen}
aria-controls="company-region-listbox"
aria-autocomplete="list"
/>
<button
type="button"
onClick={() => {
if (saving || !tenantId) return;
setRegionOpen(v => !v);
if (!regionOpen) setRegionInput('');
}}
disabled={saving || !tenantId}
aria-label={regionOpen ? (zh ? '收起地区列表' : 'Collapse region list') : (zh ? '展开地区列表' : 'Expand region list')}
style={{
position: 'absolute',
right: '7px',
top: '50%',
transform: `translateY(-50%) rotate(${regionOpen ? 180 : 0}deg)`,
border: 'none',
background: 'transparent',
color: 'var(--text-secondary)',
cursor: saving || !tenantId ? 'not-allowed' : 'pointer',
width: '30px',
height: '30px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 120ms ease',
}}
>
<span
aria-hidden="true"
style={{
width: '8px',
height: '8px',
borderRight: '1.6px solid currentColor',
borderBottom: '1.6px solid currentColor',
transform: 'rotate(45deg) translateY(-2px)',
borderRadius: '1px',
}}
/>
</button>
{regionOpen && (
<div
id="company-region-listbox"
role="listbox"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
right: 0,
zIndex: 30,
background: 'var(--bg-primary)',
border: '1px solid var(--border-subtle)',
borderRadius: '8px',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.14)',
maxHeight: '260px',
overflowY: 'auto',
padding: '6px',
}}
>
{filteredRegions.length > 0 ? filteredRegions.map((region, index) => {
const active = region.code === countryRegion;
const highlighted = index === highlightedRegion;
return (
<button
key={region.code}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => setHighlightedRegion(index)}
onMouseDown={e => e.preventDefault()}
onClick={() => selectRegion(region)}
style={{
width: '100%',
border: 'none',
background: highlighted ? 'var(--bg-elevated)' : 'transparent',
color: 'var(--text-primary)',
borderRadius: '6px',
padding: '9px 10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
textAlign: 'left',
cursor: 'pointer',
}}
>
<span style={{ minWidth: 0 }}>
<span style={{ display: 'block', fontSize: '13px', fontWeight: active ? 600 : 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{regionLabel(region)}
</span>
<span style={{ display: 'block', marginTop: '2px', color: 'var(--text-tertiary)', fontSize: '11px' }}>
{region.code} · {region.timezone}
</span>
</span>
{active && <span style={{ color: 'var(--text-primary)', fontSize: '14px', flexShrink: 0 }}>✓</span>}
</button>
);
}) : (
<div style={{ padding: '12px 10px', color: 'var(--text-tertiary)', fontSize: '12px' }}>
{zh ? '没有匹配的国家或地区' : 'No matching country or region'}
</div>
)}
</div>
)}
</div>
{(saved || error || !tenantId) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minHeight: '16px', flexWrap: 'wrap' }}>
{saved && <span style={{ color: 'var(--success)', fontSize: '12px' }}>已保存</span>}
{error && (
<div style={{ fontSize: '11px', color: 'var(--error)' }}>
{error}
</div>
)}
{!tenantId && (
<div style={{ fontSize: '11px', color: 'var(--error)' }}>
{t('enterprise.timezone.noTenant', 'No company selected. Please refresh the page or contact support.')}
</div>
)}
</div>
)}
</div>
</div>
);
}
function A2AAsyncToggle() {
const { t, i18n } = useTranslation();
const user = useAuthStore((s) => s.user);
const tenantId = user?.tenant_id || localStorage.getItem('current_tenant_id') || '';
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const zh = i18n.language?.startsWith('zh');
useEffect(() => {
if (!tenantId) return;
fetchJson<any>(`/tenants/${tenantId}`)
.then(d => setEnabled(!!d?.a2a_async_enabled))
.catch((e: any) => setError(e.message || 'Failed to load A2A setting'));
}, [tenantId]);
const handleToggle = async () => {
if (!tenantId || saving) return;
const next = !enabled;
setEnabled(next);
setSaving(true);
setError('');
try {
await fetchJson(`/tenants/${tenantId}`, {
method: 'PUT',
body: JSON.stringify({ a2a_async_enabled: next }),
});
} catch (e: any) {
setEnabled(!next);
setError(e.message || 'Failed to save A2A setting');
} finally {
setSaving(false);
}
};
return (
<div className="card" style={{ padding: '16px', marginBottom: '24px' }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px' }}>
{zh ? 'Agent 异步协作(Beta)' : 'Agent Async Collaboration (Beta)'}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-tertiary)', marginBottom: '12px' }}>
{zh
? '开启后,数字员工之间可使用 notify / task_delegate 等异步协作模式。关闭后,Agent 间消息统一走同步 consult。'
: 'When enabled, agents can use async notify and task_delegate modes. When disabled, agent-to-agent messaging falls back to synchronous consult.'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '12px' }}>
<div style={{ width: '100%' }}>
{error && (
<div style={{ fontSize: '11px', color: 'var(--error)' }}>
{error}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<label style={{ position: 'relative', display: 'inline-block', width: '40px', height: '22px', flexShrink: 0, opacity: saving ? 0.6 : 1 }}>
<input
type="checkbox"
checked={enabled}
onChange={handleToggle}
disabled={saving || !tenantId}
style={{ opacity: 0, width: 0, height: 0 }}
/>
<span style={{
position: 'absolute', inset: 0,
borderRadius: '999px',
cursor: saving ? 'not-allowed' : 'pointer',
background: enabled ? 'var(--accent-primary)' : 'var(--border-subtle)',
transition: '0.2s',
}}>
<span style={{
position: 'absolute',
top: '2px',
left: enabled ? '20px' : '2px',
width: '18px',
height: '18px',
borderRadius: '50%',
background: '#fff',
transition: '0.2s',
boxShadow: '0 1px 2px rgba(0,0,0,0.12)',
}} />
</span>
</label>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
{enabled ? (zh ? '已开启' : 'Enabled') : (zh ? '已关闭' : 'Disabled')}
</span>
</div>
</div>
<div style={{ marginTop: '6px', fontSize: '11px', color: 'var(--text-tertiary)', maxWidth: '640px' }}>
{zh
? '说明:OKR 日报收集本身会优先使用更稳的同步方式,不依赖这里的异步开关。'
: 'Note: OKR daily collection itself uses the more reliable synchronous path and does not depend on this toggle.'}
</div>
</div>
);
}
function BroadcastSection() {
const { t } = useTranslation();
const toast = useToast();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [sendEmail, setSendEmail] = useState(false);
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ users: number; agents: number; emails: number } | null>(null);
const handleSend = async () => {
if (!title.trim()) return;
setSending(true);
setResult(null);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/notifications/broadcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ title: title.trim(), body: body.trim(), send_email: sendEmail }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error('广播发送失败', { details: String(err.detail || `HTTP ${res.status}`) });
setSending(false);
return;
}
const data = await res.json();
setResult({
users: data.users_notified,
agents: data.agents_notified,
emails: data.emails_sent || 0,
});
setTitle('');
setBody('');
setSendEmail(false);
} catch (e: any) {
toast.error('广播发送失败', { details: String(e?.message || e) });
}
setSending(false);
};
return (
<div style={{ marginTop: '24px', marginBottom: '24px' }}>
<h3 style={{ marginBottom: '4px' }}>{t('enterprise.broadcast.title', 'Broadcast Notification')}</h3>
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginBottom: '12px' }}>
{t('enterprise.broadcast.description', 'Send a notification to all users and agents in this company.')}
</p>
<div className="card" style={{ padding: '16px' }}>
<input
className="form-input"
placeholder={t('enterprise.broadcast.titlePlaceholder', 'Notification title')}
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={200}
style={{ marginBottom: '8px', fontSize: '13px' }}
/>
<textarea
className="form-input"
placeholder={t('enterprise.broadcast.bodyPlaceholder', 'Optional details...')}
value={body}
onChange={e => setBody(e.target.value)}
maxLength={1000}
rows={3}
style={{ resize: 'vertical', fontSize: '13px', marginBottom: '12px' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
<input
type="checkbox"
checked={sendEmail}
onChange={e => setSendEmail(e.target.checked)}
/>
<span>{t('enterprise.broadcast.sendEmail', 'Also send email to users with a configured address')}</span>
</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button className="btn btn-primary" onClick={handleSend} disabled={sending || !title.trim()}>
{sending ? t('common.loading') : t('enterprise.broadcast.send', 'Send Broadcast')}
</button>
{result && (
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
{t(
'enterprise.broadcast.sentWithEmail',
`Sent to ${result.users} users, ${result.agents} agents, and ${result.emails} email recipients`,
{ users: result.users, agents: result.agents, emails: result.emails },
)}
</span>
)}
</div>
</div>
</div>
);
}