| 1 | import { startOfMonth } from 'date-fns' |
| 2 | import { |
| 3 | Briefcase, |
| 4 | ClipboardList, |
| 5 | Clock, |
| 6 | FileSpreadsheet, |
| 7 | FileText, |
| 8 | Milestone as MilestoneIcon, |
| 9 | Users, |
| 10 | } from 'lucide-react' |
| 11 | import type { Metadata |
| 12 | import Link from 'next/link |
| 13 | import { notFound, redirect |
| 14 | |
| 15 | import { expensesServices } |
| 16 | import { invoicesService } |
| 17 | import { milestonesService } |
| 18 | import { projectsService } |
| 19 | import { teamService } |
| 20 | import { timesheetService } |
| 21 | import { FinancialOverviewCard } |
| 22 | import { |
| 23 | ActionItemLink, |
| 24 | NeedsAttentionCard, |
| 25 | } from '@/components/dashboard/needs-attention' |
| 26 | import { OverdueInvoicesAccordion } |
| 27 | import { StatCard } |
| 28 | import { Badge } |
| 29 | import { Card, CardContent |
| 30 | import { createMetadata } |
| 31 | import type { RouteImpl |
| 32 | import { resolveOrgContext } |
| 33 | |
| 34 | export const metadata: |
| 35 | title: 'Overview', |
| 36 | description: 'Your workspace at a glance — projects, invoices, and activity.', |
| 37 | openGraph: { |
| 38 | images: ['/api/og?page=Overview'], |
| 39 | }, |
| 40 | twitter: { |
| 41 | images: ['/api/og?page=Overview'], |
| 42 | }, |
| 43 | }) |
| 44 | |
| 45 | export default async function |
| 46 | const { org |
| 47 | const { session, |
| 48 | await resolveOrgContext(org |
| 49 | |
| 50 | if (!session) |
| 51 | redirect(`/auth/sign-in?callback=${encodeURIComponent |
| 52 | } |
| 53 | if (!organization) |
| 54 | return notFound() |
| 55 | } |
| 56 | |
| 57 | const isClient = |
| 58 | const isAdmin = |
| 59 | |
| 60 | const canReadInvoices = |
| 61 | const canReadExpenses = |
| 62 | const canReadMilestones = |
| 63 | const canReadTimesheets = |
| 64 | |
| 65 | const projectList = |
| 66 | organization.id, |
| 67 | orgMember |
| 68 | ) |
| 69 | const projectIds = |
| 70 | const hasProjects = |
| 71 | |
| 72 | const monthStart = |
| 73 | |
| 74 | const [invoiceList, |
| 75 | await Promise.all |
| 76 | hasProjects && canReadInvoices |
| 77 | ? invoicesService.listByProjectIds( |
| 78 | : Promise.resolve([]) |
| 79 | hasProjects && canReadExpenses |
| 80 | ? expensesServices.listByProjectIds( |
| 81 | : Promise.resolve([]) |
| 82 | hasProjects && canReadTimesheets |
| 83 | ? timesheetService.listByProjectIdsSince( |
| 84 | projectIds, |
| 85 | monthStart, |
| 86 | orgMember.role === |
| 87 | ) |
| 88 | : Promise.resolve([]) |
| 89 | hasProjects && canReadMilestones |
| 90 | ? milestonesService.listByProjectIds( |
| 91 | : Promise.resolve([]) |
| 92 | isAdmin |
| 93 | ? teamService.getOrgMemberCounts( |
| 94 | : Promise.resolve( |
| 95 | ]) |
| 96 | |
| 97 | const projectBySlug = |
| 98 | |
| 99 | const invoicesOverdue = |
| 100 | (i) => |
| 101 | ) |
| 102 | const invoicesDraft = |
| 103 | const invoicesDisputed = |
| 104 | const invoicesPaid = |
| 105 | const invoicesSent = |
| 106 | |
| 107 | const overdueInvoiceEntries = |
| 108 | .map((inv) |
| 109 | const project = |
| 110 | if (!project) |
| 111 | return null |
| 112 | } |
| 113 | return { |
| 114 | id: inv.id |
| 115 | invoiceNumber: inv.invoiceNumber |
| 116 | totalAmount: inv.totalAmount |
| 117 | currency: inv.currency |
| 118 | dueDate: new Date |
| 119 | projectSlug: project.slug |
| 120 | projectName: project.name |
| 121 | } |
| 122 | }) |
| 123 | .filter((e) |
| 124 | |
| 125 | const minutesThisMonth = |
| 126 | (sum, e) |
| 127 | 0 |
| 128 | ) |
| 129 | const hoursThisMonth = |
| 130 | const minutesRemainder = |
| 131 | |
| 132 | const milestonesOverdue = |
| 133 | (m) => |
| 134 | m.dueDate && |
| 135 | ) |
| 136 | const milestonesBlocked = |
| 137 | const milestonesCompleted = |
| 138 | (m) => |
| 139 | ).length |
| 140 | const milestoneProgress = |
| 141 | ? Math.round(( |
| 142 | : 0 |
| 143 | |
| 144 | const { team: |
| 145 | |
| 146 | const projectsWithDueDate = |
| 147 | |
| 148 | const hasActionItems = |
| 149 | overdueInvoiceEntries.length > 0 |
| 150 | invoicesDisputed.length > 0 |
| 151 | (canReadMilestones && |
| 152 | (milestonesOverdue.length > |
| 153 | (isAdmin && invoicesDraft |
| 154 | |
| 155 | const userName = |
| 156 | const greeting = |
| 157 | |
| 158 | return ( |
| 159 | <div className='w-full space-y-6 |
| 160 | <div className='flex flex-wrap items-start justify-between gap-4 |
| 161 | <div> |
| 162 | <h1 className='font-semibold text-2xl |
| 163 | {greeting} |
| 164 | {userName && `, |
| 165 | </h1> |
| 166 | <p className='mt-1 text-muted-foreground text-sm |
| 167 | Here's what's happening in {organization.name} |
| 168 | </p> |
| 169 | </div> |
| 170 | <div className='flex flex-wrap items-center gap-2 |
| 171 | <Badge className=' |
| 172 | <Briefcase className=' |
| 173 | {projectList.length}{' |
| 174 | {projectList.length === |
| 175 | </Badge> |
| 176 | {isAdmin && ( |
| 177 | <Badge className=' |
| 178 | <Users className=' |
| 179 | {teamCount} {teamCount |
| 180 | {clientCount > 0 |
| 181 | ` · ${clientCount} ${ |
| 182 | </Badge> |
| 183 | )} |
| 184 | </div> |
| 185 | </div> |
| 186 | |
| 187 | {!hasProjects && ( |
| 188 | <Card> |
| 189 | <CardContent className=' |
| 190 | <Briefcase className=' |
| 191 | <p className='text-muted-foreground text-sm |
| 192 | No projects yet.{' '} |
| 193 | {isAdmin |
| 194 | ? 'Create one to start tracking work and billing clients.' |
| 195 | : 'Ask an admin to add you to a project.'} |
| 196 | </p> |
| 197 | {isAdmin && ( |
| 198 | <Link |
| 199 | className='text-primary text-sm underline-offset-4 hover:underline' |
| 200 | href={`/${org |
| 201 | > |
| 202 | Create a project |
| 203 | </Link> |
| 204 | )} |
| 205 | </CardContent> |
| 206 | </Card> |
| 207 | )} |
| 208 | |
| 209 | {hasProjects && canReadInvoices |
| 210 | <FinancialOverviewCard |
| 211 | expenses={canReadExpenses ? |
| 212 | invoices={invoiceList} |
| 213 | isAdmin={isAdmin} |
| 214 | isClient={isClient} |
| 215 | scopeLabel={`Across ${projectList |
| 216 | /> |
| 217 | )} |
| 218 | |
| 219 | {hasProjects && ( |
| 220 | <div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4 |
| 221 | <StatCard |
| 222 | href={`/${org |
| 223 | icon={Briefcase} |
| 224 | label='Projects' |
| 225 | sublabel={ |
| 226 | projectList.length > 0 |
| 227 | ? `${projectsWithDueDate} with due date |
| 228 | : undefined |
| 229 | } |
| 230 | value={projectList.length |
| 231 | /> |
| 232 | {canReadTimesheets && ( |
| 233 | <StatCard |
| 234 | icon={Clock} |
| 235 | label='Logged this month' |
| 236 | sublabel={`${timeEntryList |
| 237 | value={`${hoursThisMonth |
| 238 | /> |
| 239 | )} |
| 240 | {canReadMilestones && milestoneList |
| 241 | <StatCard |
| 242 | icon={MilestoneIcon} |
| 243 | label='Milestones' |
| 244 | sublabel={`${milestonesCompleted |
| 245 | value={`${milestoneProgress |
| 246 | /> |
| 247 | )} |
| 248 | {canReadInvoices && ( |
| 249 | <StatCard |
| 250 | icon={FileSpreadsheet} |
| 251 | label='Invoices' |
| 252 | sublabel={ |
| 253 | invoiceList.length > 0 |
| 254 | ? `${invoicesPaid.length |
| 255 | : undefined |
| 256 | } |
| 257 | value={invoiceList.length |
| 258 | /> |
| 259 | )} |
| 260 | </div> |
| 261 | )} |
| 262 | |
| 263 | {hasActionItems && ( |
| 264 | <NeedsAttentionCard> |
| 265 | {overdueInvoiceEntries.length > |
| 266 | <OverdueInvoicesAccordion |
| 267 | invoices={overdueInvoiceEntries} |
| 268 | orgSlug={org} |
| 269 | showProjectName |
| 270 | /> |
| 271 | )} |
| 272 | {invoicesDisputed.length > |
| 273 | <ActionItemLink |
| 274 | count={invoicesDisputed.length |
| 275 | href={`/${org |
| 276 | icon={FileSpreadsheet} |
| 277 | label='Disputed invoices' |
| 278 | variant='destructive' |
| 279 | /> |
| 280 | )} |
| 281 | {canReadMilestones && milestonesOverdue |
| 282 | <ActionItemLink |
| 283 | count={milestonesOverdue.length |
| 284 | href={`/${org |
| 285 | icon={MilestoneIcon} |
| 286 | label='Overdue milestones' |
| 287 | variant='destructive' |
| 288 | /> |
| 289 | )} |
| 290 | {canReadMilestones && milestonesBlocked |
| 291 | <ActionItemLink |
| 292 | count={milestonesBlocked.length |
| 293 | href={`/${org |
| 294 | icon={ClipboardList} |
| 295 | label='Blocked milestones' |
| 296 | /> |
| 297 | )} |
| 298 | {isAdmin && invoicesDraft |
| 299 | <ActionItemLink |
| 300 | count={invoicesDraft.length |
| 301 | href={`/${org |
| 302 | icon={FileText} |
| 303 | label='Draft invoices' |
| 304 | /> |
| 305 | )} |
| 306 | </NeedsAttentionCard> |
| 307 | )} |
| 308 | </div> |
| 309 | ) |
| 310 | } |
| 311 | |
| 312 | function getGreeting() { |
| 313 | const hour = |
| 314 | if (hour < |
| 315 | return 'Good morning' |
| 316 | } |
| 317 | if (hour < |
| 318 | return 'Good afternoon' |
| 319 | } |
| 320 | return 'Good evening' |
| 321 | } |