| 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 { |
| 32 | classifyInvoices, |
| 33 | classifyMilestones, |
| 34 | getGreeting, |
| 35 | summarizeTime, |
| 36 | toOverdueInvoiceEntries, |
| 37 | } from '@/lib/overview-stats' |
| 38 | import type { RouteImpl |
| 39 | import { resolveOrgContext } |
| 40 | |
| 41 | export const metadata: |
| 42 | title: 'Overview', |
| 43 | description: 'Your workspace at a glance — projects, invoices, and activity.', |
| 44 | openGraph: { images: [ |
| 45 | twitter: { images: [ |
| 46 | }) |
| 47 | |
| 48 | export default async function |
| 49 | const { org |
| 50 | const { session, |
| 51 | await resolveOrgContext(org |
| 52 | |
| 53 | if (!session) |
| 54 | redirect(`/auth/sign-in?callback=${encodeURIComponent |
| 55 | } |
| 56 | if (!organization) |
| 57 | return notFound() |
| 58 | } |
| 59 | |
| 60 | const isClient = |
| 61 | const isAdmin = |
| 62 | |
| 63 | const canReadInvoices = |
| 64 | const canReadExpenses = |
| 65 | const canReadMilestones = |
| 66 | const canReadTimesheets = |
| 67 | |
| 68 | const projectList = |
| 69 | organization.id, |
| 70 | orgMember |
| 71 | ) |
| 72 | const projectIds = |
| 73 | const hasProjects = |
| 74 | |
| 75 | const monthStart = |
| 76 | |
| 77 | const [invoiceList, |
| 78 | await Promise.all |
| 79 | hasProjects && canReadInvoices |
| 80 | ? invoicesService.listByProjectIds( |
| 81 | : Promise.resolve([]) |
| 82 | hasProjects && canReadExpenses |
| 83 | ? expensesServices.listByProjectIds( |
| 84 | : Promise.resolve([]) |
| 85 | hasProjects && canReadTimesheets |
| 86 | ? timesheetService.listByProjectIdsSince( |
| 87 | projectIds, |
| 88 | monthStart, |
| 89 | orgMember.role === |
| 90 | ) |
| 91 | : Promise.resolve([]) |
| 92 | hasProjects && canReadMilestones |
| 93 | ? milestonesService.listByProjectIds( |
| 94 | : Promise.resolve([]) |
| 95 | isAdmin |
| 96 | ? teamService.getOrgMemberCounts( |
| 97 | : Promise.resolve( |
| 98 | ]) |
| 99 | |
| 100 | const projectsById = |
| 101 | const invoiceBuckets = |
| 102 | const milestoneBuckets = |
| 103 | const time = |
| 104 | const overdueInvoiceEntries = |
| 105 | invoiceBuckets.overdue, |
| 106 | projectsById |
| 107 | ) |
| 108 | |
| 109 | const { team: |
| 110 | const projectsWithDueDate = |
| 111 | |
| 112 | const hasActionItems = |
| 113 | overdueInvoiceEntries.length > 0 |
| 114 | invoiceBuckets.disputed.length |
| 115 | (canReadMilestones && |
| 116 | (milestoneBuckets.overdue.length |
| 117 | milestoneBuckets.blocked.length |
| 118 | (isAdmin && invoiceBuckets |
| 119 | |
| 120 | const userName = |
| 121 | const greeting = |
| 122 | |
| 123 | return ( |
| 124 | <div className='w-full space-y-8 |
| 125 | <header className='flex flex-wrap items-start justify-between gap-4 |
| 126 | <div> |
| 127 | <h1 className='font-semibold text-2xl tracking-tight |
| 128 | {greeting} |
| 129 | {userName && `, |
| 130 | </h1> |
| 131 | <p className='mt-1 text-muted-foreground text-sm |
| 132 | Here's what's happening in {organization.name}. |
| 133 | </p> |
| 134 | </div> |
| 135 | <div className='flex flex-wrap items-center gap-2 |
| 136 | <Badge className=' |
| 137 | <Briefcase className=' |
| 138 | {projectList.length}{' |
| 139 | {projectList.length === |
| 140 | </Badge> |
| 141 | {isAdmin && ( |
| 142 | <Badge className=' |
| 143 | <Users className=' |
| 144 | {teamCount} {teamCount |
| 145 | {clientCount > 0 |
| 146 | ` · ${clientCount} ${ |
| 147 | </Badge> |
| 148 | )} |
| 149 | </div> |
| 150 | </header> |
| 151 | |
| 152 | {!hasProjects && ( |
| 153 | <Card> |
| 154 | <CardContent className=' |
| 155 | <div className='flex size-12 items-center justify-center rounded-full bg-muted |
| 156 | <Briefcase className=' |
| 157 | </div> |
| 158 | <p className='text-muted-foreground text-sm |
| 159 | No projects yet.{' '} |
| 160 | {isAdmin |
| 161 | ? 'Create one to start tracking work and billing clients.' |
| 162 | : 'Ask an admin to add you to a project.'} |
| 163 | </p> |
| 164 | {isAdmin && ( |
| 165 | <Link |
| 166 | className='text-primary text-sm underline-offset-4 hover:underline' |
| 167 | href={`/${org |
| 168 | > |
| 169 | Create a project → |
| 170 | </Link> |
| 171 | )} |
| 172 | </CardContent> |
| 173 | </Card> |
| 174 | )} |
| 175 | |
| 176 | {hasActionItems && ( |
| 177 | <section className='space-y-3 |
| 178 | <SectionLabel>Needs your attention</SectionLabel |
| 179 | <NeedsAttentionCard> |
| 180 | {overdueInvoiceEntries.length > |
| 181 | <OverdueInvoicesAccordion |
| 182 | invoices={overdueInvoiceEntries} |
| 183 | orgSlug={org} |
| 184 | showProjectName |
| 185 | /> |
| 186 | )} |
| 187 | {invoiceBuckets.disputed.length |
| 188 | <ActionItemLink |
| 189 | count={invoiceBuckets.disputed |
| 190 | href={`/${org |
| 191 | icon={FileSpreadsheet} |
| 192 | label='Disputed invoices' |
| 193 | variant='destructive' |
| 194 | /> |
| 195 | )} |
| 196 | {canReadMilestones && milestoneBuckets |
| 197 | <ActionItemLink |
| 198 | count={milestoneBuckets.overdue |
| 199 | href={`/${org |
| 200 | icon={MilestoneIcon} |
| 201 | label='Overdue milestones' |
| 202 | variant='destructive' |
| 203 | /> |
| 204 | )} |
| 205 | {canReadMilestones && milestoneBuckets |
| 206 | <ActionItemLink |
| 207 | count={milestoneBuckets.blocked |
| 208 | href={`/${org |
| 209 | icon={ClipboardList} |
| 210 | label='Blocked milestones' |
| 211 | /> |
| 212 | )} |
| 213 | {isAdmin && invoiceBuckets |
| 214 | <ActionItemLink |
| 215 | count={invoiceBuckets.draft |
| 216 | href={`/${org |
| 217 | icon={FileText} |
| 218 | label='Draft invoices' |
| 219 | /> |
| 220 | )} |
| 221 | </NeedsAttentionCard> |
| 222 | </section> |
| 223 | )} |
| 224 | |
| 225 | {hasProjects && canReadInvoices |
| 226 | <FinancialOverviewCard |
| 227 | expenses={canReadExpenses ? |
| 228 | invoices={invoiceList} |
| 229 | isAdmin={isAdmin} |
| 230 | isClient={isClient} |
| 231 | scopeLabel={`Across ${projectList |
| 232 | /> |
| 233 | )} |
| 234 | |
| 235 | {hasProjects && ( |
| 236 | <section className='space-y-3 |
| 237 | <SectionLabel>At a glance</SectionLabel |
| 238 | <div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4 |
| 239 | <StatCard |
| 240 | accent='violet' |
| 241 | href={`/${org |
| 242 | icon={Briefcase} |
| 243 | label='Projects' |
| 244 | sublabel={ |
| 245 | projectList.length > 0 |
| 246 | ? `${projectsWithDueDate} with due date |
| 247 | : undefined |
| 248 | } |
| 249 | value={projectList.length |
| 250 | /> |
| 251 | {canReadTimesheets && ( |
| 252 | <StatCard |
| 253 | accent='sky' |
| 254 | icon={Clock} |
| 255 | label='Logged this month' |
| 256 | sublabel={`${time |
| 257 | value={`${time |
| 258 | /> |
| 259 | )} |
| 260 | {canReadMilestones && milestoneList |
| 261 | <StatCard |
| 262 | accent='amber' |
| 263 | icon={MilestoneIcon} |
| 264 | label='Milestones' |
| 265 | sublabel={`${milestoneBuckets |
| 266 | value={`${milestoneBuckets |
| 267 | /> |
| 268 | )} |
| 269 | {canReadInvoices && ( |
| 270 | <StatCard |
| 271 | accent='teal' |
| 272 | icon={FileSpreadsheet} |
| 273 | label='Invoices' |
| 274 | sublabel={ |
| 275 | invoiceList.length > 0 |
| 276 | ? `${invoiceBuckets.paid |
| 277 | : undefined |
| 278 | } |
| 279 | value={invoiceList.length |
| 280 | /> |
| 281 | )} |
| 282 | </div> |
| 283 | </section> |
| 284 | )} |
| 285 | </div> |
| 286 | ) |
| 287 | } |
| 288 | |
| 289 | function SectionLabel({ children |
| 290 | return ( |
| 291 | <h2 className='font-medium text-muted-foreground text-sm uppercase tracking-wider |
| 292 | {children} |
| 293 | </h2> |
| 294 | ) |
| 295 | } |