fix: select dropdowns clipped by sections, add approval actions to posts
All checks were successful
Deploy / deploy (push) Successful in 12s
All checks were successful
Deploy / deploy (push) Successful in 12s
- Fix CollapsibleSection overflow clipping select dropdowns - Add SlidePanel footer prop for always-visible save/cancel bar - Add approval action buttons: Send to Review, Approve, Reject, Schedule - Add sync-brands.js script for local→remote NocoDB brand sync - Add posts.reject i18n key (en + ar) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
|
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PLATFORMS, getBrandColor } from '../utils/api'
|
import { api, PLATFORMS, getBrandColor } from '../utils/api'
|
||||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||||
@@ -124,6 +124,18 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStatusAction = async (newStatus) => {
|
||||||
|
if (!postId || saving) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(postId, { ...form, status: newStatus, approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null })
|
||||||
|
setForm(f => ({ ...f, status: newStatus }))
|
||||||
|
setDirty(false)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
setShowDeleteConfirm(false)
|
setShowDeleteConfirm(false)
|
||||||
await onDelete(postId)
|
await onDelete(postId)
|
||||||
@@ -380,13 +392,59 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
|||||||
|
|
||||||
{/* Approval Section */}
|
{/* Approval Section */}
|
||||||
<CollapsibleSection title={t('posts.approval')}>
|
<CollapsibleSection title={t('posts.approval')}>
|
||||||
<div className="px-5 pb-4">
|
<div className="px-5 pb-4 space-y-3">
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.approvers')}</label>
|
<div>
|
||||||
<ApproverMultiSelect
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.approvers')}</label>
|
||||||
users={teamMembers || []}
|
<ApproverMultiSelect
|
||||||
selected={form.approver_ids || []}
|
users={teamMembers || []}
|
||||||
onChange={ids => update('approver_ids', ids)}
|
selected={form.approver_ids || []}
|
||||||
/>
|
onChange={ids => update('approver_ids', ids)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCreateMode && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{(form.status === 'draft' || form.status === 'rejected') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusAction('in_review')}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.sendToReview')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{form.status === 'in_review' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusAction('approved')}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.approve')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusAction('rejected')}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.reject')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.status === 'approved' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusAction('scheduled')}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('posts.schedule')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
|||||||
@@ -888,5 +888,6 @@
|
|||||||
"posts.approvers": "المعتمدون",
|
"posts.approvers": "المعتمدون",
|
||||||
"posts.selectApprovers": "اختر المعتمدين...",
|
"posts.selectApprovers": "اختر المعتمدين...",
|
||||||
"posts.scheduling": "الجدولة والتعيين",
|
"posts.scheduling": "الجدولة والتعيين",
|
||||||
"posts.content": "المحتوى"
|
"posts.content": "المحتوى",
|
||||||
|
"posts.reject": "رفض"
|
||||||
}
|
}
|
||||||
@@ -888,5 +888,6 @@
|
|||||||
"posts.approvers": "Approvers",
|
"posts.approvers": "Approvers",
|
||||||
"posts.selectApprovers": "Select approvers...",
|
"posts.selectApprovers": "Select approvers...",
|
||||||
"posts.scheduling": "Scheduling & Assignment",
|
"posts.scheduling": "Scheduling & Assignment",
|
||||||
"posts.content": "Content"
|
"posts.content": "Content",
|
||||||
|
"posts.reject": "Reject"
|
||||||
}
|
}
|
||||||
@@ -421,6 +421,10 @@ textarea {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapsible-content.is-open > .collapsible-inner {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
/* Stagger children */
|
/* Stagger children */
|
||||||
.stagger-children > * {
|
.stagger-children > * {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
112
server/sync-brands.js
Normal file
112
server/sync-brands.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Sync brands from local NocoDB to a remote NocoDB instance.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* REMOTE_NOCODB_URL=https://... REMOTE_NOCODB_TOKEN=... REMOTE_NOCODB_BASE_ID=... node sync-brands.js
|
||||||
|
*
|
||||||
|
* Reads local config from .env (NOCODB_URL, NOCODB_TOKEN, NOCODB_BASE_ID).
|
||||||
|
* Syncs all brands from local → remote (upserts by name).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config'
|
||||||
|
|
||||||
|
const LOCAL_URL = process.env.NOCODB_URL || 'http://localhost:8090'
|
||||||
|
const LOCAL_TOKEN = process.env.NOCODB_TOKEN
|
||||||
|
const LOCAL_BASE_ID = process.env.NOCODB_BASE_ID
|
||||||
|
|
||||||
|
const REMOTE_URL = process.env.REMOTE_NOCODB_URL
|
||||||
|
const REMOTE_TOKEN = process.env.REMOTE_NOCODB_TOKEN
|
||||||
|
const REMOTE_BASE_ID = process.env.REMOTE_NOCODB_BASE_ID
|
||||||
|
|
||||||
|
if (!REMOTE_URL || !REMOTE_TOKEN || !REMOTE_BASE_ID) {
|
||||||
|
console.error('Missing env vars: REMOTE_NOCODB_URL, REMOTE_NOCODB_TOKEN, REMOTE_NOCODB_BASE_ID')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTableId(baseUrl, token, baseId, tableName) {
|
||||||
|
const res = await fetch(`${baseUrl}/api/v2/meta/bases/${baseId}/tables`, {
|
||||||
|
headers: { 'xc-token': token },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
const table = (data.list || []).find(t => t.title === tableName)
|
||||||
|
if (!table) throw new Error(`Table "${tableName}" not found`)
|
||||||
|
return table.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRecords(baseUrl, token, tableId) {
|
||||||
|
const res = await fetch(`${baseUrl}/api/v2/tables/${tableId}/records?limit=200`, {
|
||||||
|
headers: { 'xc-token': token },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return data.list || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRecord(baseUrl, token, tableId, record) {
|
||||||
|
const res = await fetch(`${baseUrl}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xc-token': token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(record),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRecord(baseUrl, token, tableId, record) {
|
||||||
|
const res = await fetch(`${baseUrl}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'xc-token': token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(record),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Resolving table IDs...')
|
||||||
|
const localTableId = await resolveTableId(LOCAL_URL, LOCAL_TOKEN, LOCAL_BASE_ID, 'Brands')
|
||||||
|
const remoteTableId = await resolveTableId(REMOTE_URL, REMOTE_TOKEN, REMOTE_BASE_ID, 'Brands')
|
||||||
|
|
||||||
|
console.log('Fetching local brands...')
|
||||||
|
const localBrands = await listRecords(LOCAL_URL, LOCAL_TOKEN, localTableId)
|
||||||
|
console.log(` Found ${localBrands.length} local brands`)
|
||||||
|
|
||||||
|
console.log('Fetching remote brands...')
|
||||||
|
const remoteBrands = await listRecords(REMOTE_URL, REMOTE_TOKEN, remoteTableId)
|
||||||
|
console.log(` Found ${remoteBrands.length} remote brands`)
|
||||||
|
|
||||||
|
const remoteByName = new Map(remoteBrands.map(b => [b.name, b]))
|
||||||
|
|
||||||
|
const FIELDS = ['name', 'name_ar', 'priority', 'color', 'icon', 'category', 'logo']
|
||||||
|
|
||||||
|
let created = 0, updated = 0, skipped = 0
|
||||||
|
|
||||||
|
for (const brand of localBrands) {
|
||||||
|
const data = {}
|
||||||
|
for (const f of FIELDS) {
|
||||||
|
if (brand[f] !== undefined && brand[f] !== null) data[f] = brand[f]
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = remoteByName.get(brand.name)
|
||||||
|
if (existing) {
|
||||||
|
// Check if anything changed
|
||||||
|
const needsUpdate = FIELDS.some(f => brand[f] !== existing[f])
|
||||||
|
if (needsUpdate) {
|
||||||
|
await updateRecord(REMOTE_URL, REMOTE_TOKEN, remoteTableId, { Id: existing.Id, ...data })
|
||||||
|
console.log(` Updated: ${brand.name}`)
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await createRecord(REMOTE_URL, REMOTE_TOKEN, remoteTableId, data)
|
||||||
|
console.log(` Created: ${brand.name}`)
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone! Created: ${created}, Updated: ${updated}, Skipped (no changes): ${skipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Sync failed:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user