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

@@ -410,39 +410,38 @@ export default function Budgets() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.category')}</label>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.category')}</label>
<select
value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.linkedTo')}</label>
<div className="grid grid-cols-2 gap-3">
<select
value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
value={form.campaign_id}
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value, project_id: '' }))}
disabled={!!form.project_id}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
<option value="">{t('budgets.noCampaign')}</option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
<select
value={form.project_id}
onChange={e => setForm(f => ({ ...f, project_id: e.target.value, campaign_id: '' }))}
disabled={!!form.campaign_id}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
>
<option value="">{t('budgets.noProject')}</option>
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.linkedTo')}</label>
<div className="flex gap-2">
<select
value={form.campaign_id}
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value, project_id: '' }))}
disabled={!!form.project_id}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
>
<option value="">{t('budgets.noCampaign')}</option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
<select
value={form.project_id}
onChange={e => setForm(f => ({ ...f, project_id: e.target.value, campaign_id: '' }))}
disabled={!!form.campaign_id}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
>
<option value="">{t('budgets.noProject')}</option>
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
</select>
</div>
</div>
</div>

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 && (

View File

@@ -11,12 +11,12 @@ import { SkeletonCard } from '../components/SkeletonLoader'
const EMPTY_PROJECT = {
name: '', description: '', brand_id: '', status: 'active',
owner_id: '', start_date: '', due_date: '',
owner_id: '', start_date: '', due_date: '', team_id: '',
}
export default function Projects() {
const navigate = useNavigate()
const { teamMembers, brands } = useContext(AppContext)
const { teamMembers, brands, teams } = useContext(AppContext)
const { permissions } = useAuth()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
@@ -45,6 +45,7 @@ export default function Projects() {
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
team_id: formData.team_id ? Number(formData.team_id) : null,
status: formData.status,
start_date: formData.start_date || null,
due_date: formData.due_date || null,
@@ -236,6 +237,20 @@ export default function Projects() {
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Team</label>
<select
value={formData.team_id}
onChange={e => setFormData(f => ({ ...f, team_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 focus:border-brand-primary"
>
<option value="">No team</option>
{teams.map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
<input

View File

@@ -1,13 +1,22 @@
import { useState, useEffect } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
import { useState, useEffect, useContext } from 'react'
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import { CURRENCIES } from '../i18n/LanguageContext'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
const ROLE_COLORS = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#06B6D4', '#F97316', '#6366F1', '#14B8A6',
]
export default function Settings() {
const { t, lang, setLang, currency, setCurrency } = useLanguage()
const toast = useToast()
const { user } = useAuth()
const { roles, loadRoles } = useContext(AppContext)
const [restarting, setRestarting] = useState(false)
const [success, setSuccess] = useState(false)
const [maxSizeMB, setMaxSizeMB] = useState(50)
@@ -176,6 +185,119 @@ export default function Settings() {
)}
</div>
</div>
{/* Roles Management (Superadmin only) */}
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
</div>
)
}
function RolesSection({ roles, loadRoles, t, toast }) {
const [editingRole, setEditingRole] = useState(null)
const [newRole, setNewRole] = useState(null)
const [saving, setSaving] = useState(false)
const handleSave = async (role) => {
setSaving(true)
try {
if (role.Id || role.id) {
await api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color })
} else {
await api.post('/roles', { name: role.name, color: role.color })
}
await loadRoles()
setEditingRole(null)
setNewRole(null)
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSaving(false)
}
}
const handleDelete = async (role) => {
if (!confirm(t('settings.deleteRoleConfirm'))) return
try {
await api.delete(`/roles/${role.Id || role.id}`)
await loadRoles()
} catch (err) {
toast.error(err.message || t('common.error'))
}
}
return (
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<Tag className="w-5 h-5 text-brand-primary" />
{t('settings.roles')}
</h2>
<button
onClick={() => setNewRole({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-4 h-4" />
{t('settings.addRole')}
</button>
</div>
<div className="p-6">
<p className="text-sm text-text-tertiary mb-4">{t('settings.rolesDesc')}</p>
<div className="space-y-2">
{roles.map(role => (
<div key={role.Id || role.id} className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-surface-secondary transition-colors">
{editingRole?.Id === role.Id ? (
<RoleForm role={editingRole} onChange={setEditingRole} onSave={() => handleSave(editingRole)} onCancel={() => setEditingRole(null)} saving={saving} t={t} />
) : (
<>
<div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} />
<span className="flex-1 text-sm font-medium text-text-primary">{role.name}</span>
<button onClick={() => setEditingRole({ ...role })} className="p-1.5 text-text-tertiary hover:text-brand-primary rounded-lg hover:bg-surface-tertiary transition-colors">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(role)} className="p-1.5 text-text-tertiary hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
))}
{newRole && (
<div className="p-3 rounded-lg border-2 border-dashed border-brand-primary/30 bg-brand-primary/5">
<RoleForm role={newRole} onChange={setNewRole} onSave={() => handleSave(newRole)} onCancel={() => setNewRole(null)} saving={saving} t={t} />
</div>
)}
{roles.length === 0 && !newRole && (
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p>
)}
</div>
</div>
</div>
)
}
function RoleForm({ role, onChange, onSave, onCancel, saving, t }) {
return (
<div className="flex items-center gap-3 flex-1">
<input
type="color"
value={role.color || '#94A3B8'}
onChange={e => onChange({ ...role, color: e.target.value })}
className="w-8 h-8 rounded-lg border border-border cursor-pointer"
/>
<input
type="text"
value={role.name}
onChange={e => onChange({ ...role, name: e.target.value })}
placeholder={t('settings.roleName')}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
autoFocus
/>
<button onClick={onSave} disabled={!role.name || saving} className="px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors">
{saving ? '...' : t('common.save')}
</button>
<button onClick={onCancel} className="p-1.5 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-surface-tertiary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
)
}

View File

@@ -58,7 +58,8 @@ export default function Team() {
const payload = {
name: data.name,
email: data.email,
team_role: data.role,
role: data.role,
role_id: data.role_id,
brands: data.brands,
phone: data.phone,
modules: data.modules,
@@ -176,7 +177,7 @@ export default function Team() {
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
<p className="text-sm text-text-secondary capitalize">{selectedMember.role_name || selectedMember.team_role || ''}</p>
{selectedMember.email && (
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
)}
@@ -499,7 +500,7 @@ export default function Team() {
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{member.name}</p>
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
</div>
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 shrink-0">
@@ -543,7 +544,7 @@ export default function Team() {
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{member.name}</p>
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
</div>
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 shrink-0">