fix: code review — security, dead code, performance, consistency
Critical fixes: - XSS: escapeHtml() on all user-supplied text in email notifications - Budget PATCH: added mutex lock + availability validation (prevents corruption) - batchResolveNames: fixed wrong signature for budget request earmark names Dead code cleanup: - Deleted 8 unused PostComposition* files (replaced by PostDetail full page) Performance: - budget-helpers: single-fetch with computeFromEntries(), optional prefetch param - post-composition: parallelized text + thumbnail fetches with Promise.all Consistency: - PostDetail.jsx: native <select> → PortalSelect (matches all panels) - Finance.jsx: 11 hardcoded English table headers → t() with i18n keys - PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys - App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback) - UploadZone: proper useRef pattern, no vanilla JS document.createElement - All file inputs: className="hidden" → absolute w-0 h-0 opacity-0 - ArtefactDetailPanel: removed campaign/project selects (inherited from post) - TranslationDetailPanel: removed brand/linked post selects (inherited from post) - ApproverMultiSelect: portal-based dropdown (fixes clipping in modals) - Thumbnail fix: post-composition constructs URL from filename (was undefined) - Upload fix: UploadZone with drag-and-drop for design + video artefacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
[ 3110815ms] [ERROR] %o
|
||||
|
||||
%s
|
||||
|
||||
%s
|
||||
ReferenceError: Upload is not defined
|
||||
at ArtefactDetailVersionsTab (http://localhost:5173/src/components/ArtefactDetailVersionsTab.jsx?t=1773656331074:286:42)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8525:20)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) The above error occurred in the <ArtefactDetailVersionsTab> component. React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. @ http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7000
|
||||
[ 3110816ms] [ERROR] ErrorBoundary caught: ReferenceError: Upload is not defined
|
||||
at ArtefactDetailVersionsTab (http://localhost:5173/src/components/ArtefactDetailVersionsTab.jsx?t=1773656331074:286:42)
|
||||
at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18509:20)
|
||||
at renderWithHooks (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:5654:24)
|
||||
at updateFunctionComponent (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:7475:21)
|
||||
at beginWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:8525:20)
|
||||
at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72)
|
||||
at performUnitOfWork (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12561:98)
|
||||
at workLoopSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12424:43)
|
||||
at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13)
|
||||
at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack:
|
||||
at ArtefactDetailVersionsTab (http://localhos…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
+1
-1
@@ -290,7 +290,7 @@ function AppContent() {
|
||||
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
|
||||
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="min-h-screen bg-surface-secondary flex items-center justify-center"><div className="animate-pulse text-text-tertiary">Loading...</div></div>}>
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="w-8 h-8 border-2 border-brand-primary/30 border-t-brand-primary rounded-full animate-spin" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronDown, X } from 'lucide-react'
|
||||
|
||||
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dropUp, setDropUp] = useState(false)
|
||||
const wrapperRef = useRef(null)
|
||||
const triggerRef = useRef(null)
|
||||
const dropdownRef = useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const dropdownHeight = Math.min(users.length * 40 + 8, 220)
|
||||
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
|
||||
|
||||
setPos({
|
||||
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
})
|
||||
}, [users.length])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
if (dropdownRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
const handleScroll = () => updatePosition()
|
||||
|
||||
// Detect if dropdown should open upward
|
||||
useEffect(() => {
|
||||
if (!open || !wrapperRef.current) return
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
setDropUp(spaceBelow < 220)
|
||||
}, [open])
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
const handleOpen = () => {
|
||||
updatePosition()
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const toggle = (userId) => {
|
||||
const id = String(userId)
|
||||
@@ -39,9 +60,10 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<>
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
ref={triggerRef}
|
||||
onClick={handleOpen}
|
||||
className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${
|
||||
open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
|
||||
}`}
|
||||
@@ -58,7 +80,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
|
||||
className="hover:text-amber-950"
|
||||
className="hover:text-amber-950 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -66,8 +88,13 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
))}
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg max-h-[220px] overflow-y-auto"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width }}
|
||||
>
|
||||
{users.map(u => {
|
||||
const uid = String(u._id || u.id || u.Id)
|
||||
const isSelected = selected.includes(uid)
|
||||
@@ -76,7 +103,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
key={uid}
|
||||
type="button"
|
||||
onClick={() => toggle(uid)}
|
||||
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
|
||||
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between transition-colors ${
|
||||
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
|
||||
}`}
|
||||
>
|
||||
@@ -88,8 +115,9 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
|
||||
{users.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
@@ -380,38 +381,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project & Campaign dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
|
||||
<select
|
||||
value={editProjectId}
|
||||
onChange={e => {
|
||||
setEditProjectId(e.target.value)
|
||||
handleUpdateField('project_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
|
||||
<select
|
||||
value={editCampaignId}
|
||||
onChange={e => {
|
||||
setEditCampaignId(e.target.value)
|
||||
handleUpdateField('campaign_id', e.target.value)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -500,21 +469,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={editApproverIds[0] || ''}
|
||||
onChange={e => {
|
||||
const val = e.target.value
|
||||
onChange={val => {
|
||||
const ids = val ? [val] : []
|
||||
setEditApproverIds(ids)
|
||||
handleUpdateField('approver_ids', val || '')
|
||||
}}
|
||||
options={[{ value: '', label: t('artefacts.selectReviewer') }, ...assignableUsers.map(u => ({ value: u.id || u.Id, label: u.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('artefacts.selectReviewer')}</option>
|
||||
{assignableUsers.map(u => (
|
||||
<option key={u.id || u.Id} value={u.id || u.Id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Upload, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
|
||||
import { Plus, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import UploadZone from './UploadZone'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import Modal from './Modal'
|
||||
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
||||
@@ -172,29 +174,17 @@ export function ArtefactDetailVersionsTab({
|
||||
{/* DESIGN TYPE: Image gallery */}
|
||||
{artefact.type === 'design' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
|
||||
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
|
||||
<Upload className="w-3 h-3" />
|
||||
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={onFileUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.imagesLabel')}</h4>
|
||||
|
||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{versionData.attachments && versionData.attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
{versionData.attachments.map(att => (
|
||||
<div key={att.Id} className="relative group">
|
||||
<img
|
||||
src={att.url}
|
||||
alt={att.original_name}
|
||||
className="w-full h-48 object-cover rounded-lg border border-border"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
|
||||
<button
|
||||
@@ -210,12 +200,16 @@ export function ArtefactDetailVersionsTab({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
|
||||
</div>
|
||||
)}
|
||||
<UploadZone
|
||||
onUpload={onFileUpload}
|
||||
accept="image/*"
|
||||
uploading={uploading}
|
||||
progress={uploadProgress}
|
||||
label={t('artefacts.dropOrClickImage') || 'Drop images here or click to upload'}
|
||||
hint={t('artefacts.imageFormats') || 'PNG, JPG, WebP'}
|
||||
compact={versionData.attachments?.length > 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -256,30 +250,14 @@ export function ArtefactDetailVersionsTab({
|
||||
)}
|
||||
|
||||
{/* Drag-and-drop / click-to-upload zone */}
|
||||
<label
|
||||
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
||||
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
||||
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleVideoDrop}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-7 h-7 text-text-tertiary" />
|
||||
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
|
||||
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" className="hidden" accept="video/*" onChange={onFileUpload} disabled={uploading} />
|
||||
</label>
|
||||
<UploadZone
|
||||
onUpload={onFileUpload}
|
||||
accept="video/*"
|
||||
uploading={uploading}
|
||||
progress={uploadProgress}
|
||||
label={t('artefacts.dropOrClickVideo')}
|
||||
hint={t('artefacts.videoFormats')}
|
||||
/>
|
||||
|
||||
{/* Google Drive URL inline input */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
@@ -311,23 +289,21 @@ export function ArtefactDetailVersionsTab({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||
onChange={val => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === val)
|
||||
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
||||
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('artefacts.selectLanguage')}</option>
|
||||
{AVAILABLE_LANGUAGES
|
||||
options={[
|
||||
{ value: '', label: t('artefacts.selectLanguage') },
|
||||
...AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
.map(lang => ({ value: lang.code, label: `${lang.label} (${lang.code})` }))
|
||||
]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
|
||||
|
||||
@@ -6,6 +6,7 @@ import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import BudgetBar from './BudgetBar'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
|
||||
@@ -189,38 +190,33 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.brand_id}
|
||||
onChange={e => update('brand_id', e.target.value)}
|
||||
onChange={val => update('brand_id', val)}
|
||||
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b.id || b._id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">Select brand</option>
|
||||
{(brands || []).map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={statusOptions}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.team_id}
|
||||
onChange={e => update('team_id', e.target.value)}
|
||||
onChange={val => update('team_id', val)}
|
||||
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.noTeam')}</option>
|
||||
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
|
||||
import { Copy, Eye, Lock, Send, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
|
||||
import UploadZone from './UploadZone'
|
||||
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import Modal from './Modal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
|
||||
const { brands } = useContext(AppContext)
|
||||
@@ -284,67 +286,53 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
{/* Assigned To */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={assignedTo}
|
||||
onChange={(e) => handleAssignmentChange(e.target.value)}
|
||||
onChange={val => handleAssignmentChange(val)}
|
||||
options={[{ value: '', label: t('issues.unassigned') }, ...teamMembers.map(member => ({ value: member.id || member._id, label: member.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('issues.unassigned')}</option>
|
||||
{teamMembers.map((member) => (
|
||||
<option key={member.id || member._id} value={member.id || member._id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
{teams.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={teamId}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null
|
||||
setTeamId(val || '')
|
||||
onChange={async (val) => {
|
||||
const resolvedVal = val || null
|
||||
setTeamId(resolvedVal || '')
|
||||
try {
|
||||
await api.patch(`/issues/${issueId}`, { team_id: val })
|
||||
await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
|
||||
await onUpdate()
|
||||
await loadIssueDetails()
|
||||
} catch (err) {
|
||||
console.error('Failed to update team:', err)
|
||||
}
|
||||
}}
|
||||
options={[{ value: '', label: t('issues.allTeams') }, ...teams.map(team => ({ value: team.id || team._id, label: team.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('issues.allTeams')}</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={issueData.brand_id || ''}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null;
|
||||
onChange={async (val) => {
|
||||
const resolvedVal = val || null;
|
||||
try {
|
||||
await api.patch(`/issues/${issueId}`, { brand_id: val });
|
||||
await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
|
||||
loadIssueDetails();
|
||||
onUpdate();
|
||||
} catch {}
|
||||
}}
|
||||
options={[{ value: '', label: t('issues.noBrand') }, ...(brands || []).map(b => ({ value: b._id || b.Id, label: b.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('issues.noBrand')}</option>
|
||||
{(brands || []).map((b) => (
|
||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Internal Notes */}
|
||||
@@ -501,15 +489,12 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
{activeTab === 'attachments' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Upload */}
|
||||
<label className="block">
|
||||
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
|
||||
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
|
||||
<p className="text-sm text-text-secondary">
|
||||
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<UploadZone
|
||||
onUpload={handleFileUpload}
|
||||
uploading={uploadingFile}
|
||||
label={t('issues.clickToUpload')}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Attachments List */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Portal-based select dropdown that renders options outside any overflow/stacking context.
|
||||
* Drop-in replacement for <select> inside SlidePanel/TabbedModal/Modal.
|
||||
*
|
||||
* Props:
|
||||
* value - current value
|
||||
* onChange - (value) => void
|
||||
* options - [{ value, label }] or children-based (fallback to native if no options)
|
||||
* placeholder - text when no value selected
|
||||
* className - additional classes on the trigger button
|
||||
* disabled - boolean
|
||||
*/
|
||||
export default function PortalSelect({ value, onChange, options = [], placeholder = '—', className = '', disabled = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef(null)
|
||||
const dropdownRef = useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
|
||||
const selectedOption = options.find(o => String(o.value) === String(value))
|
||||
const displayText = selectedOption?.label || placeholder
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const dropdownHeight = Math.min(options.length * 32 + 8, 240)
|
||||
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
|
||||
|
||||
setPos({
|
||||
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 160),
|
||||
})
|
||||
}, [options.length])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (disabled) return
|
||||
updatePosition()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleSelect = (val) => {
|
||||
onChange(val)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e) => {
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
if (dropdownRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
const handleScroll = () => updatePosition()
|
||||
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-between gap-1 text-start ${className} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`truncate ${selectedOption ? '' : 'text-text-tertiary'}`}>{displayText}</span>
|
||||
<ChevronDown className={`w-3 h-3 shrink-0 text-text-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg overflow-y-auto animate-scale-in"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: 240 }}
|
||||
>
|
||||
{options.map(opt => {
|
||||
const isSelected = String(opt.value) === String(value)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-start transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-primary/10 text-brand-primary font-medium'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
{isSelected && <Check className="w-3 h-3 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-tertiary text-center">—</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const CAPTION_LIMITS = { instagram: 2200, tiktok: 4000, twitter: 280, linkedin: 3000, facebook: 63206, youtube: 5000, snapchat: 250 }
|
||||
|
||||
export default function PostCompositionCaption({ caption, onChange, disabled, platforms = [] }) {
|
||||
const { t } = useLanguage()
|
||||
const len = (caption || '').length
|
||||
const minLimit = platforms.length > 0
|
||||
? Math.min(...platforms.map(p => CAPTION_LIMITS[p] || 5000))
|
||||
: null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
value={caption || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={t('post.captionPlaceholder')}
|
||||
rows={5}
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-primary/30 focus:border-brand-primary disabled:opacity-50 disabled:cursor-not-allowed resize-y"
|
||||
/>
|
||||
{len > 0 && (
|
||||
<div className={`flex justify-end mt-1 text-[10px] ${minLimit && len > minLimit ? 'text-red-500' : 'text-text-tertiary'}`}>
|
||||
{len}{minLimit ? ` / ${minLimit}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Clock, Pencil, Link, Plus } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { AVAILABLE_LANGUAGES } from '../utils/translations'
|
||||
|
||||
const STATUS_ICON = {
|
||||
approved: { Icon: Check, color: 'text-emerald-500' },
|
||||
in_review: { Icon: Clock, color: 'text-amber-500' },
|
||||
pending_review: { Icon: Clock, color: 'text-amber-500' },
|
||||
draft: { Icon: Pencil, color: 'text-text-tertiary' },
|
||||
}
|
||||
|
||||
export default function PostCompositionCopy({ copy = [], onLink, onCreate, onOpen }) {
|
||||
const { t } = useLanguage()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newLang, setNewLang] = useState('')
|
||||
|
||||
const existingLangs = copy.map(c => c.language?.toUpperCase())
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newLang) return
|
||||
onCreate?.(newLang)
|
||||
setShowCreate(false)
|
||||
setNewLang('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{copy.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{copy.map(item => {
|
||||
const { Icon, color } = STATUS_ICON[item.status] || STATUS_ICON.draft
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onOpen?.(item.id)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-surface-secondary border border-border-light px-3 py-1 text-xs font-medium text-text-primary hover:border-brand-primary transition-colors"
|
||||
>
|
||||
<span className="uppercase">{item.language}</span>
|
||||
<Icon className={`w-3 h-3 ${color}`} />
|
||||
{item.is_original && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" title="Original" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onLink}
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors">
|
||||
<Link className="w-3 h-3" /> {t('post.linkTranslation')}
|
||||
</button>
|
||||
|
||||
{!showCreate ? (
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors">
|
||||
<Plus className="w-3 h-3" /> {t('post.createNew')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<select value={newLang} onChange={e => setNewLang(e.target.value)}
|
||||
className="text-xs border border-border rounded px-2 py-1 bg-surface text-text-secondary focus:outline-none" autoFocus>
|
||||
<option value="">{t('post.selectLanguage') || 'Language...'}</option>
|
||||
{AVAILABLE_LANGUAGES.filter(l => !existingLangs.includes(l.code)).map(l => (
|
||||
<option key={l.code} value={l.code}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={handleCreate} disabled={!newLang}
|
||||
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium disabled:opacity-40 transition-colors">
|
||||
{t('common.create')}
|
||||
</button>
|
||||
<button onClick={() => { setShowCreate(false); setNewLang('') }}
|
||||
className="text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{copy.length === 0 && !showCreate && (
|
||||
<p className="text-xs text-text-tertiary text-center py-2">{t('post.noCopyLinked')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Image, Link, Plus } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
export default function PostCompositionDesigns({ designs = [], onLink, onCreate, onOpen }) {
|
||||
const { t } = useLanguage()
|
||||
const total = designs.length
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{total > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{designs.map((design, idx) => (
|
||||
<button
|
||||
key={design.id}
|
||||
onClick={() => onOpen?.(design.id)}
|
||||
className="flex items-start gap-2 bg-surface-secondary rounded-lg border border-border-light p-2 hover:border-brand-primary transition-colors text-start"
|
||||
>
|
||||
<div className="w-16 aspect-square rounded bg-surface flex-shrink-0 overflow-hidden relative">
|
||||
{design.thumbnail_url ? (
|
||||
<img
|
||||
src={design.thumbnail_url}
|
||||
alt={design.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image className="w-5 h-5 text-text-quaternary" />
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute top-0.5 end-0.5 text-[9px] font-mono bg-black/60 text-white rounded px-1">
|
||||
{idx + 1}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 py-0.5">
|
||||
<p className="text-xs font-medium text-text-primary truncate max-w-[120px]">
|
||||
{design.title}
|
||||
</p>
|
||||
<StatusBadge status={design.status} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onLink}
|
||||
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
|
||||
>
|
||||
+ {t('post.addDesign')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface-secondary rounded-lg border border-border-light p-3 text-center">
|
||||
<p className="text-xs text-text-tertiary mb-2">{t('post.noDesignsLinked')}</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={onLink}
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
|
||||
>
|
||||
<Link className="w-3 h-3" />
|
||||
{t('post.addDesign')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('post.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { getFormatsForPlatforms } from '../utils/platformFormats'
|
||||
|
||||
export default function PostCompositionFormats({ platforms = [] }) {
|
||||
const { t } = useLanguage()
|
||||
const formats = getFormatsForPlatforms(platforms)
|
||||
const [checked, setChecked] = useState(new Set())
|
||||
|
||||
const toggle = (key) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (formats.length === 0) {
|
||||
return <p className="text-xs text-text-tertiary italic">{t('post.selectPlatforms')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{formats.map(f => {
|
||||
const isChecked = checked.has(f.key)
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => toggle(f.key)}
|
||||
className={`flex items-center gap-2 w-full rounded-lg px-2 py-1.5 transition-colors text-start ${
|
||||
isChecked ? 'bg-brand-primary/10' : 'hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isChecked
|
||||
? 'bg-brand-primary border-brand-primary'
|
||||
: 'border-border'
|
||||
}`}>
|
||||
{isChecked && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm ${isChecked ? 'text-brand-primary font-medium' : 'text-text-primary'}`}>
|
||||
{f.label}
|
||||
</span>
|
||||
<span className="ms-auto text-[10px] font-mono text-text-quaternary bg-surface-secondary rounded px-1.5 py-0.5">
|
||||
{f.ratio}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function PostCompositionLinkPicker({ items = [], onSelect, onCancel, searchPlaceholder, loading }) {
|
||||
const { t } = useLanguage()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filtered = items.filter(c =>
|
||||
!search || (c.title || c.name || '').toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-border bg-surface-secondary p-2 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder || t('common.search')}
|
||||
className="w-full ps-8 pe-3 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto divide-y divide-border-light">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary py-3 text-center">{t('common.noResults')}</p>
|
||||
) : (
|
||||
filtered.map(item => (
|
||||
<button
|
||||
key={item.Id || item.id}
|
||||
onClick={() => onSelect(item.Id || item.id)}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-between px-2 py-2 hover:bg-surface transition-colors text-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-text-primary truncate">{item.title || item.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{item.language && (
|
||||
<span className="text-[10px] uppercase font-medium text-text-tertiary bg-surface rounded px-1 py-0.5">{item.language}</span>
|
||||
)}
|
||||
{item.type && (
|
||||
<span className="text-[10px] text-text-tertiary bg-surface rounded px-1 py-0.5">{item.type}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-brand-primary font-medium shrink-0 ms-2">{t('post.linkExisting')}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Trash2, Save, FileText, Image as ImageIcon, Film, LayoutGrid, CheckCircle, Calendar } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import { useToast } from './ToastContainer'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import TranslationDetailPanel from './TranslationDetailPanel'
|
||||
import ArtefactDetailPanel from './ArtefactDetailPanel'
|
||||
import PostCompositionCaption from './PostCompositionCaption'
|
||||
import PostCompositionCopy from './PostCompositionCopy'
|
||||
import PostCompositionDesigns from './PostCompositionDesigns'
|
||||
import PostCompositionVideo from './PostCompositionVideo'
|
||||
import PostCompositionFormats from './PostCompositionFormats'
|
||||
import PostCompositionReadiness from './PostCompositionReadiness'
|
||||
import PostCompositionLinkPicker from './PostCompositionLinkPicker'
|
||||
|
||||
const STAGES = ['copy', 'translate', 'design', 'post']
|
||||
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
|
||||
const selectCls = 'border border-border rounded px-2 py-1 bg-surface text-text-secondary text-xs focus:outline-none'
|
||||
|
||||
function Section({ icon: Icon, label, children }) {
|
||||
return (
|
||||
<div className="px-5 py-4">
|
||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-2 flex items-center gap-1.5">
|
||||
{Icon && <Icon className="w-3.5 h-3.5" />} {label}
|
||||
</h4>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PostCompositionPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
|
||||
const { t, lang } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [composition, setComposition] = useState(null)
|
||||
const [postId, setPostId] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [activePicker, setActivePicker] = useState(null)
|
||||
const [linkCandidates, setLinkCandidates] = useState([])
|
||||
const [linking, setLinking] = useState(false)
|
||||
const [openTranslation, setOpenTranslation] = useState(null)
|
||||
const [openArtefact, setOpenArtefact] = useState(null)
|
||||
|
||||
const isCreateMode = !postId
|
||||
|
||||
useEffect(() => {
|
||||
if (!post) return
|
||||
const id = post._id || post.id || null
|
||||
setPostId(id)
|
||||
setForm({
|
||||
title: post.title || '', brand_id: post.brandId || post.brand_id || '',
|
||||
campaign_id: post.campaignId || post.campaign_id || '',
|
||||
assigned_to: post.assignedTo || post.assigned_to || '',
|
||||
platforms: post.platforms || (post.platform ? [post.platform] : []),
|
||||
status: post.status || 'draft', caption: post.caption || '',
|
||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 10) : (post.scheduled_date ? new Date(post.scheduled_date).toISOString().slice(0, 10) : ''),
|
||||
stage: post.stage || 'copy',
|
||||
})
|
||||
setDirty(!id); setComposition(null); setActivePicker(null)
|
||||
if (id) loadComposition(id)
|
||||
}, [post])
|
||||
|
||||
const loadComposition = useCallback(async (id) => {
|
||||
const pid = id || postId
|
||||
if (!pid) return
|
||||
try { setComposition(await api.get(`/posts/${pid}/composition`)) }
|
||||
catch (err) { console.error('Failed to load composition:', err) }
|
||||
}, [postId])
|
||||
|
||||
const update = (field, value) => { setForm(f => ({ ...f, [field]: value })); setDirty(true) }
|
||||
|
||||
const togglePlatform = (key) => {
|
||||
setForm(f => ({ ...f, platforms: f.platforms.includes(key) ? f.platforms.filter(p => p !== key) : [...f.platforms, key] }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
title: form.title, brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
||||
assigned_to: form.assigned_to ? Number(form.assigned_to) : null,
|
||||
status: form.status, platforms: form.platforms || [],
|
||||
caption: form.caption || '', scheduled_date: form.scheduled_date || null,
|
||||
}
|
||||
const result = await onSave(isCreateMode ? null : postId, data)
|
||||
setDirty(false)
|
||||
if (isCreateMode && result) {
|
||||
const newId = result._id || result.id
|
||||
setPostId(newId)
|
||||
setForm(f => ({ ...f, stage: result.stage || 'copy' }))
|
||||
loadComposition(newId)
|
||||
toast.success(t('posts.created'))
|
||||
}
|
||||
} catch { toast.error(t('common.saveFailed')) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const createAsset = async (endpoint, body) => {
|
||||
if (!postId) return
|
||||
try { await api.post(endpoint, body); loadComposition(); toast.success(t('common.success')) }
|
||||
catch { toast.error(t('common.saveFailed')) }
|
||||
}
|
||||
|
||||
const openLinkPicker = async (type) => {
|
||||
setActivePicker(type)
|
||||
try {
|
||||
if (type === 'copy') {
|
||||
const all = await api.get('/translations')
|
||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(t => !t.post_id))
|
||||
} else {
|
||||
const all = await api.get('/artefacts')
|
||||
const at = type === 'video' ? 'video' : 'design'
|
||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => !a.post_id && (a.type || 'design') === at))
|
||||
}
|
||||
} catch { setLinkCandidates([]) }
|
||||
}
|
||||
|
||||
const handleLink = async (itemId) => {
|
||||
setLinking(true)
|
||||
try {
|
||||
await api.patch(activePicker === 'copy' ? `/translations/${itemId}` : `/artefacts/${itemId}`, { post_id: postId })
|
||||
toast.success(t('common.success')); setActivePicker(null); loadComposition()
|
||||
} catch { toast.error(t('common.error')) }
|
||||
finally { setLinking(false) }
|
||||
}
|
||||
|
||||
const handleOpenCopy = async (id) => {
|
||||
try { setOpenTranslation(await api.get(`/translations/${id}`)) } catch { toast.error(t('common.error')) }
|
||||
}
|
||||
const handleOpenAsset = async (id) => {
|
||||
try { setOpenArtefact(await api.get(`/artefacts/${id}`)) } catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
const handleSignOff = async () => {
|
||||
setSaving(true)
|
||||
try { await onSave(postId, { ...form, status: 'approved' }); setForm(f => ({ ...f, status: 'approved' })); setDirty(false) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const waitingOn = composition ? [
|
||||
...(composition.copy?.length === 0 ? [t('post.copy')] : []),
|
||||
...(composition.designs?.length === 0 ? [t('post.designs')] : []),
|
||||
...(composition.waiting_on || []),
|
||||
] : []
|
||||
|
||||
if (!post) return null
|
||||
|
||||
const picker = (type, placeholder) => activePicker === type && (
|
||||
<PostCompositionLinkPicker items={linkCandidates} onSelect={handleLink}
|
||||
onCancel={() => setActivePicker(null)} searchPlaceholder={placeholder} loading={linking} />
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border bg-surface sticky top-0 z-10">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input type="text" value={form.title || ''} onChange={e => update('title', e.target.value)}
|
||||
className="flex-1 text-base font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||
placeholder={t('posts.postTitlePlaceholder')} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<select value={form.status || 'draft'} onChange={e => update('status', e.target.value)} className={selectCls}>
|
||||
{STATUS_OPTS.map(s => <option key={s} value={s}>{t(`posts.status.${s}`)}</option>)}
|
||||
</select>
|
||||
<select value={form.brand_id || ''} onChange={e => update('brand_id', e.target.value)} className={selectCls}>
|
||||
<option value="">{t('posts.brand')}</option>
|
||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
<select value={form.campaign_id || ''} onChange={e => update('campaign_id', e.target.value)} className={selectCls}>
|
||||
<option value="">{t('campaigns.title')}</option>
|
||||
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<select value={form.assigned_to || ''} onChange={e => update('assigned_to', e.target.value)} className={selectCls}>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{Object.entries(PLATFORMS).map(([key, p]) => (
|
||||
<button key={key} onClick={() => togglePlatform(key)}
|
||||
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors ${
|
||||
(form.platforms || []).includes(key)
|
||||
? 'border-brand-primary bg-brand-primary/10 text-brand-primary font-medium'
|
||||
: 'border-border-light text-text-tertiary hover:border-brand-primary/30'
|
||||
}`}>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex items-center gap-1 border border-border rounded px-2 py-1 bg-surface text-text-secondary">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<input type="date" value={form.scheduled_date || ''} onChange={e => update('scheduled_date', e.target.value)}
|
||||
className="bg-transparent text-xs border-0 p-0 focus:outline-none w-24" />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{onDelete && !isCreateMode && (
|
||||
<button onClick={showDeleteConfirm ? async () => { setShowDeleteConfirm(false); await onDelete(postId); onClose() } : () => setShowDeleteConfirm(true)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${showDeleteConfirm ? 'text-red-500 bg-red-50' : 'text-text-tertiary hover:text-red-500 hover:bg-red-50'}`}
|
||||
title={showDeleteConfirm ? t('common.confirm') : t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleSave} disabled={!form.title || saving || !dirty}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{isCreateMode ? t('posts.createPost') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
<div className="divide-y divide-border">
|
||||
{!isCreateMode && (
|
||||
<div className="px-5 py-3 flex items-center gap-1">
|
||||
{STAGES.map((stage, idx) => {
|
||||
const ci = STAGES.indexOf(form.stage || 'copy')
|
||||
return (
|
||||
<div key={stage} className="flex items-center gap-1">
|
||||
{idx > 0 && <div className={`w-4 h-px ${idx <= ci ? 'bg-brand-primary' : 'bg-border'}`} />}
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full capitalize ${
|
||||
idx === ci ? 'bg-brand-primary text-white font-medium'
|
||||
: idx < ci ? 'bg-brand-primary/10 text-brand-primary'
|
||||
: 'bg-surface-secondary text-text-tertiary'}`}>{stage}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section label={t('post.caption')}>
|
||||
<PostCompositionCaption caption={form.caption} onChange={v => update('caption', v)} disabled={false} platforms={form.platforms || []} />
|
||||
</Section>
|
||||
|
||||
{!isCreateMode && composition && (
|
||||
<>
|
||||
<Section icon={FileText} label={t('post.copy')}>
|
||||
<PostCompositionCopy copy={composition.copy || []} onLink={() => openLinkPicker('copy')}
|
||||
onCreate={(lang) => createAsset('/translations', { post_id: postId, language: lang, is_original: (composition.copy || []).length === 0, title: form.title })}
|
||||
onOpen={handleOpenCopy} />
|
||||
{picker('copy', t('post.linkTranslation'))}
|
||||
</Section>
|
||||
|
||||
<Section icon={ImageIcon} label={t('post.designs')}>
|
||||
<PostCompositionDesigns designs={composition.designs || []} onLink={() => openLinkPicker('design')}
|
||||
onCreate={() => createAsset('/artefacts', { post_id: postId, type: 'design', title: 'Design for ' + form.title })}
|
||||
onOpen={handleOpenAsset} />
|
||||
{picker('design', t('post.addDesign'))}
|
||||
</Section>
|
||||
|
||||
<Section icon={Film} label={t('post.video')}>
|
||||
<PostCompositionVideo video={composition.video || null} onLink={() => openLinkPicker('video')}
|
||||
onCreate={() => createAsset('/artefacts', { post_id: postId, type: 'video', title: 'Video for ' + form.title })}
|
||||
onOpen={handleOpenAsset} />
|
||||
{picker('video', t('post.addVideo'))}
|
||||
</Section>
|
||||
|
||||
{(form.platforms || []).length > 0 && (
|
||||
<Section icon={LayoutGrid} label={t('post.formatChecklist')}>
|
||||
<PostCompositionFormats platforms={form.platforms} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section icon={CheckCircle} label={t('post.readiness')}>
|
||||
<PostCompositionReadiness piecesReady={!isCreateMode && composition?.pieces_ready}
|
||||
waitingOn={waitingOn} onSignOff={handleSignOff} />
|
||||
</Section>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
<CommentsSection entityType="post" entityId={postId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SlidePanel>
|
||||
|
||||
{openTranslation && (
|
||||
<TranslationDetailPanel translation={openTranslation}
|
||||
onClose={() => { setOpenTranslation(null); loadComposition() }} onUpdate={() => loadComposition()} />
|
||||
)}
|
||||
{openArtefact && (
|
||||
<ArtefactDetailPanel artefact={openArtefact}
|
||||
onClose={() => { setOpenArtefact(null); loadComposition() }} onUpdate={() => loadComposition()}
|
||||
projects={[]} campaigns={campaigns || []} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function PostCompositionReadiness({ piecesReady, waitingOn = [], onSignOff }) {
|
||||
const { t } = useLanguage()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
const handleSignOff = () => {
|
||||
setShowConfirm(false)
|
||||
onSignOff?.()
|
||||
}
|
||||
|
||||
if (piecesReady) {
|
||||
return (
|
||||
<div className="rounded-lg border border-brand-primary/30 bg-brand-primary/5 p-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-brand-primary" />
|
||||
<p className="text-sm font-medium text-brand-primary">
|
||||
{t('post.allPiecesReady')}
|
||||
</p>
|
||||
</div>
|
||||
{showConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-text-secondary">{t('post.signOffConfirm')}</p>
|
||||
<button
|
||||
onClick={handleSignOff}
|
||||
className="px-3 py-1 rounded-lg bg-brand-primary text-white text-xs font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="px-3 py-1 rounded-lg border border-border text-xs text-text-secondary hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="px-5 py-2 rounded-lg bg-brand-primary text-sm font-medium text-white hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
{t('post.signOff')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/40 dark:bg-amber-950/20 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-amber-500" />
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
{t('post.waitingOn')}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{waitingOn.map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Video, Link, Plus, Play } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
export default function PostCompositionVideo({ video, onLink, onCreate, onOpen }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{video ? (
|
||||
<button
|
||||
onClick={() => onOpen?.(video.id)}
|
||||
className="flex items-start gap-2 w-full bg-surface-secondary rounded-lg border border-border-light p-2 hover:border-brand-primary transition-colors text-start"
|
||||
>
|
||||
<div className="w-16 aspect-square rounded bg-surface flex-shrink-0 overflow-hidden relative">
|
||||
{video.thumbnail_url ? (
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Video className="w-5 h-5 text-text-quaternary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-6 h-6 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<Play className="w-3 h-3 text-white fill-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 py-0.5">
|
||||
<p className="text-xs font-medium text-text-primary truncate">{video.title}</p>
|
||||
<StatusBadge status={video.status} />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-surface-secondary rounded-lg border border-border-light p-3 text-center">
|
||||
<p className="text-xs text-text-tertiary mb-2">{t('post.noVideoLinked')}</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={onLink}
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
|
||||
>
|
||||
<Link className="w-3 h-3" />
|
||||
{t('post.addVideo')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('post.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { api, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { AppContext } from '../App'
|
||||
|
||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||
@@ -186,49 +187,42 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.brand_id}
|
||||
onChange={e => update('brand_id', e.target.value)}
|
||||
onChange={val => update('brand_id', val)}
|
||||
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b._id || b.id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">Select brand</option>
|
||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={statusOptions}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.owner_id}
|
||||
onChange={e => update('owner_id', e.target.value)}
|
||||
onChange={val => update('owner_id', val)}
|
||||
options={[{ value: '', label: t('common.unassigned') }, ...(teamMembers || []).map(m => ({ value: m._id || m.id, label: m.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.team_id}
|
||||
onChange={e => update('team_id', e.target.value)}
|
||||
onChange={val => update('team_id', val)}
|
||||
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.noTeam')}</option>
|
||||
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,7 +283,8 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
||||
ref={thumbnailInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
className="absolute w-0 h-0 opacity-0 pointer-events-none"
|
||||
tabIndex={-1}
|
||||
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLanguage } from '../i18n/LanguageContext'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
@@ -293,16 +294,12 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.project_id}
|
||||
onChange={e => update('project_id', e.target.value)}
|
||||
onChange={val => update('project_id', val)}
|
||||
options={[{ value: '', label: t('tasks.noProject') }, ...(projects || []).map(p => ({ value: p._id || p.id, label: p.name || p.title }))]}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('tasks.noProject')}</option>
|
||||
{(projects || []).map(p => (
|
||||
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
@@ -314,43 +311,33 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('assigned_to', e.target.value)}
|
||||
onChange={val => update('assigned_to', val)}
|
||||
options={[{ value: '', label: t('common.unassigned') }, ...(users || []).map(m => ({ value: m._id || m.team_member_id, label: m.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(users || []).map(m => (
|
||||
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority & Status */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.priority}
|
||||
onChange={e => update('priority', e.target.value)}
|
||||
onChange={val => update('priority', val)}
|
||||
options={priorityOptions}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{priorityOptions.map(p => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={statusOptions}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{statusOptions.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -494,7 +481,8 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
className="absolute w-0 h-0 opacity-0 pointer-events-none"
|
||||
tabIndex={-1}
|
||||
onChange={e => {
|
||||
setUploadError(null)
|
||||
const files = Array.from(e.target.files || [])
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToast } from './ToastContainer'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import PortalSelect from './PortalSelect'
|
||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||
|
||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||
@@ -231,13 +232,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
{userRole === 'superadmin' && !isEditingSelf && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.permission_level}
|
||||
onChange={e => update('permission_level', e.target.value)}
|
||||
onChange={val => update('permission_level', val)}
|
||||
options={PERMISSION_LEVELS.map(p => ({ value: p.value, label: p.label }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -252,14 +252,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.role_id || ''}
|
||||
onChange={e => update('role_id', e.target.value ? Number(e.target.value) : null)}
|
||||
onChange={val => update('role_id', val ? Number(val) : null)}
|
||||
options={[{ value: '', label: t('team.selectRole') }, ...roles.map(r => ({ value: r.Id || r.id, label: r.name }))]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('team.selectRole')}</option>
|
||||
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PLATFORMS } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import BudgetBar from './BudgetBar'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social' },
|
||||
@@ -156,29 +157,21 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.type}
|
||||
onChange={e => update('type', e.target.value)}
|
||||
onChange={val => update('type', val)}
|
||||
options={Object.entries(TRACK_TYPES).map(([k, v]) => ({ value: k, label: v.label }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
{Object.entries(TRACK_TYPES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.platform}
|
||||
onChange={e => update('platform', e.target.value)}
|
||||
onChange={val => update('platform', val)}
|
||||
options={[{ value: '', label: 'All / Multiple' }, ...Object.entries(PLATFORMS).map(([k, v]) => ({ value: k, label: v.label })), { value: 'google_ads', label: 'Google Ads' }]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
<option value="">All / Multiple</option>
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
<option value="google_ads">Google Ads</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,15 +188,12 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
onChange={val => update('status', val)}
|
||||
options={TRACK_STATUSES.map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
{TRACK_STATUSES.map(s => (
|
||||
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
import PortalSelect from './PortalSelect'
|
||||
|
||||
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
|
||||
const { t } = useLanguage()
|
||||
@@ -296,14 +297,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={editSourceLanguage}
|
||||
onChange={e => setEditSourceLanguage(e.target.value)}
|
||||
onChange={val => setEditSourceLanguage(val)}
|
||||
disabled={isApproved}
|
||||
options={AVAILABLE_LANGUAGES.map(l => ({ value: l.code, label: `${l.label} (${l.code})` }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 disabled:opacity-60 disabled:cursor-default"
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -317,66 +317,6 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
|
||||
<select
|
||||
value={translation.brand_id || ''}
|
||||
onChange={e => handleFieldUpdate('brand_id', e.target.value)}
|
||||
disabled={isApproved}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 disabled:opacity-60 disabled:cursor-default"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.linkedPost')}</h4>
|
||||
{isApproved ? (
|
||||
<p className="px-3 py-2 text-sm text-text-secondary">{translation.post_name || '—'}</p>
|
||||
) : showCreatePost ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPostTitle}
|
||||
onChange={e => setNewPostTitle(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreatePost()}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
placeholder={t('translations.newPostTitle')}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreatePost}
|
||||
disabled={creatingPost || !newPostTitle.trim()}
|
||||
className="px-2 py-2 bg-brand-primary text-white text-xs rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
|
||||
>
|
||||
{creatingPost ? '...' : t('common.create')}
|
||||
</button>
|
||||
<button onClick={() => setShowCreatePost(false)} className="text-xs text-text-secondary hover:text-text-primary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={translation.post_id || ''}
|
||||
onChange={e => handleFieldUpdate('post_id', e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{posts.map(p => <option key={p.Id || p.id || p._id} value={p.Id || p.id || p._id}>{p.title}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowCreatePost(true)}
|
||||
className="p-2 text-brand-primary hover:text-brand-primary/80"
|
||||
title={t('translations.createPost')}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.approversLabel')}</h4>
|
||||
@@ -574,17 +514,15 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={langForm.language_code}
|
||||
onChange={e => setLangForm(f => ({ ...f, language_code: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('translations.selectLanguage')}</option>
|
||||
{targetLanguages.map(l => {
|
||||
onChange={val => setLangForm(f => ({ ...f, language_code: val }))}
|
||||
options={[{ value: '', label: t('translations.selectLanguage') }, ...targetLanguages.map(l => {
|
||||
const count = textsByLanguage[l.code]?.length || 0
|
||||
return <option key={l.code} value={l.code}>{l.label} ({l.code}){count > 0 ? ` — ${count} ${t('translations.existing')}` : ''}</option>
|
||||
})}
|
||||
</select>
|
||||
return { value: l.code, label: `${l.label} (${l.code})${count > 0 ? ` — ${count} ${t('translations.existing')}` : ''}` }
|
||||
})]}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function UploadZone({
|
||||
onUpload,
|
||||
accept = '*',
|
||||
uploading = false,
|
||||
progress = 0,
|
||||
label,
|
||||
hint,
|
||||
compact = false,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
const processFiles = (files) => {
|
||||
const list = Array.from(files)
|
||||
const filtered = accept === '*' ? list : list.filter(f => {
|
||||
if (accept.endsWith('/*')) return f.type.startsWith(accept.replace('/*', '/'))
|
||||
return f.type === accept
|
||||
})
|
||||
if (filtered.length === 0) return
|
||||
if (multiple) filtered.forEach(f => onUpload(f))
|
||||
else onUpload(filtered[0])
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (uploading || disabled) return
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
processFiles(e.target.files || [])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
if (uploading || disabled) return
|
||||
processFiles(e.dataTransfer.files || [])
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`flex flex-col items-center gap-2 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
||||
compact ? 'px-4 py-4' : 'px-6 py-6'
|
||||
} ${
|
||||
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
||||
} ${
|
||||
(uploading || disabled) ? 'pointer-events-none opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleChange}
|
||||
className="absolute w-0 h-0 opacity-0 pointer-events-none"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-brand-primary h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t('artefacts.uploading')} {progress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className={compact ? 'w-5 h-5 text-text-tertiary' : 'w-7 h-7 text-text-tertiary'} />
|
||||
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
|
||||
{hint && <span className="text-xs text-text-tertiary">{hint}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+24
-1
@@ -786,6 +786,7 @@
|
||||
"common.team": "الفريق",
|
||||
"common.noTeam": "بدون فريق",
|
||||
"common.none": "بدون",
|
||||
"common.untitled": "بدون عنوان",
|
||||
"common.success": "تم بنجاح",
|
||||
"common.error": "حدث خطأ",
|
||||
"settings.roles": "الأدوار",
|
||||
@@ -922,6 +923,8 @@
|
||||
"artefacts.imagesLabel": "الصور",
|
||||
"artefacts.uploadImage": "رفع صورة",
|
||||
"artefacts.uploading": "جاري الرفع...",
|
||||
"artefacts.dropOrClickImage": "اسحب الصور هنا أو انقر للرفع",
|
||||
"artefacts.imageFormats": "PNG, JPG, WebP",
|
||||
"artefacts.noImages": "لم يتم رفع صور بعد",
|
||||
"artefacts.videosLabel": "الفيديوهات",
|
||||
"artefacts.addVideoBtn": "إضافة فيديو",
|
||||
@@ -1181,5 +1184,25 @@
|
||||
"postDetail.linkExisting": "ربط موجود",
|
||||
"postDetail.createNew": "إنشاء جديد",
|
||||
"postDetail.open": "فتح",
|
||||
"postDetail.unlink": "إلغاء الربط"
|
||||
"postDetail.unlink": "إلغاء الربط",
|
||||
"finance.campaign": "الحملة",
|
||||
"finance.budgetAssigned": "الميزانية المخصصة",
|
||||
"finance.trackAllocated": "المسار المخصص",
|
||||
"finance.spent": "المنفق",
|
||||
"finance.roi": "العائد",
|
||||
"finance.workOrder": "أمر العمل",
|
||||
"finance.budgetAllocated": "الميزانية المخصصة",
|
||||
"finance.of": "من",
|
||||
"finance.campaignCount": "{{count}} حملات · توزيع ميزانية على مستوى المسار",
|
||||
"finance.workOrderCount": "{{count}} أوامر عمل بميزانية مخصصة",
|
||||
"calendar.sun": "أحد",
|
||||
"calendar.mon": "إثن",
|
||||
"calendar.tue": "ثلا",
|
||||
"calendar.wed": "أرب",
|
||||
"calendar.thu": "خمي",
|
||||
"calendar.fri": "جمع",
|
||||
"calendar.sat": "سبت",
|
||||
"calendar.month": "شهر",
|
||||
"calendar.week": "أسبوع",
|
||||
"calendar.today": "اليوم"
|
||||
}
|
||||
+24
-1
@@ -786,6 +786,7 @@
|
||||
"common.team": "Team",
|
||||
"common.noTeam": "No team",
|
||||
"common.none": "None",
|
||||
"common.untitled": "Untitled",
|
||||
"common.success": "Success",
|
||||
"common.error": "An error occurred",
|
||||
"settings.roles": "Roles",
|
||||
@@ -922,6 +923,8 @@
|
||||
"artefacts.imagesLabel": "Images",
|
||||
"artefacts.uploadImage": "Upload Image",
|
||||
"artefacts.uploading": "Uploading...",
|
||||
"artefacts.dropOrClickImage": "Drop images here or click to upload",
|
||||
"artefacts.imageFormats": "PNG, JPG, WebP",
|
||||
"artefacts.noImages": "No images uploaded yet",
|
||||
"artefacts.videosLabel": "Videos",
|
||||
"artefacts.addVideoBtn": "Add Video",
|
||||
@@ -1181,5 +1184,25 @@
|
||||
"postDetail.linkExisting": "Link existing",
|
||||
"postDetail.createNew": "Create new",
|
||||
"postDetail.open": "Open",
|
||||
"postDetail.unlink": "Unlink"
|
||||
"postDetail.unlink": "Unlink",
|
||||
"finance.campaign": "Campaign",
|
||||
"finance.budgetAssigned": "Budget Assigned",
|
||||
"finance.trackAllocated": "Track Allocated",
|
||||
"finance.spent": "Spent",
|
||||
"finance.roi": "ROI",
|
||||
"finance.workOrder": "Work Order",
|
||||
"finance.budgetAllocated": "Budget Allocated",
|
||||
"finance.of": "of",
|
||||
"finance.campaignCount": "{{count}} campaigns · Track-level budget allocation",
|
||||
"finance.workOrderCount": "{{count}} work orders with assigned budget",
|
||||
"calendar.sun": "Sun",
|
||||
"calendar.mon": "Mon",
|
||||
"calendar.tue": "Tue",
|
||||
"calendar.wed": "Wed",
|
||||
"calendar.thu": "Thu",
|
||||
"calendar.fri": "Fri",
|
||||
"calendar.sat": "Sat",
|
||||
"calendar.month": "Month",
|
||||
"calendar.week": "Week",
|
||||
"calendar.today": "Today"
|
||||
}
|
||||
@@ -222,7 +222,7 @@ export default function Finance() {
|
||||
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
|
||||
/>
|
||||
<div className="text-xs text-text-tertiary mt-3">
|
||||
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} {currencySymbol}
|
||||
{totalSpent.toLocaleString()} {t('finance.of')} {totalReceived.toLocaleString()} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,21 +272,21 @@ export default function Finance() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{t('finance.campaignBreakdown')}</h3>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns · Track-level budget allocation</p>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.campaignCount').replace('{{count}}', s.campaigns.length)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">Campaign</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Assigned</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Track Allocated</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Spent</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Revenue</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">ROI</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.campaign')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAssigned')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.trackAllocated')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.spent')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.revenue')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.roi')}</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
@@ -335,17 +335,17 @@ export default function Finance() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{t('finance.allocatedFunds')}</h3>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.workOrderCount').replace('{{count}}', s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">Work Order</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Allocated</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.workOrder')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAllocated')}</th>
|
||||
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api, PLATFORMS } from '../utils/api'
|
||||
import PostDetailPanel from '../components/PostDetailPanel'
|
||||
import { SkeletonCalendar } from '../components/SkeletonLoader'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const DAY_KEYS = ['calendar.sun', 'calendar.mon', 'calendar.tue', 'calendar.wed', 'calendar.thu', 'calendar.fri', 'calendar.sat']
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
@@ -215,27 +215,27 @@ export default function PostCalendar() {
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
Month
|
||||
{t('calendar.month')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCalView('week')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" />
|
||||
Week
|
||||
{t('calendar.week')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||
Today
|
||||
{t('calendar.today')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
|
||||
{DAYS.map(d => (
|
||||
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
|
||||
{d}
|
||||
{DAY_KEYS.map(k => (
|
||||
<div key={k} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
|
||||
{t(k)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import PlatformIcon from '../components/PlatformIcon'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import PortalSelect from '../components/PortalSelect'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import TranslationDetailPanel from '../components/TranslationDetailPanel'
|
||||
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
|
||||
@@ -285,42 +286,45 @@ export default function PostDetail() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
<PortalSelect
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
{STATUS_OPTS.map(s => (
|
||||
<option key={s} value={s}>{t(`posts.status.${s}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={val => setStatus(val)}
|
||||
options={STATUS_OPTS.map(s => ({ value: s, label: t(`posts.status.${s}`) }))}
|
||||
className="text-xs"
|
||||
/>
|
||||
|
||||
<select
|
||||
<PortalSelect
|
||||
value={brandId}
|
||||
onChange={e => setBrandId(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.selectBrand')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
onChange={val => setBrandId(val)}
|
||||
options={[
|
||||
{ value: '', label: t('posts.selectBrand') },
|
||||
...brands.map(b => ({ value: String(b._id), label: lang === 'ar' && b.name_ar ? b.name_ar : b.name }))
|
||||
]}
|
||||
placeholder={t('posts.selectBrand')}
|
||||
className="text-xs"
|
||||
/>
|
||||
|
||||
<select
|
||||
<PortalSelect
|
||||
value={campaignId}
|
||||
onChange={e => setCampaignId(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.noCampaign')}</option>
|
||||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
onChange={val => setCampaignId(val)}
|
||||
options={[
|
||||
{ value: '', label: t('posts.noCampaign') },
|
||||
...campaigns.map(c => ({ value: String(c._id || c.id), label: c.name }))
|
||||
]}
|
||||
placeholder={t('posts.noCampaign')}
|
||||
className="text-xs"
|
||||
/>
|
||||
|
||||
<select
|
||||
<PortalSelect
|
||||
value={assignedTo}
|
||||
onChange={e => setAssignedTo(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
</select>
|
||||
onChange={val => setAssignedTo(val)}
|
||||
options={[
|
||||
{ value: '', label: t('common.unassigned') },
|
||||
...teamMembers.map(m => ({ value: String(m._id), label: m.name }))
|
||||
]}
|
||||
placeholder={t('common.unassigned')}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
+18
-16
@@ -3,27 +3,23 @@
|
||||
|
||||
const nocodb = require('./nocodb');
|
||||
|
||||
async function getMainAvailable() {
|
||||
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
function computeFromEntries(entries) {
|
||||
const income = entries.filter(e => (e.type || 'income') === 'income');
|
||||
const expenses = entries.filter(e => e.type === 'expense');
|
||||
|
||||
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||
|
||||
return {
|
||||
totalReceived,
|
||||
totalExpenses,
|
||||
totalCampaignBudget,
|
||||
totalProjectBudget,
|
||||
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
|
||||
};
|
||||
return { totalReceived, totalExpenses, totalCampaignBudget, totalProjectBudget, available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget };
|
||||
}
|
||||
|
||||
async function getCampaignAvailable(campaignId) {
|
||||
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
async function getMainAvailable(prefetchedEntries) {
|
||||
const entries = prefetchedEntries || await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
return computeFromEntries(entries);
|
||||
}
|
||||
|
||||
async function getCampaignAvailable(campaignId, prefetchedEntries) {
|
||||
const entries = prefetchedEntries || await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
const campaignIncome = entries.filter(e =>
|
||||
e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income'
|
||||
);
|
||||
@@ -38,11 +34,17 @@ async function getCampaignAvailable(campaignId) {
|
||||
return { allocated, trackAllocated, available: allocated - trackAllocated };
|
||||
}
|
||||
|
||||
async function getCampaignAllocatedFromEntries(campaignId) {
|
||||
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
async function getCampaignAllocatedFromEntries(campaignId, prefetchedEntries) {
|
||||
const entries = prefetchedEntries || await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
return entries
|
||||
.filter(e => e.campaign_id && Number(e.campaign_id) === Number(campaignId) && (e.type || 'income') === 'income')
|
||||
.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
}
|
||||
|
||||
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
|
||||
async function getAllBudgetData() {
|
||||
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
const main = computeFromEntries(entries);
|
||||
return { entries, ...main };
|
||||
}
|
||||
|
||||
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries, getAllBudgetData, computeFromEntries };
|
||||
|
||||
+12
-7
@@ -3,6 +3,11 @@ const { sendMail } = require('./mail');
|
||||
const nocodb = require('./nocodb');
|
||||
const { parseApproverIds } = require('./helpers');
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
|
||||
const APP_NAME_EN = 'Rawaj';
|
||||
const APP_NAME_AR = 'رواج';
|
||||
@@ -239,7 +244,7 @@ function notifyRejected({ type, record, approverName, feedback }) {
|
||||
heading: tr('rejectedHeading', l)(typeLabel),
|
||||
bodyHtml: `
|
||||
<p>${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(feedback)}</blockquote>` : ''}`,
|
||||
ctaText: `${tr('view', l)} ${typeLabel}`,
|
||||
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
|
||||
});
|
||||
@@ -263,7 +268,7 @@ function notifyRevisionRequested({ type, record, approverName, feedback }) {
|
||||
heading: tr('revisionRequested', l),
|
||||
bodyHtml: `
|
||||
<p>${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(feedback)}</blockquote>` : ''}`,
|
||||
ctaText: `${tr('view', l)} ${tr(entityType, l)}`,
|
||||
ctaUrl: `${APP_URL}/${entityPath}`,
|
||||
});
|
||||
@@ -286,7 +291,7 @@ function notifyTaskAssigned({ task, assignerName }) {
|
||||
bodyHtml: `
|
||||
<p>${tr('taskAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}</p>
|
||||
<p style="font-size:16px;font-weight:600;color:#1e293b">${title}</p>
|
||||
${task.description ? `<p style="color:#64748b">${task.description.substring(0, 200)}</p>` : ''}
|
||||
${task.description ? `<p style="color:#64748b">${escapeHtml(task.description.substring(0, 200))}</p>` : ''}
|
||||
${task.priority ? `<p>${tr('priority', l)}: <strong>${task.priority}</strong></p>` : ''}
|
||||
${task.due_date ? `<p>${tr('dueDate', l)}: <strong>${task.due_date}</strong></p>` : ''}`,
|
||||
ctaText: tr('viewTask', l),
|
||||
@@ -351,7 +356,7 @@ function notifyIssueStatusUpdate({ issue, oldStatus, newStatus }) {
|
||||
bodyHtml: `
|
||||
<p>${tr('issueUpdateBody', 'en')(title)}</p>
|
||||
<p><span style="color:#94a3b8">${oldStatus || 'new'}</span> → <strong style="color:#3b82f6">${newStatus}</strong></p>
|
||||
${issue.resolution_summary ? `<p style="margin-top:12px"><strong>${tr('resolution', 'en')}:</strong> ${issue.resolution_summary}</p>` : ''}`,
|
||||
${issue.resolution_summary ? `<p style="margin-top:12px"><strong>${tr('resolution', 'en')}:</strong> ${escapeHtml(issue.resolution_summary)}</p>` : ''}`,
|
||||
ctaText: issue.tracking_token ? tr('trackIssue', 'en') : null,
|
||||
ctaUrl: issue.tracking_token ? `${APP_URL}/track/${issue.tracking_token}` : null,
|
||||
});
|
||||
@@ -413,7 +418,7 @@ function notifyBudgetRequest({ ceoEmail, amount, requesterName, justification, e
|
||||
heading: tr('budgetRequestHeading', 'en'),
|
||||
bodyHtml: `
|
||||
<p>${tr('budgetRequestBody', 'en')(requesterName, amount)}</p>
|
||||
<p><strong>${tr('budgetJustification', 'en')}:</strong> ${justification}</p>
|
||||
<p><strong>${tr('budgetJustification', 'en')}:</strong> ${escapeHtml(justification)}</p>
|
||||
${earmarkHtml}`,
|
||||
ctaText: tr('reviewRequest', 'en'),
|
||||
ctaUrl: approvalUrl,
|
||||
@@ -429,7 +434,7 @@ function notifyBudgetApproved({ request, requesterEmail, requesterLang }) {
|
||||
heading: tr('budgetApproved', l),
|
||||
bodyHtml: `
|
||||
<p>${tr('budgetApprovedBody', l)(String(request.amount))}</p>
|
||||
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
|
||||
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(request.response_note)}</blockquote>` : ''}`,
|
||||
ctaText: null, ctaUrl: null,
|
||||
});
|
||||
}
|
||||
@@ -443,7 +448,7 @@ function notifyBudgetRejected({ request, requesterEmail, requesterLang }) {
|
||||
heading: tr('budgetRejected', l),
|
||||
bodyHtml: `
|
||||
<p>${tr('budgetRejectedBody', l)(String(request.amount))}</p>
|
||||
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${request.response_note}</blockquote>` : ''}`,
|
||||
${request.response_note ? `<blockquote ${BLOCKQUOTE}>${escapeHtml(request.response_note)}</blockquote>` : ''}`,
|
||||
ctaText: null, ctaUrl: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ async function getPostComposition(postId) {
|
||||
return texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
|
||||
} catch { return []; }
|
||||
};
|
||||
const captionTexts = caption ? await getTexts(caption.Id) : [];
|
||||
const bodyTexts = bodyCopy ? await getTexts(bodyCopy.Id) : [];
|
||||
const [captionTexts, bodyTexts] = await Promise.all([
|
||||
caption ? getTexts(caption.Id) : [],
|
||||
bodyCopy ? getTexts(bodyCopy.Id) : [],
|
||||
]);
|
||||
|
||||
// Get first attachment for design/video thumbnail
|
||||
const getFirstAttachment = async (artefactId) => {
|
||||
@@ -44,11 +46,15 @@ async function getPostComposition(postId) {
|
||||
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
|
||||
if (versions.length === 0) return null;
|
||||
const attachments = await nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${versions[0].Id})`, limit: 1 });
|
||||
return attachments.length > 0 ? (attachments[0].url || attachments[0].file_url || null) : null;
|
||||
if (attachments.length === 0) return null;
|
||||
const att = attachments[0];
|
||||
return att.drive_url || (att.filename ? `/api/uploads/${att.filename}` : null);
|
||||
} catch { return null; }
|
||||
};
|
||||
const designThumb = design ? (design.thumbnail_url || await getFirstAttachment(design.Id)) : null;
|
||||
const videoThumb = video ? (video.thumbnail_url || await getFirstAttachment(video.Id)) : null;
|
||||
const [designThumb, videoThumb] = await Promise.all([
|
||||
design ? (design.thumbnail_url || getFirstAttachment(design.Id)) : null,
|
||||
video ? (video.thumbnail_url || getFirstAttachment(video.Id)) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, language: caption.source_language, content_preview: (caption.source_content || '').slice(0, 120), languages: captionTexts } : null,
|
||||
@@ -66,9 +72,7 @@ function computeStage(composition) {
|
||||
const { caption, body_copy, design, video, pieces_ready } = composition;
|
||||
if (pieces_ready) return 'post';
|
||||
if (design || video) return 'design';
|
||||
// Check if we have any copy at all
|
||||
const hasCopy = caption || body_copy;
|
||||
if (!hasCopy) return 'copy';
|
||||
if (caption || body_copy) return 'translate';
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
|
||||
+36
-4
@@ -1,3 +1,4 @@
|
||||
// TODO: Decompose routes into separate files by domain (posts, campaigns, tasks, artefacts, budget, auth)
|
||||
require('dotenv').config({ path: __dirname + '/.env' });
|
||||
|
||||
const express = require('express');
|
||||
@@ -1312,6 +1313,8 @@ app.get('/api/posts/:id', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/posts', requireAuth, async (req, res) => {
|
||||
// NOTE: Client sends `assigned_to` but NocoDB column is `assigned_to_id`.
|
||||
// Response includes both `assigned_to: post.assigned_to_id` for backward compat.
|
||||
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids, caption } = req.body;
|
||||
|
||||
const platformsArr = platforms || (platform ? [platform] : []);
|
||||
@@ -1374,6 +1377,8 @@ app.post('/api/posts/bulk-delete', requireAuth, requireRole('superadmin', 'manag
|
||||
});
|
||||
|
||||
app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
|
||||
// NOTE: Client sends `assigned_to` but NocoDB column is `assigned_to_id`.
|
||||
// Mapping: req.body.assigned_to → data.assigned_to_id (see below).
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const existing = await nocodb.get('Posts', id);
|
||||
@@ -2525,6 +2530,7 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
|
||||
});
|
||||
|
||||
app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||
let releaseLock = null;
|
||||
try {
|
||||
const existing = await nocodb.get('BudgetEntries', req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
|
||||
@@ -2538,6 +2544,29 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
|
||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
// Validate amount > 0 if being changed
|
||||
if (data.amount !== undefined && (isNaN(Number(data.amount)) || Number(data.amount) <= 0)) {
|
||||
return res.status(400).json({ error: 'Amount must be greater than 0' });
|
||||
}
|
||||
|
||||
// Budget validation: check availability when changing to expense or increasing expense amount
|
||||
const newType = data.type || existing.type || 'income';
|
||||
const oldType = existing.type || 'income';
|
||||
const newAmount = data.amount !== undefined ? Number(data.amount) : (existing.amount || 0);
|
||||
const oldAmount = existing.amount || 0;
|
||||
const needsCheck = (newType === 'expense' && oldType !== 'expense') ||
|
||||
(newType === 'expense' && newAmount > oldAmount);
|
||||
|
||||
if (needsCheck) {
|
||||
releaseLock = await acquireBudgetLock();
|
||||
const { available } = await getMainAvailable();
|
||||
// Add back the old expense amount if it was already an expense, then check new amount
|
||||
const effectiveAvailable = oldType === 'expense' ? available + oldAmount : available;
|
||||
if (newAmount > effectiveAvailable) {
|
||||
return res.status(400).json({ error: 'Insufficient budget', available: effectiveAvailable });
|
||||
}
|
||||
}
|
||||
|
||||
await nocodb.update('BudgetEntries', req.params.id, data);
|
||||
|
||||
const entry = await nocodb.get('BudgetEntries', req.params.id);
|
||||
@@ -2548,6 +2577,8 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to update budget entry' });
|
||||
} finally {
|
||||
if (releaseLock) releaseLock();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2721,10 +2752,11 @@ app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (r
|
||||
...r,
|
||||
requester_name: users[r.requested_by_user_id] || 'Unknown',
|
||||
}));
|
||||
await batchResolveNames(enriched, {
|
||||
earmarked_campaign_id: { table: 'Campaigns', as: 'earmarked_campaign_name' },
|
||||
earmarked_project_id: { table: 'Projects', as: 'earmarked_project_name' },
|
||||
});
|
||||
// Resolve earmarked campaign/project names
|
||||
for (const r of enriched) {
|
||||
if (r.earmarked_campaign_id) r.earmarked_campaign_name = await getRecordName('Campaigns', Number(r.earmarked_campaign_id));
|
||||
if (r.earmarked_project_id) r.earmarked_project_name = await getRecordName('Projects', Number(r.earmarked_project_id));
|
||||
}
|
||||
res.json(enriched);
|
||||
} catch (err) {
|
||||
console.error('Budget requests list error:', err);
|
||||
|
||||
Reference in New Issue
Block a user