feat: team-based visibility, roles management, unified users, UI fixes
All checks were successful
Deploy / deploy (push) Successful in 12s

- Add Roles table with CRUD routes and Settings page management
- Unify user management: remove Users page, enhance Team page with
  permission level + role dropdowns
- Add team-based visibility scoping to projects, campaigns, posts,
  tasks, issues, artefacts, and dashboard
- Add team_id to projects and campaigns (create + edit forms)
- Add getUserTeamIds/getUserVisibilityContext helpers
- Fix Budgets modal horizontal scroll (separate linked-to row)
- Add collapsible filter bar to PostProduction page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-04 15:55:15 +03:00
parent 7c6e8dce08
commit da161014af
14 changed files with 655 additions and 308 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, LayoutGrid, List, Search, X, FileText } from 'lucide-react'
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -37,6 +37,7 @@ export default function PostProduction() {
const [moveError, setMoveError] = useState('')
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [showFilters, setShowFilters] = useState(false)
useEffect(() => {
loadPosts()
@@ -158,98 +159,110 @@ export default function PostProduction() {
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder={t('posts.searchPosts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
<div data-tutorial="filters" className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
/>
<div className="flex items-center gap-1.5">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="date"
value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<span className="text-xs text-text-tertiary"></span>
<input
type="date"
value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
type="text"
placeholder={t('posts.searchPosts')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
/>
</div>
</div>
</div>
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
<button
onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
data-tutorial="filters"
onClick={() => setShowFilters(f => !f)}
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`}
>
<LayoutGrid className="w-4 h-4" />
<Filter className="w-4 h-4" />
{t('common.filter')}
{(filters.brand || filters.platform || filters.assignedTo || filters.periodFrom || filters.periodTo) && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" />
)}
</button>
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
<button
onClick={() => setView('kanban')}
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
data-tutorial="new-post"
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<List className="w-4 h-4" />
<Plus className="w-4 h-4" />
{t('posts.newPost')}
</button>
</div>
<button
data-tutorial="new-post"
onClick={openNew}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
>
<Plus className="w-4 h-4" />
{t('posts.newPost')}
</button>
{showFilters && (
<div className="flex items-center gap-2 flex-wrap animate-fade-in">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
/>
<div className="flex items-center gap-1.5">
<input
type="date"
value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<span className="text-xs text-text-tertiary"></span>
<input
type="date"
value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')}
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
</div>
)}
</div>
{moveError && (