Skip to content

Commit cbe17be

Browse files
authored
Merge pull request #333 from codeunia-dev/feat/companydashboard
Feat: Company Dashboard Enhancements & Member Role Notifications
2 parents fd21917 + 456eabf commit cbe17be

File tree

5 files changed

+234
-73
lines changed

5 files changed

+234
-73
lines changed

app/api/companies/[slug]/members/[userId]/route.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { companyMemberService } from '@/lib/services/company-member-service'
55
import { CompanyError } from '@/types/company'
66
import { UnifiedCache } from '@/lib/unified-cache-system'
77
import { z } from 'zod'
8+
import { getRoleChangeEmail, sendCompanyEmail } from '@/lib/email/company-emails'
89

910
// Force Node.js runtime for API routes
1011
export const runtime = 'nodejs'
@@ -98,12 +99,59 @@ export async function PUT(
9899
)
99100
}
100101

102+
// Store old role for email notification
103+
const oldRole = targetMember.role
104+
101105
// Update member role
102106
const updatedMember = await companyMemberService.updateMemberRole(
103107
targetMember.id,
104108
role
105109
)
106110

111+
// Get member's profile information for email
112+
const { data: memberProfile } = await supabase
113+
.from('profiles')
114+
.select('email, first_name, last_name')
115+
.eq('id', userId)
116+
.single()
117+
118+
// Get requesting user's name for email
119+
const { data: requestingUserProfile } = await supabase
120+
.from('profiles')
121+
.select('first_name, last_name')
122+
.eq('id', user.id)
123+
.single()
124+
125+
const changedByName = requestingUserProfile?.first_name
126+
? `${requestingUserProfile.first_name} ${requestingUserProfile.last_name || ''}`.trim()
127+
: 'a team administrator'
128+
129+
// Send role change notification email
130+
if (memberProfile?.email && oldRole !== role) {
131+
const memberName = memberProfile.first_name || memberProfile.email.split('@')[0]
132+
const dashboardUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://codeunia.com'}/dashboard/company/${company.slug}`
133+
134+
const emailContent = getRoleChangeEmail({
135+
memberName,
136+
companyName: company.name,
137+
oldRole,
138+
newRole: role,
139+
changedBy: changedByName,
140+
dashboardUrl,
141+
})
142+
143+
// Send email asynchronously (don't wait for it)
144+
console.log(`📧 Sending role change email to ${memberProfile.email}: ${oldRole}${role}`)
145+
sendCompanyEmail({
146+
to: memberProfile.email,
147+
subject: emailContent.subject,
148+
html: emailContent.html,
149+
}).catch(error => {
150+
console.error('❌ Failed to send role change email:', error)
151+
// Don't fail the request if email fails
152+
})
153+
}
154+
107155
// Invalidate cache
108156
await UnifiedCache.purgeByTags(['content', 'api'])
109157

app/dashboard/company/layout.tsx

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from 'lucide-react'
1919
import { useAuth } from '@/lib/hooks/useAuth'
2020
import { useRoleProtection } from '@/lib/hooks/useRoleProtection'
21+
import { createClient } from '@/lib/supabase/client'
2122

2223
export type SidebarGroupType = {
2324
title: string
@@ -95,37 +96,68 @@ export default function CompanyDashboardLayout({
9596
)
9697
}
9798

98-
const avatar =
99-
user?.user_metadata?.first_name?.[0]?.toUpperCase() ||
100-
user?.email?.[0]?.toUpperCase() ||
101-
'C'
102-
const name = user?.user_metadata?.first_name || user?.email || 'User'
103-
const email = user?.email || 'user@codeunia.com'
104-
10599
return (
106100
<CompanyProvider initialCompanySlug={companySlug}>
107-
<CompanyDashboardContent avatar={avatar} name={name} email={email}>
101+
<CompanyDashboardContent user={user}>
108102
{children}
109103
</CompanyDashboardContent>
110104
</CompanyProvider>
111105
)
112106
}
113107

108+
interface UserProfile {
109+
id: string
110+
email: string
111+
first_name?: string
112+
last_name?: string
113+
avatar_url?: string
114+
}
115+
114116
// Component that uses CompanyContext to conditionally render sidebar
115117
function CompanyDashboardContent({
116-
avatar,
117-
name,
118-
email,
118+
user,
119119
children,
120120
}: {
121-
avatar: string
122-
name: string
123-
email: string
121+
user: { id: string; email?: string; user_metadata?: { first_name?: string } } | null
124122
children: React.ReactNode
125123
}) {
126124
const params = useParams()
127125
const companySlug = params?.slug as string
128126
const { currentCompany, userCompanies, loading, error } = useCompanyContext()
127+
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
128+
129+
// Fetch user profile data
130+
useEffect(() => {
131+
async function fetchProfile() {
132+
if (!user?.id) return
133+
134+
try {
135+
const supabase = createClient()
136+
const { data, error } = await supabase
137+
.from('profiles')
138+
.select('id, email, first_name, last_name, avatar_url')
139+
.eq('id', user.id)
140+
.single()
141+
142+
if (!error && data) {
143+
setUserProfile(data)
144+
}
145+
} catch (error) {
146+
console.error('Error fetching user profile:', error)
147+
}
148+
}
149+
150+
fetchProfile()
151+
}, [user?.id])
152+
153+
const avatarUrl = userProfile?.avatar_url
154+
const avatarInitial =
155+
userProfile?.first_name?.[0]?.toUpperCase() ||
156+
user?.user_metadata?.first_name?.[0]?.toUpperCase() ||
157+
user?.email?.[0]?.toUpperCase() ||
158+
'C'
159+
const name = userProfile?.first_name || user?.user_metadata?.first_name || user?.email || 'User'
160+
const email = user?.email || 'user@codeunia.com'
129161

130162
// Show loading while company context is loading
131163
if (loading) {
@@ -213,7 +245,8 @@ function CompanyDashboardContent({
213245

214246
return (
215247
<CompanySidebar
216-
avatar={avatar}
248+
avatarUrl={avatarUrl}
249+
avatarInitial={avatarInitial}
217250
name={name}
218251
email={email}
219252
sidebarItems={sidebarItems}

components/dashboard/CompanySidebar.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from '@/components/ui/sidebar'
3636
import { Button } from '@/components/ui/button'
3737
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet'
38+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
3839
import { useCompanyContext } from '@/contexts/CompanyContext'
3940

4041
export type SidebarGroupType = {
@@ -47,7 +48,8 @@ export type SidebarGroupType = {
4748
}
4849

4950
interface CompanySidebarProps {
50-
avatar: React.ReactNode
51+
avatarUrl?: string
52+
avatarInitial: string
5153
name: string
5254
email: string
5355
sidebarItems: SidebarGroupType[]
@@ -56,13 +58,23 @@ interface CompanySidebarProps {
5658
}
5759

5860
export function CompanySidebar({
59-
avatar,
61+
avatarUrl,
62+
avatarInitial,
6063
name,
6164
email,
6265
sidebarItems,
6366
children,
6467
header,
6568
}: CompanySidebarProps) {
69+
// Avatar component to reuse across mobile and desktop
70+
const AvatarContent = ({ size = 'default' }: { size?: 'default' | 'small' }) => (
71+
<Avatar className={size === 'small' ? 'h-8 w-8' : 'h-10 w-10'}>
72+
<AvatarImage src={avatarUrl} alt={name} />
73+
<AvatarFallback className="bg-gradient-to-br from-primary to-purple-600 text-white font-semibold">
74+
{avatarInitial}
75+
</AvatarFallback>
76+
</Avatar>
77+
)
6678
const [mobileOpen, setMobileOpen] = useState(false)
6779
const [collapsed, setCollapsed] = useState(false)
6880
const closeSidebar = () => setMobileOpen(false)
@@ -155,9 +167,7 @@ export function CompanySidebar({
155167
variant="ghost"
156168
className="w-full flex items-center gap-3 rounded-xl p-2 hover:bg-purple-700/20 transition-colors"
157169
>
158-
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-purple-600 text-white font-semibold shadow-md">
159-
{avatar}
160-
</div>
170+
<AvatarContent />
161171
<div className="grid flex-1 text-left text-sm leading-tight">
162172
<span className="truncate font-semibold text-white">
163173
{name}
@@ -177,9 +187,7 @@ export function CompanySidebar({
177187
>
178188
<DropdownMenuLabel className="p-0 font-normal">
179189
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
180-
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-purple-600 text-white font-semibold">
181-
{avatar}
182-
</div>
190+
<AvatarContent size="small" />
183191
<div className="grid flex-1 text-left text-sm leading-tight">
184192
<span className="truncate font-semibold">{name}</span>
185193
<span className="truncate text-xs text-zinc-400">
@@ -397,9 +405,7 @@ export function CompanySidebar({
397405
className="w-full flex items-center gap-3 rounded-xl p-2 hover:bg-purple-700/20 transition-colors data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
398406
title={collapsed ? name : undefined}
399407
>
400-
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-purple-600 text-white font-semibold shadow-md flex-shrink-0">
401-
{avatar}
402-
</div>
408+
<AvatarContent />
403409
{!collapsed && (
404410
<>
405411
<div className="grid flex-1 text-left text-sm leading-tight min-w-0">
@@ -423,9 +429,7 @@ export function CompanySidebar({
423429
>
424430
<DropdownMenuLabel className="p-0 font-normal">
425431
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
426-
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-purple-600 text-white font-semibold">
427-
{avatar}
428-
</div>
432+
<AvatarContent size="small" />
429433
<div className="grid flex-1 text-left text-sm leading-tight">
430434
<span className="truncate font-semibold">{name}</span>
431435
<span className="truncate text-xs text-zinc-400">

components/dashboard/TeamManagement.tsx

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
} from '@/components/ui/select'
4242
import { Input } from '@/components/ui/input'
4343
import { Label } from '@/components/ui/label'
44-
import { useToast } from '@/components/ui/use-toast'
44+
import { toast } from 'sonner'
4545
import { Skeleton } from '@/components/ui/skeleton'
4646
import {
4747
UserPlus,
@@ -86,7 +86,6 @@ export function TeamManagement({
8686
const [inviteRole, setInviteRole] = useState<'admin' | 'editor' | 'viewer'>('viewer')
8787
const [newRole, setNewRole] = useState<'owner' | 'admin' | 'editor' | 'viewer'>('viewer')
8888
const [submitting, setSubmitting] = useState(false)
89-
const { toast } = useToast()
9089

9190
// Check if current user can manage team
9291
const canManageTeam = ['owner', 'admin'].includes(currentUserRole)
@@ -110,15 +109,11 @@ export function TeamManagement({
110109
setMembers(data.members || [])
111110
} catch (error) {
112111
console.error('Error fetching members:', error)
113-
toast({
114-
title: 'Error',
115-
description: 'Failed to load team members',
116-
variant: 'destructive',
117-
})
112+
toast.error('Failed to load team members')
118113
} finally {
119114
setLoading(false)
120115
}
121-
}, [companySlug, toast])
116+
}, [companySlug])
122117

123118
useEffect(() => {
124119
fetchMembers()
@@ -127,11 +122,7 @@ export function TeamManagement({
127122
// Handle invite member
128123
const handleInvite = async () => {
129124
if (!inviteEmail) {
130-
toast({
131-
title: 'Error',
132-
description: 'Please enter an email address',
133-
variant: 'destructive',
134-
})
125+
toast.error('Please enter an email address')
135126
return
136127
}
137128

@@ -150,25 +141,28 @@ export function TeamManagement({
150141

151142
if (!response.ok) {
152143
console.error('Invite error response:', data)
153-
throw new Error(data.error || 'Failed to invite member')
144+
145+
// Check if it's a team limit error
146+
if (data.upgrade_required) {
147+
toast.error('Team Member Limit Reached', {
148+
description: `${data.error} (${data.current_usage}/${data.limit}). Please upgrade your plan to add more members.`,
149+
duration: 6000,
150+
})
151+
} else {
152+
toast.error(data.error || 'Failed to invite member')
153+
}
154+
return
154155
}
155156

156-
toast({
157-
title: 'Success',
158-
description: 'Team member invited successfully',
159-
})
157+
toast.success('Team member invited successfully')
160158

161159
setInviteDialogOpen(false)
162160
setInviteEmail('')
163161
setInviteRole('viewer')
164162
fetchMembers()
165163
} catch (error) {
166164
console.error('Error inviting member:', error)
167-
toast({
168-
title: 'Error',
169-
description: error instanceof Error ? error.message : 'Failed to invite member',
170-
variant: 'destructive',
171-
})
165+
toast.error(error instanceof Error ? error.message : 'Failed to invite member')
172166
} finally {
173167
setSubmitting(false)
174168
}
@@ -195,21 +189,14 @@ export function TeamManagement({
195189
throw new Error(data.error || 'Failed to update role')
196190
}
197191

198-
toast({
199-
title: 'Success',
200-
description: 'Member role updated successfully',
201-
})
192+
toast.success('Member role updated successfully')
202193

203194
setRoleDialogOpen(false)
204195
setSelectedMember(null)
205196
fetchMembers()
206197
} catch (error) {
207198
console.error('Error updating role:', error)
208-
toast({
209-
title: 'Error',
210-
description: error instanceof Error ? error.message : 'Failed to update role',
211-
variant: 'destructive',
212-
})
199+
toast.error(error instanceof Error ? error.message : 'Failed to update role')
213200
} finally {
214201
setSubmitting(false)
215202
}
@@ -234,21 +221,14 @@ export function TeamManagement({
234221
throw new Error(data.error || 'Failed to remove member')
235222
}
236223

237-
toast({
238-
title: 'Success',
239-
description: 'Member removed successfully',
240-
})
224+
toast.success('Member removed successfully')
241225

242226
setRemoveDialogOpen(false)
243227
setSelectedMember(null)
244228
fetchMembers()
245229
} catch (error) {
246230
console.error('Error removing member:', error)
247-
toast({
248-
title: 'Error',
249-
description: error instanceof Error ? error.message : 'Failed to remove member',
250-
variant: 'destructive',
251-
})
231+
toast.error(error instanceof Error ? error.message : 'Failed to remove member')
252232
} finally {
253233
setSubmitting(false)
254234
}
@@ -473,7 +453,7 @@ export function TeamManagement({
473453
<DialogHeader>
474454
<DialogTitle>Invite Team Member</DialogTitle>
475455
<DialogDescription>
476-
Send an invitation to join your team. They must have a CodeUnia account.
456+
Send an invitation to join your team. They must have a Codeunia account.
477457
</DialogDescription>
478458
</DialogHeader>
479459
<div className="space-y-4 py-4">

0 commit comments

Comments
 (0)