const ArticleModal = ({ article, onSave, onClose, saving, currentUser, userProperties = [], kbPath = [], onOpenHistory }) => { const { useState, useEffect, useRef } = React; // Determine if we're creating or editing const isEditing = article && article.id; const isCreatingInFolder = article && article.parent_id && !article.id; // Build the location breadcrumb const locationPath = isCreatingInFolder ? kbPath.map(p => p.title).join(' → ') : (isEditing && article.category ? article.category : 'Root'); const normalizeMetadata = (meta) => { if (!meta) return {}; if (typeof meta === 'string') { try { return JSON.parse(meta); } catch { return {}; } } if (typeof meta === 'object') return meta; return {}; }; const initialMeta = normalizeMetadata(article?.metadata); const [formData, setFormData] = useState({ title: article?.title || '', content: article?.content || '', parent_id: article?.parent_id || null, is_folder: article?.is_folder || false, applies_to_properties: article?.applies_to_properties || [], allowed_users: article?.allowed_users || [], allowed_roles: article?.allowed_roles || [], allowed_brands: article?.allowed_brands || [], tags: article?.tags || [], versioning_mode: article?.versioning_mode || 'history', audience: (String(initialMeta.audience || '').toLowerCase() === 'oto' ? 'Corporate' : (initialMeta.audience || '')), department: initialMeta.department || '' }); // File upload state const [selectedFiles, setSelectedFiles] = useState([]); const [existingAttachments, setExistingAttachments] = useState([]); const [allUsers, setAllUsers] = useState([]); const [allRoles, setAllRoles] = useState([]); const [allBrands, setAllBrands] = useState([]); const [ancestorPerms, setAncestorPerms] = useState(null); const [activeTab, setActiveTab] = useState('content'); const [propertySearch, setPropertySearch] = useState(''); const [userSearch, setUserSearch] = useState(''); const [roleSearch, setRoleSearch] = useState(''); // Build tabs array — only 2 tabs: Content + Access (admin only) const tabs = [ { id: 'content', label: 'Content' }, ...(currentUser.can_manage_knowledge_base ? [{ id: 'access', label: 'Access' }] : []) ]; const AUDIENCE_OPTIONS = [ { value: '', label: '— Not set —' }, { value: 'Hotel', label: 'Hotel (Property Ops)' }, { value: 'Corporate', label: 'Corporate' }, { value: 'Restaurant', label: 'Restaurant / F&B' }, { value: 'Facilities', label: 'Facilities' }, { value: 'Policies', label: 'Policies' }, { value: 'Services', label: 'Services' }, { value: 'Other', label: 'Other' } ]; // Load existing attachments if editing (parallel mode starts fresh — each version has its own attachments) useEffect(() => { if (article?.id) { if (article?.versioning_mode === 'parallel') { setExistingAttachments([]); return; } fetch(`/api/knowledge-base/${article.id}/attachments`, { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }) .then(res => res.json()) .then(data => { if (data.success && data.attachments) { setExistingAttachments(data.attachments); } }) .catch(err => console.error('Error loading attachments:', err)); } }, [article?.id]); // Fetch users list, roles list, and ancestor permissions for access control UI useEffect(() => { fetch('/api/knowledge-base/users-list', { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }) .then(res => res.json()) .then(data => { if (data.success) setAllUsers(data.users || []); }) .catch(err => console.error('Error loading users:', err)); fetch('/api/knowledge-base/roles-list', { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }) .then(res => res.json()) .then(data => { if (data.success) setAllRoles(data.roles || []); }) .catch(err => console.error('Error loading roles:', err)); fetch('/api/knowledge-base/brands-list', { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }) .then(res => res.json()) .then(data => { if (data.success) setAllBrands(data.brands || []); }) .catch(err => console.error('Error loading brands:', err)); const parentId = article?.parent_id || formData.parent_id; if (parentId) { fetch(`/api/knowledge-base/ancestor-permissions/${parentId}`, { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }) .then(res => res.json()) .then(data => { if (data.success) setAncestorPerms(data.permissions); }) .catch(err => console.error('Error loading ancestor permissions:', err)); } }, []); // Auto-select ancestor-restricted properties/users/roles (inherited = locked, non-removable) useEffect(() => { if (!ancestorPerms) return; setFormData(prev => { const updated = { ...prev }; if (ancestorPerms.applies_to_properties) { const inherited = new Set(ancestorPerms.applies_to_properties); const current = new Set(prev.applies_to_properties); const merged = [...new Set([...inherited, ...current])]; if (merged.length !== current.size) updated.applies_to_properties = merged; } if (ancestorPerms.allowed_users) { const inherited = new Set(ancestorPerms.allowed_users); const current = new Set(prev.allowed_users); const merged = [...new Set([...inherited, ...current])]; if (merged.length !== current.size) updated.allowed_users = merged; } if (ancestorPerms.allowed_roles) { const inherited = new Set(ancestorPerms.allowed_roles); const current = new Set(prev.allowed_roles); const merged = [...new Set([...inherited, ...current])]; if (merged.length !== current.size) updated.allowed_roles = merged; } if (ancestorPerms.allowed_brands) { const inherited = new Set(ancestorPerms.allowed_brands); const current = new Set(prev.allowed_brands || []); const merged = [...new Set([...inherited, ...current])]; if (merged.length !== current.size) updated.allowed_brands = merged; } return updated; }); }, [ancestorPerms]); const handleFileSelect = (e) => { const files = Array.from(e.target.files); setSelectedFiles(prev => [...prev, ...files]); }; const removeSelectedFile = (idx) => { setSelectedFiles(prev => prev.filter((_, i) => i !== idx)); }; const handleSubmit = async (e) => { e.preventDefault(); // Validate required fields (title, and content-or-attachments for documents) const allAttachments = [ ...(existingAttachments || []), ...(selectedFiles || []) ]; const errors = (window.KBComponents && KBComponents.validateKBForm) ? KBComponents.validateKBForm(formData, allAttachments) : []; if (errors.length > 0) { window.otoNotify?.toast(errors.join('\n'), 'error'); return; } // Build category path from parent folders let category = 'General'; if (kbPath.length > 0) { category = kbPath.map(p => p.title).concat(formData.title).join('/'); } else if (formData.title) { category = formData.title; } // First, save the article/folder const itemData = { title: formData.title, content: formData.content, category: category, parent_id: formData.parent_id, is_folder: formData.is_folder, applies_to_properties: formData.applies_to_properties, allowed_users: formData.allowed_users, allowed_roles: formData.allowed_roles, allowed_brands: formData.allowed_brands || [], tags: formData.tags, versioning_mode: formData.versioning_mode, metadata: { audience: formData.audience || undefined, department: formData.department || undefined } }; try { // Save item first const saveResult = await onSave(itemData); const itemId = saveResult?.data?.id || article?.id; // If there are files to upload and we have an item ID (only for documents, not folders) if (selectedFiles.length > 0 && itemId && !formData.is_folder) { for (const file of selectedFiles) { const formData = new FormData(); formData.append('file', file); await fetch(`/api/knowledge-base/${itemId}/attachments`, { method: 'POST', headers: { 'X-OTO-User': currentUser.Username }, body: formData }); } } // Close modal after success onClose(); } catch (error) { console.error('Error saving:', error); window.otoNotify?.toast('Error saving. Please try again.', 'error'); } }; // Compute available property list (respecting ancestor restrictions) const availableProperties = (() => { const props = ancestorPerms?.applies_to_properties ? (userProperties || []).filter(p => ancestorPerms.applies_to_properties.includes(p.property_code)) : (userProperties || []); return props; })(); // Filter properties by search const filteredProperties = availableProperties.filter(p => { if (!propertySearch.trim()) return true; const q = propertySearch.toLowerCase(); return (p.property_code || '').toLowerCase().includes(q) || (p.name || '').toLowerCase().includes(q); }); // Compute visible users: filter by selected properties first, then by search const propertyFilteredUsers = (() => { let users = ancestorPerms?.allowed_users ? allUsers.filter(u => ancestorPerms.allowed_users.includes(u.username)) : allUsers; // If properties are selected on this item, further filter to users at those properties if (formData.applies_to_properties.length > 0) { users = users.filter(u => (u.property_code && formData.applies_to_properties.includes(u.property_code)) || (u.properties || []).some(pc => formData.applies_to_properties.includes(pc)) ); } return users; })(); // Then apply text search on top const filteredUsers = propertyFilteredUsers.filter(u => { if (!userSearch.trim()) return true; const q = userSearch.toLowerCase(); return (u.username || '').toLowerCase().includes(q) || (u.first_name || '').toLowerCase().includes(q) || (u.last_name || '').toLowerCase().includes(q) || (`${u.first_name} ${u.last_name}`).toLowerCase().includes(q); }); // Compute available roles (respecting ancestor restrictions) const availableRoles = ancestorPerms?.allowed_roles ? allRoles.filter(r => ancestorPerms.allowed_roles.includes(r.id)) : allRoles; // Filter roles by search const filteredRoles = availableRoles.filter(r => { if (!roleSearch.trim()) return true; const q = roleSearch.toLowerCase(); return (r.display_name || '').toLowerCase().includes(q) || (r.name || '').toLowerCase().includes(q); }); return (