feat: bulk delete, team dispatch, calendar views, timeline colors
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
Deploy / deploy (push) Successful in 11s
- Multi-select bulk delete in all 5 list views (Artefacts, Posts, Tasks, Issues, Assets) with cascade deletes and confirmation modals - Team-based issue dispatch: team picker on public issue form, team filter on Issues page, copy public link from Team page and Issues header, team assignment in IssueDetailPanel - Month/Week toggle on PostCalendar and TaskCalendarView - Month/Week/Day zoom on project and campaign timelines (InteractiveTimeline) and ProjectDetail GanttView, with Month as default - Custom timeline bar colors: clickable color dot with 12-color palette popover on project, campaign, and task timeline bars - Artefacts default view changed to list - BulkSelectBar reusable component - i18n keys for all new features (en + ar) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,15 @@ import { Plus, Upload, Search, FolderOpen, ChevronRight, Grid3X3, X } from 'luci
|
||||
import { api } from '../utils/api'
|
||||
import AssetCard from '../components/AssetCard'
|
||||
import Modal from '../components/Modal'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import { SkeletonAssetGrid } from '../components/SkeletonLoader'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
export default function Assets() {
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [assets, setAssets] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
|
||||
@@ -18,13 +23,15 @@ export default function Assets() {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [assetToDelete, setAssetToDelete] = useState(null)
|
||||
const fileRef = useRef(null)
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => { loadAssets() }, [])
|
||||
|
||||
const loadAssets = async () => {
|
||||
try {
|
||||
const res = await api.get('/assets')
|
||||
const assetsData = res.data || res || []
|
||||
const assetsData = Array.isArray(res) ? res : []
|
||||
// Map assets to include URL for thumbnails
|
||||
const assetsWithUrls = assetsData.map(asset => ({
|
||||
...asset,
|
||||
@@ -91,7 +98,7 @@ export default function Assets() {
|
||||
setUploadProgress(0)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
alert('Upload failed: ' + err.message)
|
||||
toast.error(t('assets.uploadFailed') + ': ' + err.message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -111,10 +118,36 @@ export default function Assets() {
|
||||
loadAssets()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete asset')
|
||||
toast.error(t('assets.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/assets/bulk-delete', { ids: [...selectedIds] })
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadAssets()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === filteredAssets.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(filteredAssets.map(a => a._id || a.id)))
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
@@ -212,6 +245,10 @@ export default function Assets() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
@@ -222,7 +259,10 @@ export default function Assets() {
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
|
||||
{filteredAssets.map(asset => (
|
||||
<div key={asset._id || asset.id}>
|
||||
<div key={asset._id || asset.id} className="relative">
|
||||
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(asset._id || asset.id)} onChange={() => toggleSelect(asset._id || asset.id)} className="rounded border-border" />
|
||||
</div>
|
||||
<AssetCard asset={asset} onClick={setSelectedAsset} />
|
||||
</div>
|
||||
))}
|
||||
@@ -343,6 +383,18 @@ export default function Assets() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Asset Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Brands() {
|
||||
const loadBrands = async () => {
|
||||
try {
|
||||
const data = await api.get('/brands')
|
||||
setBrands(Array.isArray(data) ? data : (data.data || []))
|
||||
setBrands(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load brands:', err)
|
||||
} finally {
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function CampaignDetail() {
|
||||
|
||||
useEffect(() => { loadAll() }, [id])
|
||||
useEffect(() => {
|
||||
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||||
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
@@ -82,10 +82,10 @@ export default function CampaignDetail() {
|
||||
api.get(`/campaigns/${id}/posts`),
|
||||
api.get(`/campaigns/${id}/assignments`),
|
||||
])
|
||||
setCampaign(campRes.data || campRes || null)
|
||||
setTracks(tracksRes.data || tracksRes || [])
|
||||
setPosts(postsRes.data || postsRes || [])
|
||||
setAssignments(Array.isArray(assignRes) ? assignRes : (assignRes.data || []))
|
||||
setCampaign(campRes)
|
||||
setTracks(Array.isArray(tracksRes) ? tracksRes : [])
|
||||
setPosts(Array.isArray(postsRes) ? postsRes : [])
|
||||
setAssignments(Array.isArray(assignRes) ? assignRes : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load campaign:', err)
|
||||
} finally {
|
||||
@@ -96,7 +96,7 @@ export default function CampaignDetail() {
|
||||
const loadUsersForAssign = async () => {
|
||||
try {
|
||||
const users = await api.get('/users/team?all=true')
|
||||
setAllUsers(Array.isArray(users) ? users : (users.data || []))
|
||||
setAllUsers(Array.isArray(users) ? users : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function Campaigns() {
|
||||
const loadCampaigns = async () => {
|
||||
try {
|
||||
const res = await api.get('/campaigns')
|
||||
setCampaigns(res.data || res || [])
|
||||
setCampaigns(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load campaigns:', err)
|
||||
} finally {
|
||||
@@ -202,6 +202,7 @@ export default function Campaigns() {
|
||||
status: campaign.status,
|
||||
assigneeName: campaign.brandName || campaign.brand_name,
|
||||
tags: campaign.platforms || [],
|
||||
color: campaign.color,
|
||||
})}
|
||||
onDateChange={async (campaignId, { startDate, endDate }) => {
|
||||
try {
|
||||
@@ -212,6 +213,15 @@ export default function Campaigns() {
|
||||
loadCampaigns()
|
||||
}
|
||||
}}
|
||||
onColorChange={async (campaignId, color) => {
|
||||
try {
|
||||
await api.patch(`/campaigns/${campaignId}`, { color: color || '' })
|
||||
} catch (err) {
|
||||
console.error('Color update failed:', err)
|
||||
} finally {
|
||||
loadCampaigns()
|
||||
}
|
||||
}}
|
||||
onItemClick={(campaign) => {
|
||||
navigate(`/campaigns/${campaign._id || campaign.id}`)
|
||||
}}
|
||||
|
||||
@@ -287,15 +287,15 @@ export default function Dashboard() {
|
||||
const fetches = []
|
||||
// Only fetch data for modules the user has access to
|
||||
if (hasModule('marketing')) {
|
||||
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: r.data || r || [] })))
|
||||
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: r.data || r || [] })))
|
||||
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
|
||||
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
|
||||
}
|
||||
if (hasModule('projects')) {
|
||||
fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', data: r.data || r || [] })))
|
||||
fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: r.data || r || [] })))
|
||||
fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', data: Array.isArray(r) ? r : [] })))
|
||||
fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: Array.isArray(r) ? r : [] })))
|
||||
}
|
||||
if (hasModule('finance')) {
|
||||
fetches.push(api.get('/finance/summary').then(r => ({ key: 'finance', data: r.data || r || null })))
|
||||
fetches.push(api.get('/finance/summary').then(r => ({ key: 'finance', data: r || null })))
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(fetches)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown, Link2 } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import IssueDetailPanel from '../components/IssueDetailPanel'
|
||||
import IssueCard from '../components/IssueCard'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
@@ -17,19 +19,13 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
|
||||
// Issue-specific status order for the kanban board
|
||||
const ISSUE_STATUS_CONFIG = {
|
||||
new: STATUS_CONFIG.new,
|
||||
acknowledged: STATUS_CONFIG.acknowledged,
|
||||
in_progress: STATUS_CONFIG.in_progress,
|
||||
resolved: STATUS_CONFIG.resolved,
|
||||
declined: STATUS_CONFIG.declined,
|
||||
}
|
||||
|
||||
const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
|
||||
@@ -37,14 +33,14 @@ const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'decline
|
||||
export default function Issues() {
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const { brands } = useContext(AppContext)
|
||||
const { brands, teams } = useContext(AppContext)
|
||||
|
||||
const [issues, setIssues] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedIssue, setSelectedIssue] = useState(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '' })
|
||||
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '', team: '' })
|
||||
const [categories, setCategories] = useState([])
|
||||
const [teamMembers, setTeamMembers] = useState([])
|
||||
|
||||
@@ -59,6 +55,9 @@ export default function Issues() {
|
||||
const [sortBy, setSortBy] = useState('created_at')
|
||||
const [sortDir, setSortDir] = useState('desc')
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -72,7 +71,7 @@ export default function Issues() {
|
||||
setIssues(issuesData.issues || [])
|
||||
setCounts(issuesData.counts || {})
|
||||
setCategories(categoriesData || [])
|
||||
setTeamMembers(Array.isArray(teamData) ? teamData : teamData.data || [])
|
||||
setTeamMembers(Array.isArray(teamData) ? teamData : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load issues:', err)
|
||||
} finally {
|
||||
@@ -97,6 +96,7 @@ export default function Issues() {
|
||||
if (filters.type) filtered = filtered.filter(i => i.type === filters.type)
|
||||
if (filters.priority) filtered = filtered.filter(i => i.priority === filters.priority)
|
||||
if (filters.brand) filtered = filtered.filter(i => String(i.brand_id) === String(filters.brand))
|
||||
if (filters.team) filtered = filtered.filter(i => String(i.team_id) === String(filters.team))
|
||||
return filtered
|
||||
}, [issues, searchTerm, filters])
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function Issues() {
|
||||
|
||||
const updateFilter = (key, value) => setFilters(f => ({ ...f, [key]: value }))
|
||||
const clearFilters = () => {
|
||||
setFilters({ status: '', category: '', type: '', priority: '', brand: '' })
|
||||
setFilters({ status: '', category: '', type: '', priority: '', brand: '', team: '' })
|
||||
setSearchTerm('')
|
||||
}
|
||||
const hasActiveFilters = Object.values(filters).some(Boolean) || searchTerm
|
||||
@@ -149,6 +149,40 @@ export default function Issues() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success('Issues deleted')
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === sortedIssues.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(sortedIssues.map(i => i.Id || i.id)))
|
||||
}
|
||||
|
||||
const copyPublicLink = () => {
|
||||
const base = `${window.location.origin}/submit-issue`
|
||||
const url = filters.team ? `${base}?team=${filters.team}` : base
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success(t('issues.linkCopied'))
|
||||
}
|
||||
|
||||
const handleDragStart = (e, issue) => {
|
||||
setDraggedIssue(issue)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -208,31 +242,42 @@ export default function Issues() {
|
||||
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
|
||||
</div>
|
||||
|
||||
{/* View switcher */}
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
{[
|
||||
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
|
||||
{ mode: 'list', icon: List, label: t('issues.list') },
|
||||
].map(({ mode, icon: Icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={copyPublicLink}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-border rounded-lg hover:bg-surface-secondary transition-colors text-text-secondary"
|
||||
title={t('issues.copyPublicLink')}
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('issues.copyPublicLink')}
|
||||
</button>
|
||||
|
||||
{/* View switcher */}
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
{[
|
||||
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
|
||||
{ mode: 'list', icon: List, label: t('issues.list') },
|
||||
].map(({ mode, icon: Icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Counts */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 stagger-children">
|
||||
{Object.entries(STATUS_CONFIG).map(([status, config]) => (
|
||||
{Object.entries(ISSUE_STATUS_CONFIG).map(([status, config]) => (
|
||||
<div
|
||||
key={status}
|
||||
className={`bg-surface rounded-lg border p-4 cursor-pointer hover:shadow-sm transition-all ${
|
||||
@@ -269,7 +314,7 @@ export default function Issues() {
|
||||
className="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="">All Statuses</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
|
||||
{Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -303,6 +348,17 @@ export default function Issues() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.team || ''}
|
||||
onChange={e => updateFilter('team', e.target.value)}
|
||||
className="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="">{t('issues.allTeams')}</option>
|
||||
{(teams || []).map(tm => (
|
||||
<option key={tm.id || tm.Id} value={tm.id || tm.Id}>{tm.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={e => updateFilter('priority', e.target.value)}
|
||||
@@ -332,7 +388,7 @@ export default function Issues() {
|
||||
) : (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{STATUS_ORDER.map(status => {
|
||||
const config = STATUS_CONFIG[status]
|
||||
const config = ISSUE_STATUS_CONFIG[status]
|
||||
const columnIssues = filteredIssues.filter(i => i.status === status)
|
||||
return (
|
||||
<div
|
||||
@@ -391,10 +447,20 @@ export default function Issues() {
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar
|
||||
selectedCount={selectedIds.size}
|
||||
onClearSelection={() => setSelectedIds(new Set())}
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-surface-secondary border-b border-border">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||
Title <SortIcon col="title" />
|
||||
</th>
|
||||
@@ -424,6 +490,9 @@ export default function Issues() {
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
className="hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(issue.Id || issue.id)} onChange={() => toggleSelect(issue.Id || issue.id)} className="rounded border-border" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-text-primary">{issue.title}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
<div>{issue.submitter_name}</div>
|
||||
@@ -459,6 +528,19 @@ export default function Issues() {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Bulk Delete Confirm */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedIssue && (
|
||||
<IssueDetailPanel
|
||||
@@ -466,6 +548,7 @@ export default function Issues() {
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onUpdate={loadData}
|
||||
teamMembers={teamMembers}
|
||||
teams={teams}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Login() {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Invalid email or password')
|
||||
setError(err.message || t('login.invalidCredentials'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export default function Login() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (setupPassword !== setupConfirm) {
|
||||
setError('Passwords do not match')
|
||||
setError(t('login.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
@@ -55,7 +55,7 @@ export default function Login() {
|
||||
setNeedsSetup(false)
|
||||
setEmail(setupEmail)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Setup failed')
|
||||
setError(err.message || t('login.setupFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -78,10 +78,10 @@ export default function Login() {
|
||||
<Megaphone className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
{needsSetup ? 'Initial Setup' : t('login.title')}
|
||||
{needsSetup ? t('login.initialSetup') : t('login.title')}
|
||||
</h1>
|
||||
<p className="text-slate-400">
|
||||
{needsSetup ? 'Create your superadmin account to get started' : t('login.subtitle')}
|
||||
{needsSetup ? t('login.initialSetupDesc') : t('login.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function Login() {
|
||||
{setupDone && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 shrink-0" />
|
||||
<p className="text-sm text-green-400">Account created. You can now log in.</p>
|
||||
<p className="text-sm text-green-400">{t('login.accountCreated')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function Login() {
|
||||
<form onSubmit={handleSetup} className="space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.fullName')}</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
@@ -107,7 +107,7 @@ export default function Login() {
|
||||
value={setupName}
|
||||
onChange={(e) => setSetupName(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Your name"
|
||||
placeholder={t('login.fullNamePlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
@@ -116,7 +116,7 @@ export default function Login() {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Email</label>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
@@ -133,7 +133,7 @@ export default function Login() {
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Password</label>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
@@ -141,7 +141,7 @@ export default function Login() {
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Choose a strong password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
@@ -150,7 +150,7 @@ export default function Login() {
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Confirm Password</label>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">{t('login.confirmPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
@@ -158,7 +158,7 @@ export default function Login() {
|
||||
value={setupConfirm}
|
||||
onChange={(e) => setSetupConfirm(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Re-enter your password"
|
||||
placeholder={t('login.confirmPasswordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
@@ -182,10 +182,10 @@ export default function Login() {
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Creating account...
|
||||
{t('login.creatingAccount')}
|
||||
</span>
|
||||
) : (
|
||||
'Create Superadmin Account'
|
||||
t('login.createAccount')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
@@ -39,6 +39,19 @@ function getMonthData(year, month) {
|
||||
return cells
|
||||
}
|
||||
|
||||
function getWeekData(startDate) {
|
||||
const cells = []
|
||||
const start = new Date(startDate)
|
||||
// Align to Sunday
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(start)
|
||||
d.setDate(start.getDate() + i)
|
||||
cells.push({ day: d.getDate(), current: true, date: d })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
@@ -53,6 +66,10 @@ export default function PostCalendar() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ brand: '', platform: '', status: '' })
|
||||
const [selectedPost, setSelectedPost] = useState(null)
|
||||
const [calView, setCalView] = useState('month') // 'month' | 'week'
|
||||
const [weekStart, setWeekStart] = useState(() => {
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); return d
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
@@ -61,7 +78,7 @@ export default function PostCalendar() {
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const res = await api.get('/posts')
|
||||
setPosts(res.data || res || [])
|
||||
setPosts(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts:', err)
|
||||
} finally {
|
||||
@@ -69,7 +86,7 @@ export default function PostCalendar() {
|
||||
}
|
||||
}
|
||||
|
||||
const cells = getMonthData(year, month)
|
||||
const cells = calView === 'month' ? getMonthData(year, month) : getWeekData(weekStart)
|
||||
const todayKey = dateKey(today)
|
||||
|
||||
// Filter posts
|
||||
@@ -105,9 +122,22 @@ export default function PostCalendar() {
|
||||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||
else setMonth(m => m + 1)
|
||||
}
|
||||
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
|
||||
const prevWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n })
|
||||
const nextWeek = () => setWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n })
|
||||
|
||||
const goToday = () => {
|
||||
setYear(today.getFullYear()); setMonth(today.getMonth())
|
||||
const d = new Date(); d.setDate(d.getDate() - d.getDay()); setWeekStart(d)
|
||||
}
|
||||
|
||||
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
||||
const weekLabel = (() => {
|
||||
const start = new Date(weekStart)
|
||||
start.setDate(start.getDate() - start.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
const fmt = (d) => d.toLocaleString('default', { month: 'short', day: 'numeric' })
|
||||
return `${fmt(start)} – ${fmt(end)}, ${end.getFullYear()}`
|
||||
})()
|
||||
|
||||
const handlePostClick = (post) => {
|
||||
setSelectedPost(post)
|
||||
@@ -176,17 +206,37 @@ export default function PostCalendar() {
|
||||
{/* Nav */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<button onClick={calView === 'month' ? prevMonth : prevWeek} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold text-text-primary min-w-[180px] text-center">{monthLabel}</h3>
|
||||
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<h3 className="text-lg font-semibold text-text-primary min-w-[220px] text-center">
|
||||
{calView === 'month' ? monthLabel : weekLabel}
|
||||
</h3>
|
||||
<button onClick={calView === 'month' ? nextMonth : nextWeek} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</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
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setCalView('month')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
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-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" />
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
@@ -207,7 +257,7 @@ export default function PostCalendar() {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-b border-border min-h-[110px] p-2 ${
|
||||
className={`border-r border-b border-border ${calView === 'week' ? 'min-h-[300px]' : 'min-h-[110px]'} p-2 ${
|
||||
cell.current ? 'bg-surface' : 'bg-surface-secondary/30'
|
||||
} ${i % 7 === 6 ? 'border-r-0' : ''}`}
|
||||
>
|
||||
@@ -217,7 +267,7 @@ export default function PostCalendar() {
|
||||
{cell.day}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{dayPosts.slice(0, 3).map(post => (
|
||||
{dayPosts.slice(0, calView === 'week' ? 10 : 3).map(post => (
|
||||
<button
|
||||
key={post.Id || post._id}
|
||||
onClick={() => handlePostClick(post)}
|
||||
@@ -229,9 +279,9 @@ export default function PostCalendar() {
|
||||
{post.title}
|
||||
</button>
|
||||
))}
|
||||
{dayPosts.length > 3 && (
|
||||
{dayPosts.length > (calView === 'week' ? 10 : 3) && (
|
||||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||
+{dayPosts.length - 3} more
|
||||
+{dayPosts.length - (calView === 'week' ? 10 : 3)} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import PostDetailPanel from '../components/PostDetailPanel'
|
||||
import DatePresetPicker from '../components/DatePresetPicker'
|
||||
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const EMPTY_POST = {
|
||||
@@ -32,16 +34,18 @@ export default function PostProduction() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activePreset, setActivePreset] = useState('')
|
||||
const [moveError, setMoveError] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const res = await api.get('/posts')
|
||||
setPosts(res.data || res || [])
|
||||
setPosts(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts:', err)
|
||||
} finally {
|
||||
@@ -88,9 +92,36 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/posts/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success(t('posts.deleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === filteredPosts.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(filteredPosts.map(p => p._id || p.id || p.Id)))
|
||||
}
|
||||
|
||||
const openEdit = (post) => {
|
||||
if (!canEditResource('post', post)) {
|
||||
alert('You can only edit your own posts')
|
||||
toast.error(t('posts.canOnlyEditOwn'))
|
||||
return
|
||||
}
|
||||
setPanelPost(post)
|
||||
@@ -244,9 +275,18 @@ export default function PostProduction() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="px-4 pt-3">
|
||||
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||
@@ -256,15 +296,37 @@ export default function PostProduction() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{filteredPosts.map(post => (
|
||||
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
|
||||
))}
|
||||
{filteredPosts.map(post => {
|
||||
const postId = post._id || post.id || post.Id
|
||||
return (
|
||||
<PostCard
|
||||
key={postId}
|
||||
post={post}
|
||||
onClick={() => openEdit(post)}
|
||||
checkboxSlot={<input type="checkbox" checked={selectedIds.has(postId)} onChange={() => toggleSelect(postId)} className="rounded border-border" />}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Post Detail Panel */}
|
||||
{panelPost && (
|
||||
<PostDetailPanel
|
||||
|
||||
@@ -50,15 +50,15 @@ export default function ProjectDetail() {
|
||||
|
||||
useEffect(() => { loadProject() }, [id])
|
||||
useEffect(() => {
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const proj = await api.get(`/projects/${id}`)
|
||||
setProject(proj.data || proj)
|
||||
setProject(proj)
|
||||
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
} finally {
|
||||
@@ -458,7 +458,14 @@ export default function ProjectDetail() {
|
||||
)}
|
||||
|
||||
{/* ─── GANTT / TIMELINE VIEW ─── */}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} onTaskColorChange={async (taskId, color) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { color: color || '' })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Task color update failed:', err)
|
||||
}
|
||||
}} />}
|
||||
</div>{/* end main content */}
|
||||
|
||||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||||
@@ -576,7 +583,35 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
||||
}
|
||||
|
||||
// ─── Gantt / Timeline View ──────────────────────────
|
||||
function GanttView({ tasks, project, onEditTask }) {
|
||||
const GANTT_ZOOM = [
|
||||
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
]
|
||||
|
||||
const GANTT_COLOR_PALETTE = [
|
||||
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
||||
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
||||
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
||||
]
|
||||
|
||||
function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
const [zoomIdx, setZoomIdx] = useState(0)
|
||||
const ganttRef = useRef(null)
|
||||
const [colorPicker, setColorPicker] = useState(null)
|
||||
const colorPickerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorPicker) return
|
||||
const handleClick = (e) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
|
||||
setColorPicker(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [colorPicker])
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||||
@@ -590,17 +625,19 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
const today = startOfDay(new Date())
|
||||
|
||||
// Calculate range
|
||||
let earliest = today
|
||||
let latest = addDays(today, 21)
|
||||
let earliest = addDays(today, -7)
|
||||
let latest = addDays(today, 30)
|
||||
tasks.forEach(t => {
|
||||
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
|
||||
const start = t.startDate || t.start_date ? startOfDay(new Date(t.startDate || t.start_date)) : created
|
||||
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
|
||||
if (isBefore(created, earliest)) earliest = created
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 2)
|
||||
if (isBefore(start, earliest)) earliest = addDays(start, -3)
|
||||
if (isBefore(created, earliest)) earliest = addDays(created, -3)
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 7)
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const pd = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 2)
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 7)
|
||||
}
|
||||
const totalDays = differenceInDays(latest, earliest) + 1
|
||||
|
||||
@@ -610,7 +647,7 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
days.push(addDays(earliest, i))
|
||||
}
|
||||
|
||||
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
|
||||
const dayWidth = GANTT_ZOOM[zoomIdx].pxPerDay
|
||||
|
||||
const getBarStyle = (task) => {
|
||||
const start = task.startDate || task.start_date
|
||||
@@ -630,7 +667,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{/* Zoom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
{GANTT_ZOOM.map((z, i) => (
|
||||
<button
|
||||
key={z.key}
|
||||
onClick={() => setZoomIdx(i)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
zoomIdx === i
|
||||
? 'bg-brand-primary text-white shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{z.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (ganttRef.current) {
|
||||
const todayOff = differenceInDays(today, earliest) * dayWidth
|
||||
ganttRef.current.scrollTo({ left: Math.max(0, todayOff - 200), behavior: 'smooth' })
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={ganttRef} className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
|
||||
@@ -641,6 +709,8 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
{days.map((day, i) => {
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
const isMonthStart = day.getDate() === 1
|
||||
const isWeekStart = day.getDay() === 1
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -648,10 +718,17 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
||||
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
||||
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
|
||||
}`}
|
||||
} ${isMonthStart ? 'border-l-2 border-l-text-tertiary/30' : ''}`}
|
||||
>
|
||||
<div>{format(day, 'd')}</div>
|
||||
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
|
||||
{dayWidth >= 30 && <div>{format(day, 'd')}</div>}
|
||||
{dayWidth >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
||||
{dayWidth >= 15 && dayWidth < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{dayWidth < 15 && isMonthStart && (
|
||||
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
|
||||
)}
|
||||
{dayWidth < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
|
||||
<div className="text-[8px]">{format(day, 'd')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -665,7 +742,20 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
return (
|
||||
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
|
||||
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
|
||||
{onTaskColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const taskId = task._id || task.id
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.taskId === taskId ? null : { taskId, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-3.5 h-3.5 rounded-full border border-white shadow-sm shrink-0 hover:scale-125 transition-transform ${!task.color ? (statusColors[task.status] || 'bg-gray-300') : ''}`}
|
||||
style={task.color ? { backgroundColor: task.color } : undefined}
|
||||
title="Change color"
|
||||
/>
|
||||
)}
|
||||
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
|
||||
<button onClick={() => onEditTask(task)}
|
||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
||||
{task.title}
|
||||
@@ -681,8 +771,8 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
)}
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={barStyle}
|
||||
className={`absolute top-2.5 h-5 rounded-full ${task.color ? '' : (statusColors[task.status] || 'bg-gray-300')} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={{ ...barStyle, ...(task.color ? { backgroundColor: task.color } : {}) }}
|
||||
onClick={() => onEditTask(task)}
|
||||
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
||||
/>
|
||||
@@ -692,6 +782,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{colorPicker && onTaskColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
|
||||
style={{ left: colorPicker.x, top: colorPicker.y }}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
||||
{GANTT_COLOR_PALETTE.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onTaskColorChange(colorPicker.taskId, c)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onTaskColorChange(colorPicker.taskId, null)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Projects() {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const res = await api.get('/projects')
|
||||
setProjects(res.data || res || [])
|
||||
setProjects(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err)
|
||||
} finally {
|
||||
@@ -146,6 +146,7 @@ export default function Projects() {
|
||||
assigneeName: project.ownerName || project.owner_name,
|
||||
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
|
||||
tags: [project.status, project.priority].filter(Boolean),
|
||||
color: project.color,
|
||||
})}
|
||||
onDateChange={async (projectId, { startDate, endDate }) => {
|
||||
try {
|
||||
@@ -156,6 +157,15 @@ export default function Projects() {
|
||||
loadProjects()
|
||||
}
|
||||
}}
|
||||
onColorChange={async (projectId, color) => {
|
||||
try {
|
||||
await api.patch(`/projects/${projectId}`, { color: color || '' })
|
||||
} catch (err) {
|
||||
console.error('Color update failed:', err)
|
||||
} finally {
|
||||
loadProjects()
|
||||
}
|
||||
}}
|
||||
onItemClick={(project) => {
|
||||
navigate(`/projects/${project._id || project.id}`)
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AlertCircle, Send, CheckCircle2, Upload, X } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import FormInput from '../components/FormInput'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
@@ -19,6 +20,12 @@ const PRIORITY_OPTIONS = [
|
||||
]
|
||||
|
||||
export default function PublicIssueSubmit() {
|
||||
const toast = useToast()
|
||||
|
||||
// Team pre-selection from URL
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const teamParam = urlParams.get('team')
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -28,12 +35,20 @@ export default function PublicIssueSubmit() {
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
team_id: teamParam || '',
|
||||
})
|
||||
const [file, setFile] = useState(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [trackingToken, setTrackingToken] = useState('')
|
||||
const [errors, setErrors] = useState({})
|
||||
const [teams, setTeams] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamParam) {
|
||||
api.get('/public/teams').then(r => setTeams(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateForm = (field, value) => {
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
@@ -75,6 +90,9 @@ export default function PublicIssueSubmit() {
|
||||
formData.append('priority', form.priority)
|
||||
formData.append('title', form.title)
|
||||
formData.append('description', form.description)
|
||||
if (form.team_id) {
|
||||
formData.append('team_id', form.team_id)
|
||||
}
|
||||
if (file) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
@@ -84,7 +102,7 @@ export default function PublicIssueSubmit() {
|
||||
setSubmitted(true)
|
||||
} catch (err) {
|
||||
console.error('Submit error:', err)
|
||||
alert('Failed to submit issue. Please try again.')
|
||||
toast.error('Failed to submit issue. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -116,7 +134,7 @@ export default function PublicIssueSubmit() {
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(trackingUrl)
|
||||
alert('Copied to clipboard!')
|
||||
toast.success('Copied to clipboard!')
|
||||
}}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
@@ -145,6 +163,7 @@ export default function PublicIssueSubmit() {
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
team_id: teamParam || '',
|
||||
})
|
||||
setFile(null)
|
||||
}}
|
||||
@@ -205,10 +224,27 @@ export default function PublicIssueSubmit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Selection */}
|
||||
{!teamParam && teams.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Which team should handle your issue?</h2>
|
||||
<select
|
||||
value={form.team_id}
|
||||
onChange={(e) => updateForm('team_id', e.target.value)}
|
||||
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 transition-colors"
|
||||
>
|
||||
<option value="">Select a team</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>{team.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue Details */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Issue Details</h2>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
|
||||
@@ -20,6 +21,7 @@ const PRIORITY_CONFIG = {
|
||||
|
||||
export default function PublicIssueTracker() {
|
||||
const { token } = useParams()
|
||||
const toast = useToast()
|
||||
const [issue, setIssue] = useState(null)
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [attachments, setAttachments] = useState([])
|
||||
@@ -68,7 +70,7 @@ export default function PublicIssueTracker() {
|
||||
await loadIssue()
|
||||
} catch (err) {
|
||||
console.error('Failed to add comment:', err)
|
||||
alert('Failed to add comment')
|
||||
toast.error('Failed to add comment')
|
||||
} finally {
|
||||
setSubmittingComment(false)
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export default function PublicIssueTracker() {
|
||||
e.target.value = '' // Reset input
|
||||
} catch (err) {
|
||||
console.error('Failed to upload file:', err)
|
||||
alert('Failed to upload file')
|
||||
toast.error('Failed to upload file')
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const STATUS_ICONS = {
|
||||
copy: FileText,
|
||||
@@ -11,6 +14,8 @@ const STATUS_ICONS = {
|
||||
|
||||
export default function PublicReview() {
|
||||
const { token } = useParams()
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [artefact, setArtefact] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -19,6 +24,7 @@ export default function PublicReview() {
|
||||
const [reviewerName, setReviewerName] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(0)
|
||||
const [pendingAction, setPendingAction] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadArtefact()
|
||||
@@ -29,7 +35,7 @@ export default function PublicReview() {
|
||||
const res = await fetch(`/api/public/review/${token}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || 'Failed to load artefact')
|
||||
setError(err.error || t('review.loadFailed'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -40,25 +46,32 @@ export default function PublicReview() {
|
||||
setReviewerName(data.approvers[0].name)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load artefact')
|
||||
setError(t('review.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
const handleAction = (action) => {
|
||||
if (!reviewerName.trim()) {
|
||||
alert('Please select or enter your name')
|
||||
toast.error(t('review.enterName'))
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'approve' && !confirm('Approve this artefact?')) return
|
||||
if (action === 'reject' && !confirm('Reject this artefact?')) return
|
||||
if (action === 'revision' && !feedback.trim()) {
|
||||
alert('Please provide feedback for revision request')
|
||||
toast.error(t('review.feedbackRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'approve' || action === 'reject') {
|
||||
setPendingAction(action)
|
||||
return
|
||||
}
|
||||
|
||||
executeAction(action)
|
||||
}
|
||||
|
||||
const executeAction = async (action) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review/${token}/${action}`, {
|
||||
@@ -72,18 +85,18 @@ export default function PublicReview() {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || 'Action failed')
|
||||
setError(err.error || t('review.actionFailed'))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSuccess(data.message || 'Action completed successfully')
|
||||
setSuccess(data.message || t('review.actionCompleted'))
|
||||
setTimeout(() => {
|
||||
loadArtefact()
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
setError('Action failed')
|
||||
setError(t('review.actionFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -133,7 +146,7 @@ export default function PublicReview() {
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">Review Not Available</h2>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.notAvailable')}</h2>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +160,7 @@ export default function PublicReview() {
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">Thank You!</h2>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
|
||||
<p className="text-text-secondary">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +183,7 @@ export default function PublicReview() {
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Content Review</h1>
|
||||
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
|
||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,7 +203,7 @@ export default function PublicReview() {
|
||||
<div className="flex items-center gap-3 text-sm text-text-tertiary">
|
||||
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full capitalize">{artefact.type}</span>
|
||||
{artefact.brand_name && <span>• {artefact.brand_name}</span>}
|
||||
{artefact.version_number && <span>• Version {artefact.version_number}</span>}
|
||||
{artefact.version_number && <span>• {t('review.version')} {artefact.version_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +213,7 @@ export default function PublicReview() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Content Languages</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.contentLanguages')}</h3>
|
||||
</div>
|
||||
|
||||
{/* Language tabs */}
|
||||
@@ -226,7 +239,7 @@ export default function PublicReview() {
|
||||
{/* Selected language content */}
|
||||
<div className="bg-surface-secondary rounded-xl p-6 border border-border">
|
||||
<div className="mb-2 text-xs font-semibold text-text-tertiary uppercase">
|
||||
{artefact.texts[selectedLanguage].language_label} Content
|
||||
{artefact.texts[selectedLanguage].language_label} {t('review.content')}
|
||||
</div>
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||
{artefact.texts[selectedLanguage].content}
|
||||
@@ -238,7 +251,7 @@ export default function PublicReview() {
|
||||
{/* Legacy content field (for backward compatibility) */}
|
||||
{artefact.content && (!artefact.texts || artefact.texts.length === 0) && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">Content</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">{t('review.content')}</h3>
|
||||
<div className="bg-surface-secondary rounded-xl p-4 border border-border">
|
||||
<pre className="text-text-primary whitespace-pre-wrap font-sans text-sm leading-relaxed">
|
||||
{artefact.content}
|
||||
@@ -252,7 +265,7 @@ export default function PublicReview() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Design Files</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.designFiles')}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{artefact.attachments.map((att, idx) => (
|
||||
@@ -284,7 +297,7 @@ export default function PublicReview() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Film className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Videos</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.videos')}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{artefact.attachments.map((att, idx) => (
|
||||
@@ -293,7 +306,7 @@ export default function PublicReview() {
|
||||
<div>
|
||||
<div className="px-4 py-2 bg-surface border-b border-border flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm font-medium text-text-secondary">Google Drive Video</span>
|
||||
<span className="text-sm font-medium text-text-secondary">{t('review.googleDriveVideo')}</span>
|
||||
</div>
|
||||
<iframe
|
||||
src={getDriveEmbedUrl(att.drive_url)}
|
||||
@@ -325,7 +338,7 @@ export default function PublicReview() {
|
||||
{/* OTHER TYPE: Generic Attachments */}
|
||||
{artefact.type === 'other' && artefact.attachments && artefact.attachments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Attachments</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.attachments')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{artefact.attachments.map((att, idx) => (
|
||||
<div key={idx}>
|
||||
@@ -372,7 +385,7 @@ export default function PublicReview() {
|
||||
{/* Comments */}
|
||||
{artefact.comments && artefact.comments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Previous Comments</h3>
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.previousComments')}</h3>
|
||||
<div className="space-y-3">
|
||||
{artefact.comments.map((comment, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary rounded-lg p-3 border border-border">
|
||||
@@ -396,12 +409,12 @@ export default function PublicReview() {
|
||||
{/* Review Form */}
|
||||
{artefact.status === 'pending_review' && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">Your Review</h3>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Reviewer identity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Reviewer</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
|
||||
{artefact.approvers?.length === 1 ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
|
||||
<User className="w-4 h-4 text-text-tertiary" />
|
||||
@@ -413,7 +426,7 @@ export default function PublicReview() {
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
className="w-full px-4 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 transition-colors"
|
||||
>
|
||||
<option value="">Select your name...</option>
|
||||
<option value="">{t('review.selectYourName')}</option>
|
||||
{artefact.approvers.map(a => (
|
||||
<option key={a.id} value={a.name}>{a.name}</option>
|
||||
))}
|
||||
@@ -423,19 +436,19 @@ export default function PublicReview() {
|
||||
type="text"
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
placeholder={t('review.enterYourName')}
|
||||
className="w-full px-4 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 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Feedback (optional)</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackOptional')}</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Share your thoughts, suggestions, or required changes..."
|
||||
placeholder={t('review.feedbackPlaceholder')}
|
||||
className="w-full px-4 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 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -448,7 +461,7 @@ export default function PublicReview() {
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Approve
|
||||
{t('review.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('revision')}
|
||||
@@ -456,7 +469,7 @@ export default function PublicReview() {
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
Request Revision
|
||||
{t('review.requestRevision')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('reject')}
|
||||
@@ -464,7 +477,7 @@ export default function PublicReview() {
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
Reject
|
||||
{t('review.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -475,14 +488,14 @@ export default function PublicReview() {
|
||||
<div className="border-t border-border pt-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
|
||||
<p className="text-blue-900 font-medium">
|
||||
This artefact has already been reviewed.
|
||||
{t('review.alreadyReviewed')}
|
||||
</p>
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
Status: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
|
||||
{t('review.statusLabel')}: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
|
||||
</p>
|
||||
{artefact.approved_by_name && (
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
Reviewed by: <span className="font-semibold">{artefact.approved_by_name}</span>
|
||||
{t('review.reviewedBy')}: <span className="font-semibold">{artefact.approved_by_name}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -493,9 +506,26 @@ export default function PublicReview() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-text-tertiary text-sm">
|
||||
<p>Powered by Samaya Digital Hub</p>
|
||||
<p>{t('review.poweredBy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approve / Reject Confirmation */}
|
||||
<Modal
|
||||
isOpen={!!pendingAction}
|
||||
onClose={() => setPendingAction(null)}
|
||||
title={pendingAction === 'approve' ? t('review.confirmApprove') : t('review.confirmReject')}
|
||||
isConfirm
|
||||
danger={pendingAction === 'reject'}
|
||||
onConfirm={() => {
|
||||
const action = pendingAction
|
||||
setPendingAction(null)
|
||||
executeAction(action)
|
||||
}}
|
||||
confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
|
||||
>
|
||||
{pendingAction === 'approve' ? t('review.confirmApproveDesc') : t('review.confirmRejectDesc')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useState, useEffect } from 'react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||
|
||||
export default function Settings() {
|
||||
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||
@@ -25,7 +27,7 @@ export default function Settings() {
|
||||
setSizeSaved(true)
|
||||
setTimeout(() => setSizeSaved(false), 2000)
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to save')
|
||||
toast.error(err.message || t('settings.saveFailed'))
|
||||
} finally {
|
||||
setSizeSaving(false)
|
||||
}
|
||||
@@ -42,7 +44,7 @@ export default function Settings() {
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('Failed to restart tutorial:', err)
|
||||
alert('Failed to restart tutorial')
|
||||
toast.error(t('settings.restartTutorialFailed'))
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
||||
import TaskCard from '../components/TaskCard'
|
||||
import TaskDetailPanel from '../components/TaskDetailPanel'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
import TaskCalendarView from '../components/TaskCalendarView'
|
||||
import DatePresetPicker from '../components/DatePresetPicker'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
@@ -45,6 +47,8 @@ export default function Tasks() {
|
||||
const [filterOverdue, setFilterOverdue] = useState(false)
|
||||
const [activePreset, setActivePreset] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
// Assignable users & team
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
@@ -54,17 +58,17 @@ export default function Tasks() {
|
||||
|
||||
useEffect(() => { loadTasks() }, [currentUser])
|
||||
useEffect(() => {
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
||||
api.get('/projects').then(res => setProjects(res.data || res || [])).catch(() => {})
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
api.get('/projects').then(res => setProjects(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
if (isSuperadmin) {
|
||||
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
|
||||
api.get('/team').then(res => setUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
}
|
||||
}, [isSuperadmin])
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const res = await api.get('/tasks')
|
||||
setTasks(res.data || res || [])
|
||||
setTasks(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks:', err)
|
||||
} finally {
|
||||
@@ -177,6 +181,33 @@ export default function Tasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/tasks/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success(t('tasks.deleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === sortedListTasks.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(sortedListTasks.map(t => t._id || t.id)))
|
||||
}
|
||||
|
||||
const handleMove = async (taskId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
@@ -594,10 +625,27 @@ export default function Tasks() {
|
||||
|
||||
{/* ─── List View ───────────────────────── */}
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar
|
||||
selectedCount={selectedIds.size}
|
||||
onClear={() => setSelectedIds(new Set())}
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary/50">
|
||||
<th className="w-8 px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sortedListTasks.length > 0 && selectedIds.size === sortedListTasks.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</th>
|
||||
<th className="w-8 px-3 py-2.5"></th>
|
||||
<th
|
||||
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
|
||||
@@ -645,6 +693,14 @@ export default function Tasks() {
|
||||
onClick={() => openTask(task)}
|
||||
className="border-b border-border-light hover:bg-surface-secondary/30 cursor-pointer transition-colors group"
|
||||
>
|
||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(task._id || task.id)}
|
||||
onChange={() => toggleSelect(task._id || task.id)}
|
||||
className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${priority.color}`} title={priority.label} />
|
||||
</td>
|
||||
@@ -686,6 +742,7 @@ export default function Tasks() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Calendar View ───────────────────── */}
|
||||
@@ -695,6 +752,19 @@ export default function Tasks() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Bulk Delete Confirmation Modal ─────── */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.deleteSelected')}
|
||||
onConfirm={handleBulkDelete}
|
||||
>
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* ─── Task Detail Side Panel ──────────────── */}
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network } from 'lucide-react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2 } from 'lucide-react'
|
||||
import { getInitials } from '../utils/api'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -10,9 +10,11 @@ import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import TeamMemberPanel from '../components/TeamMemberPanel'
|
||||
import TeamPanel from '../components/TeamPanel'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
export default function Team() {
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
|
||||
const { user } = useAuth()
|
||||
const [panelMember, setPanelMember] = useState(null)
|
||||
@@ -27,6 +29,13 @@ export default function Team() {
|
||||
|
||||
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
|
||||
|
||||
const copyIssueLink = (teamId) => {
|
||||
const base = `${window.location.origin}/submit-issue`
|
||||
const url = teamId ? `${base}?team=${teamId}` : base
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success(t('issues.linkCopied'))
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setPanelMember({ role: 'content_writer' })
|
||||
setPanelIsEditingSelf(false)
|
||||
@@ -85,7 +94,7 @@ export default function Team() {
|
||||
await loadTeams()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert(err.message || 'Failed to save')
|
||||
toast.error(err.message || t('common.failedToSave'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +109,7 @@ export default function Team() {
|
||||
await loadTeam()
|
||||
} catch (err) {
|
||||
console.error('Team save failed:', err)
|
||||
alert(err.message || 'Failed to save team')
|
||||
toast.error(err.message || t('team.failedToSaveTeam'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +328,16 @@ export default function Team() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Copy generic issue link */}
|
||||
<button
|
||||
onClick={() => copyIssueLink()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
title={t('team.copyGenericIssueLink')}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
{t('issues.copyPublicLink')}
|
||||
</button>
|
||||
|
||||
{/* Edit own profile button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -443,14 +462,23 @@ export default function Team() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManageTeam && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPanelTeam(team)}
|
||||
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
onClick={() => copyIssueLink(tid)}
|
||||
className="px-2 py-1.5 text-sm text-text-tertiary hover:text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
title={t('team.copyIssueLink')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<Link2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={() => setPanelTeam(team)}
|
||||
className="px-2 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team members */}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from '../components/Modal'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import { SkeletonTable } from '../components/SkeletonLoader'
|
||||
|
||||
const ROLES = [
|
||||
@@ -27,6 +29,8 @@ function RoleBadge({ role }) {
|
||||
|
||||
export default function Users() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
@@ -53,7 +57,7 @@ export default function Users() {
|
||||
const handleSave = async () => {
|
||||
setPasswordError('')
|
||||
if (form.password && form.password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match')
|
||||
setPasswordError(t('users.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -69,7 +73,7 @@ export default function Users() {
|
||||
await api.patch(`/users/${editingUser.id}`, data)
|
||||
} else {
|
||||
if (!form.password) {
|
||||
alert('Password is required for new users')
|
||||
toast.error(t('users.passwordRequired'))
|
||||
return
|
||||
}
|
||||
data.password = form.password
|
||||
@@ -81,7 +85,7 @@ export default function Users() {
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert('Failed to save user: ' + err.message)
|
||||
toast.error(t('users.saveFailed') + ': ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +119,7 @@ export default function Users() {
|
||||
setUserToDelete(null)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete user')
|
||||
toast.error(t('users.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,16 +139,16 @@ export default function Users() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Shield className="w-7 h-7 text-purple-600" />
|
||||
User Management
|
||||
{t('users.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-text-tertiary mt-1">{users.length} user{users.length !== 1 ? 's' : ''}</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">{users.length} {users.length !== 1 ? t('users.usersPlural') : t('users.userSingular')}</p>
|
||||
</div>
|
||||
<button
|
||||
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" />
|
||||
Add User
|
||||
{t('users.addUser')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -153,18 +157,18 @@ export default function Users() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">User</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Created</th>
|
||||
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">Actions</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.userSingular')}</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.email')}</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.role')}</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('users.created')}</th>
|
||||
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">{t('users.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
|
||||
No users found
|
||||
{t('users.noUsers')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -183,7 +187,7 @@ export default function Users() {
|
||||
<p className="text-sm font-medium text-text-primary">{user.name}</p>
|
||||
{isCurrentUser && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
||||
You
|
||||
{t('users.you')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -202,7 +206,7 @@ export default function Users() {
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
||||
title="Edit user"
|
||||
title={t('users.editUser')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -210,7 +214,7 @@ export default function Users() {
|
||||
<button
|
||||
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
|
||||
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
|
||||
title="Delete user"
|
||||
title={t('users.deleteUser')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -229,24 +233,24 @@ export default function Users() {
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingUser(null) }}
|
||||
title={editingUser ? 'Edit User' : 'Add New User'}
|
||||
title={editingUser ? t('users.editUser') : t('users.addNewUser')}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.name')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: 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"
|
||||
placeholder="Full name"
|
||||
placeholder={t('users.fullNamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Email *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
@@ -259,7 +263,7 @@ export default function Users() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Password {editingUser && '(leave blank to keep current)'}
|
||||
{t('users.password')} {editingUser && `(${t('users.leaveBlankToKeep')})`}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -273,7 +277,7 @@ export default function Users() {
|
||||
|
||||
{form.password && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Confirm Password</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
@@ -288,7 +292,7 @@ export default function Users() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('users.role')} *</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ROLES.map(r => (
|
||||
<button
|
||||
@@ -313,14 +317,14 @@ export default function Users() {
|
||||
onClick={() => { setShowModal(false); setEditingUser(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || !form.email || (!editingUser && !form.password)}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{editingUser ? 'Save Changes' : 'Add User'}
|
||||
{editingUser ? t('users.saveChanges') : t('users.addUser')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,13 +334,13 @@ export default function Users() {
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
|
||||
title="Delete User?"
|
||||
title={t('users.deleteUserConfirmTitle')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete User"
|
||||
confirmText={t('users.deleteUser')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This action cannot be undone.
|
||||
{t('users.deleteConfirm')} <strong>{userToDelete?.name}</strong>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user