@burdenoff/microfe-crewfoundry 2026.707.1 → 2026.710.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crewfoundry/components/viz/BarChart.js.map +1 -1
- package/dist/crewfoundry/components/viz/CertificateCard.js.map +1 -1
- package/dist/crewfoundry/components/viz/DonutChart.js.map +1 -1
- package/dist/crewfoundry/components/viz/Heatmap.js.map +1 -1
- package/dist/crewfoundry/components/viz/LineChart.js.map +1 -1
- package/dist/crewfoundry/components/viz/PathSteps.js.map +1 -1
- package/dist/crewfoundry/components/viz/RatingStars.js.map +1 -1
- package/dist/crewfoundry/components/viz/SegmentedTabs.js.map +1 -1
- package/dist/crewfoundry/components/viz/StatCard.js.map +1 -1
- package/dist/crewfoundry/components/viz/lib/Avatar.js.map +1 -1
- package/dist/crewfoundry/components/viz/lib/CartesianFrame.js.map +1 -1
- package/dist/crewfoundry/components/viz/lib/geometry.js.map +1 -1
- package/dist/crewfoundry/index.js +1 -0
- package/dist/crewfoundry/lib/analytics.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsEmployeeAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsFinancialAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsHiringAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/CommunicationsMessagesPagePage.js +2 -1
- package/dist/crewfoundry/pages/CommunicationsMessagesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/CompanyClientManagementPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/DashboardDashboardPagePage.js +4 -4
- package/dist/crewfoundry/pages/DashboardDashboardPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringApplicationsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringCandidatesPagePage.js +3 -3
- package/dist/crewfoundry/pages/HiringCandidatesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringCreateJobPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringJobDetailsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringJobsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrDepartmentsPagePage.js +8 -4
- package/dist/crewfoundry/pages/HrDepartmentsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrLeaveManagementPagePage.js +4 -4
- package/dist/crewfoundry/pages/HrLeaveManagementPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrPayrollPagePage.js +3 -3
- package/dist/crewfoundry/pages/HrPayrollPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrTimeTrackingPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningCertificatesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningCourseLearningPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningCoursesPagePage.js +3 -3
- package/dist/crewfoundry/pages/LearningCoursesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningLearningPathsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningMyLearningPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/MarketplaceMarketplacePagePage.js.map +1 -1
- package/dist/crewfoundry/pages/MarketplaceServiceDetailsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/PaymentsInvoicesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/ProjectsProjectsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/TeamsTeamDetailPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/TeamsTeamsPagePage.js.map +1 -1
- package/dist/crewfoundry/utils/courseProgress.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/services/assistantApi.js +273 -0
- package/dist/services/assistantApi.js.map +1 -0
- package/dist/services/createSandboxAssistantTransport.js +120 -0
- package/dist/services/createSandboxAssistantTransport.js.map +1 -0
- package/dist/utils/pageContext.js +52 -0
- package/dist/utils/pageContext.js.map +1 -0
- package/package.json +6 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LearningMyLearningPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/LearningMyLearningPagePage.tsx"],"sourcesContent":["import { useMemo, useState, type ComponentType } from 'react';\nimport { Award, BookOpen, GraduationCap, Layers, Play } from 'lucide-react';\nimport {\n Badge,\n Button,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n} from '@burdenoff/fe-libs/ui';\nimport { useNavigate } from 'react-router-dom';\nimport { useAuth } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { useCrewFoundryStore, type LearningCourse } from '../store/crewfoundryStore';\nimport { CertificateCard, chartPalette, RatingStars, SegmentedTabs, StatCard } from '../components/viz';\nimport { readAllCourseProgress } from '../utils/courseProgress';\n\nconst ENROLLED_KEY = 'crewfoundry:course-enrolled:v1';\n\nfunction readEnrolledIds(): Set<string> {\n if (typeof window === 'undefined') return new Set();\n try {\n const raw = window.localStorage.getItem(ENROLLED_KEY);\n const ids: unknown = raw ? JSON.parse(raw) : [];\n return new Set(Array.isArray(ids) ? ids.filter((x): x is string => typeof x === 'string') : []);\n } catch {\n return new Set();\n }\n}\n\nfunction categoryAccent(category: string): string {\n const palette = chartPalette(8);\n let hash = 0;\n for (let i = 0; i < category.length; i += 1) {\n hash = (hash * 31 + category.charCodeAt(i)) >>> 0;\n }\n return palette[hash % palette.length];\n}\n\nfunction formatDate(iso?: string): string {\n if (!iso) return '—';\n const date = new Date(iso);\n if (Number.isNaN(date.getTime())) return '—';\n return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });\n}\n\n/** Generate a self-contained, printable certificate document (no app tokens, no raw colors). */\nfunction downloadCertificate(course: string, recipient: string, dateLabel: string, id: string): void {\n if (typeof document === 'undefined') return;\n const esc = (s: string) =>\n s.replace(/[&<>\"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '\"': '"' })[c] ?? c);\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><title>Certificate — ${esc(course)}</title>\n<style>\n body{font-family:Georgia,'Times New Roman',serif;margin:0;padding:48px;text-align:center}\n .frame{border:6px double currentColor;padding:56px 40px;max-width:760px;margin:0 auto}\n .kicker{letter-spacing:.3em;text-transform:uppercase;font-size:12px;opacity:.7}\n h1{font-size:34px;margin:24px 0 8px}\n .recipient{font-size:26px;margin:20px 0}\n .rule{width:200px;border:0;border-top:1px solid currentColor;margin:24px auto;opacity:.4}\n .meta{font-size:13px;opacity:.7;margin-top:32px}\n</style></head><body>\n <div class=\"frame\">\n <div class=\"kicker\">CrewFoundry · Certificate of Completion</div>\n <h1>${esc(course)}</h1>\n <div class=\"kicker\">Awarded to</div>\n <div class=\"recipient\">${esc(recipient)}</div>\n <hr class=\"rule\" />\n <div class=\"meta\">Issued ${esc(dateLabel)} · Credential ID: ${esc(id)}</div>\n </div>\n</body></html>`;\n const blob = new Blob([html], { type: 'text/html' });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement('a');\n anchor.href = url;\n anchor.download = `crewfoundry-certificate-${id}.html`;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n URL.revokeObjectURL(url);\n}\n\ntype Tab = 'in-progress' | 'completed' | 'certificates';\n\ninterface LearnerCourse {\n course: LearningCourse;\n completedModules: number;\n totalModules: number;\n pct: number;\n completedAt?: string;\n}\n\nconst CATEGORY_ICON: ComponentType<{ className?: string }> = BookOpen;\n\nexport function LearningMyLearningPagePage() {\n const { courses } = useCrewFoundryStore();\n const auth = useAuth();\n const navigate = useNavigate();\n const [tab, setTab] = useState<Tab>('in-progress');\n\n const recipient = auth.user?.name?.trim() || 'CrewFoundry Learner';\n\n const { inProgress, completed, modulesDone } = useMemo(() => {\n const progressMap = readAllCourseProgress();\n const enrolled = readEnrolledIds();\n const inProg: LearnerCourse[] = [];\n const done: LearnerCourse[] = [];\n let totalModulesDone = 0;\n\n for (const course of courses) {\n const entry = progressMap[course.id];\n const completedModules = entry?.completed.length ?? 0;\n totalModulesDone += completedModules;\n const totalModules = Math.max(course.modules, 1);\n const pct = Math.min(100, Math.round((completedModules / totalModules) * 100));\n const record: LearnerCourse = {\n course,\n completedModules,\n totalModules,\n pct,\n completedAt: entry?.completedAt,\n };\n if (completedModules >= totalModules && completedModules > 0) {\n done.push(record);\n } else if (completedModules > 0 || enrolled.has(course.id)) {\n inProg.push(record);\n }\n }\n inProg.sort((a, b) => b.pct - a.pct);\n done.sort((a, b) => (b.completedAt ?? '').localeCompare(a.completedAt ?? ''));\n return { inProgress: inProg, completed: done, modulesDone: totalModulesDone };\n }, [courses]);\n\n const openPlayer = (id: string) =>\n navigate(`../learning/course-learning-page?id=${encodeURIComponent(id)}`);\n\n return (\n <div className=\"space-y-6 p-6\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>My Learning</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">Track your learning progress and achievements</p>\n <PagePurpose className=\"mt-1\">\n Everything you're currently learning — track your enrolled courses, completed\n training and the certificates you've earned, all in one place.\n </PagePurpose>\n </div>\n\n <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-4\">\n <StatCard\n label=\"Courses in progress\"\n value={inProgress.length}\n icon={<BookOpen className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Completed\" value={completed.length} icon={<Award className=\"h-4 w-4\" />} />\n <StatCard\n label=\"Certificates\"\n value={completed.length}\n icon={<GraduationCap className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Modules done\" value={modulesDone} icon={<Layers className=\"h-4 w-4\" />} />\n </div>\n\n <div className=\"overflow-x-auto pb-1\">\n <SegmentedTabs\n aria-label=\"My learning\"\n value={tab}\n onChange={(id) => setTab(id as Tab)}\n className=\"w-max\"\n tabs={[\n { id: 'in-progress', label: 'In Progress', count: inProgress.length },\n { id: 'completed', label: 'Completed', count: completed.length },\n { id: 'certificates', label: 'Certificates', count: completed.length },\n ]}\n />\n </div>\n\n {tab === 'in-progress' &&\n (inProgress.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title=\"Nothing in progress yet\"\n description=\"Enroll in a course and it will show up here so you can pick up where you left off.\"\n action={\n <Button variant=\"outline\" onClick={() => navigate('../learning/courses-page')}>\n Browse courses\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {inProgress.map(({ course, pct, completedModules, totalModules }) => {\n const accent = categoryAccent(course.category);\n return (\n <GlassCard key={course.id} treatment=\"plain\" className=\"flex flex-col overflow-hidden\">\n <div\n className=\"flex h-20 items-center justify-center\"\n style={{\n background: `linear-gradient(135deg, color-mix(in srgb, ${accent} 26%, var(--color-bg-surface)), color-mix(in srgb, ${accent} 8%, var(--color-bg-surface)))`,\n }}\n >\n <span\n className=\"flex h-10 w-10 items-center justify-center rounded-xl bg-bg-surface/70 shadow-elevation-1\"\n style={{ color: accent }}\n >\n <CATEGORY_ICON className=\"h-5 w-5\" />\n </span>\n </div>\n <div className=\"flex flex-1 flex-col gap-3 p-4\">\n <div>\n <p className=\"text-sm font-semibold\">{course.title}</p>\n <p className=\"text-xs text-text-secondary\">{course.instructor}</p>\n </div>\n <div className=\"mt-auto space-y-1\">\n <div className=\"flex justify-between text-xs text-text-secondary\">\n <span className=\"tabular-nums\">{pct}% complete</span>\n <span className=\"tabular-nums\">\n {completedModules}/{totalModules} modules\n </span>\n </div>\n <div className=\"h-2 overflow-hidden rounded-full bg-bg-sunken\">\n <div\n className=\"h-full rounded-full bg-action-primary-bg\"\n style={{ width: `${pct}%` }}\n />\n </div>\n </div>\n <Button className=\"w-full\" onClick={() => openPlayer(course.id)}>\n <Play className=\"mr-1 h-4 w-4\" />\n Continue\n </Button>\n </div>\n </GlassCard>\n );\n })}\n </div>\n ))}\n\n {tab === 'completed' &&\n (completed.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title=\"No completed courses yet\"\n description=\"Finish a course from the player and it will be marked complete here.\"\n />\n ) : (\n <div className=\"space-y-3\">\n {completed.map(({ course, completedAt }) => (\n <button\n key={course.id}\n type=\"button\"\n onClick={() => openPlayer(course.id)}\n className=\"flex w-full items-center justify-between gap-3 rounded-lg border border-border-subtle p-4 text-left transition-colors hover:border-border-strong hover:bg-bg-sunken\"\n >\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-medium\">{course.title}</p>\n <p className=\"text-xs text-text-secondary\">\n Completed {formatDate(completedAt)} · {course.instructor}\n </p>\n </div>\n <div className=\"flex shrink-0 items-center gap-3\">\n <RatingStars value={course.rating} size={13} showValue />\n <Badge variant=\"secondary\" className=\"text-[10px]\">\n Completed\n </Badge>\n </div>\n </button>\n ))}\n </div>\n ))}\n\n {tab === 'certificates' &&\n (completed.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title=\"No certificates yet\"\n description=\"Certificates are issued when you complete a course. Finish one and it will appear here to download.\"\n action={\n <Button variant=\"outline\" onClick={() => navigate('../learning/courses-page')}>\n Browse courses\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {completed.map(({ course, completedAt }) => {\n const credentialId = `CF-${course.id.slice(-6).toUpperCase()}`;\n const dateLabel = formatDate(completedAt);\n return (\n <CertificateCard\n key={course.id}\n title={course.title}\n recipient={recipient}\n date={dateLabel}\n issuer=\"CrewFoundry\"\n credentialId={credentialId}\n onDownload={() =>\n downloadCertificate(course.title, recipient, dateLabel, credentialId)\n }\n />\n );\n })}\n </div>\n ))}\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAgBA,IAAM,IAAe;AAErB,SAAS,IAA+B;AACtC,KAAI,OAAO,SAAW,IAAa,wBAAO,IAAI,KAAK;AACnD,KAAI;EACF,IAAM,IAAM,OAAO,aAAa,QAAQ,EAAa,EAC/C,IAAe,IAAM,KAAK,MAAM,EAAI,GAAG,EAAE;AAC/C,SAAO,IAAI,IAAI,MAAM,QAAQ,EAAI,GAAG,EAAI,QAAQ,MAAmB,OAAO,KAAM,SAAS,GAAG,EAAE,CAAC;SACzF;AACN,yBAAO,IAAI,KAAK;;;AAIpB,SAAS,EAAe,GAA0B;CAChD,IAAM,IAAU,EAAa,EAAE,EAC3B,IAAO;AACX,MAAK,IAAI,IAAI,GAAG,IAAI,EAAS,QAAQ,KAAK,EACxC,KAAQ,IAAO,KAAK,EAAS,WAAW,EAAE,KAAM;AAElD,QAAO,EAAQ,IAAO,EAAQ;;AAGhC,SAAS,EAAW,GAAsB;AACxC,KAAI,CAAC,EAAK,QAAO;CACjB,IAAM,IAAO,IAAI,KAAK,EAAI;AAE1B,QADI,OAAO,MAAM,EAAK,SAAS,CAAC,GAAS,MAClC,EAAK,mBAAmB,KAAA,GAAW;EAAE,MAAM;EAAW,OAAO;EAAS,KAAK;EAAW,CAAC;;AAIhG,SAAS,EAAoB,GAAgB,GAAmB,GAAmB,GAAkB;AACnG,KAAI,OAAO,WAAa,IAAa;CACrC,IAAM,KAAO,MACX,EAAE,QAAQ,YAAY,OAAO;EAAE,KAAK;EAAS,KAAK;EAAQ,KAAK;EAAQ,MAAK;EAAU,EAAE,MAAM,EAAE,EAC5F,IAAO,yEAAyE,EAAI,EAAO,CAAC;;;;;;;;;;;;UAY1F,EAAI,EAAO,CAAC;;6BAEO,EAAI,EAAU,CAAC;;+BAEb,EAAI,EAAU,CAAC,gCAAgC,EAAI,EAAG,CAAC;;iBAG9E,IAAO,IAAI,KAAK,CAAC,EAAK,EAAE,EAAE,MAAM,aAAa,CAAC,EAC9C,IAAM,IAAI,gBAAgB,EAAK,EAC/B,IAAS,SAAS,cAAc,IAAI;AAM1C,CALA,EAAO,OAAO,GACd,EAAO,WAAW,2BAA2B,EAAG,QAChD,SAAS,KAAK,YAAY,EAAO,EACjC,EAAO,OAAO,EACd,EAAO,QAAQ,EACf,IAAI,gBAAgB,EAAI;;AAa1B,IAAM,IAAuD;AAE7D,SAAgB,IAA6B;CAC3C,IAAM,EAAE,eAAY,GAAqB,EACnC,IAAO,GAAS,EAChB,IAAW,GAAa,EACxB,CAAC,GAAK,KAAU,EAAc,cAAc,EAE5C,IAAY,EAAK,MAAM,MAAM,MAAM,IAAI,uBAEvC,EAAE,eAAY,cAAW,mBAAgB,QAAc;EAC3D,IAAM,IAAc,GAAuB,EACrC,IAAW,GAAiB,EAC5B,IAA0B,EAAE,EAC5B,IAAwB,EAAE,EAC5B,IAAmB;AAEvB,OAAK,IAAM,KAAU,GAAS;GAC5B,IAAM,IAAQ,EAAY,EAAO,KAC3B,IAAmB,GAAO,UAAU,UAAU;AACpD,QAAoB;GACpB,IAAM,IAAe,KAAK,IAAI,EAAO,SAAS,EAAE,EAE1C,IAAwB;IAC5B;IACA;IACA;IACA,KALU,KAAK,IAAI,KAAK,KAAK,MAAO,IAAmB,IAAgB,IAAI,CAAC;IAM5E,aAAa,GAAO;IACrB;AACD,GAAI,KAAoB,KAAgB,IAAmB,IACzD,EAAK,KAAK,EAAO,IACR,IAAmB,KAAK,EAAS,IAAI,EAAO,GAAG,KACxD,EAAO,KAAK,EAAO;;AAKvB,SAFA,EAAO,MAAM,GAAG,MAAM,EAAE,MAAM,EAAE,IAAI,EACpC,EAAK,MAAM,GAAG,OAAO,EAAE,eAAe,IAAI,cAAc,EAAE,eAAe,GAAG,CAAC,EACtE;GAAE,YAAY;GAAQ,WAAW;GAAM,aAAa;GAAkB;IAC5E,CAAC,EAAQ,CAAC,EAEP,KAAc,MAClB,EAAS,uCAAuC,mBAAmB,EAAG,GAAG;AAE3E,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf;GACE,kBAAC,OAAD,EAAA,UAAA;IACE,kBAAC,MAAD;KAAI,WAAU;eACZ,kBAAC,GAAD,EAAA,UAAc,eAA0B,CAAA;KACrC,CAAA;IACL,kBAAC,KAAD;KAAG,WAAU;eAA8B;KAAiD,CAAA;IAC5F,kBAAC,GAAD;KAAa,WAAU;eAAO;KAGhB,CAAA;IACV,EAAA,CAAA;GAEN,kBAAC,OAAD;IAAK,WAAU;cAAf;KACE,kBAAC,GAAD;MACE,OAAM;MACN,OAAO,EAAW;MAClB,MAAM,kBAAC,GAAD,EAAU,WAAU,WAAY,CAAA;MACtC,CAAA;KACF,kBAAC,GAAD;MAAU,OAAM;MAAY,OAAO,EAAU;MAAQ,MAAM,kBAAC,GAAD,EAAO,WAAU,WAAY,CAAA;MAAI,CAAA;KAC5F,kBAAC,GAAD;MACE,OAAM;MACN,OAAO,EAAU;MACjB,MAAM,kBAAC,GAAD,EAAe,WAAU,WAAY,CAAA;MAC3C,CAAA;KACF,kBAAC,GAAD;MAAU,OAAM;MAAe,OAAO;MAAa,MAAM,kBAAC,GAAD,EAAQ,WAAU,WAAY,CAAA;MAAI,CAAA;KACvF;;GAEN,kBAAC,OAAD;IAAK,WAAU;cACb,kBAAC,GAAD;KACE,cAAW;KACX,OAAO;KACP,WAAW,MAAO,EAAO,EAAU;KACnC,WAAU;KACV,MAAM;MACJ;OAAE,IAAI;OAAe,OAAO;OAAe,OAAO,EAAW;OAAQ;MACrE;OAAE,IAAI;OAAa,OAAO;OAAa,OAAO,EAAU;OAAQ;MAChE;OAAE,IAAI;OAAgB,OAAO;OAAgB,OAAO,EAAU;OAAQ;MACvE;KACD,CAAA;IACE,CAAA;GAEL,MAAQ,kBACN,EAAW,WAAW,IACrB,kBAAC,GAAD;IACE,cAAa;IACb,OAAM;IACN,aAAY;IACZ,QACE,kBAAC,GAAD;KAAQ,SAAQ;KAAU,eAAe,EAAS,2BAA2B;eAAE;KAEtE,CAAA;IAEX,CAAA,GAEF,kBAAC,OAAD;IAAK,WAAU;cACZ,EAAW,KAAK,EAAE,WAAQ,QAAK,qBAAkB,sBAAmB;KACnE,IAAM,IAAS,EAAe,EAAO,SAAS;AAC9C,YACE,kBAAC,GAAD;MAA2B,WAAU;MAAQ,WAAU;gBAAvD,CACE,kBAAC,OAAD;OACE,WAAU;OACV,OAAO,EACL,YAAY,8CAA8C,EAAO,qDAAqD,EAAO,iCAC9H;iBAED,kBAAC,QAAD;QACE,WAAU;QACV,OAAO,EAAE,OAAO,GAAQ;kBAExB,kBAAC,GAAD,EAAe,WAAU,WAAY,CAAA;QAChC,CAAA;OACH,CAAA,EACN,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,KAAD;SAAG,WAAU;mBAAyB,EAAO;SAAU,CAAA,EACvD,kBAAC,KAAD;SAAG,WAAU;mBAA+B,EAAO;SAAe,CAAA,CAC9D,EAAA,CAAA;QACN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CAAgC,GAAI,aAAiB;cACrD,kBAAC,QAAD;WAAM,WAAU;qBAAhB;YACG;YAAiB;YAAE;YAAa;YAC5B;aACH;aACN,kBAAC,OAAD;UAAK,WAAU;oBACb,kBAAC,OAAD;WACE,WAAU;WACV,OAAO,EAAE,OAAO,GAAG,EAAI,IAAI;WAC3B,CAAA;UACE,CAAA,CACF;;QACN,kBAAC,GAAD;SAAQ,WAAU;SAAS,eAAe,EAAW,EAAO,GAAG;mBAA/D,CACE,kBAAC,GAAD,EAAM,WAAU,gBAAiB,CAAA,EAAA,WAE1B;;QACL;SACI;QAtCI,EAAO,GAsCX;MAEd;IACE,CAAA;GAGT,MAAQ,gBACN,EAAU,WAAW,IACpB,kBAAC,GAAD;IACE,cAAa;IACb,OAAM;IACN,aAAY;IACZ,CAAA,GAEF,kBAAC,OAAD;IAAK,WAAU;cACZ,EAAU,KAAK,EAAE,WAAQ,qBACxB,kBAAC,UAAD;KAEE,MAAK;KACL,eAAe,EAAW,EAAO,GAAG;KACpC,WAAU;eAJZ,CAME,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,KAAD;OAAG,WAAU;iBAAgC,EAAO;OAAU,CAAA,EAC9D,kBAAC,KAAD;OAAG,WAAU;iBAAb;QAA2C;QAC9B,EAAW,EAAY;QAAC;QAAI,EAAO;QAC5C;SACA;SACN,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,GAAD;OAAa,OAAO,EAAO;OAAQ,MAAM;OAAI,WAAA;OAAY,CAAA,EACzD,kBAAC,GAAD;OAAO,SAAQ;OAAY,WAAU;iBAAc;OAE3C,CAAA,CACJ;QACC;OAjBF,EAAO,GAiBL,CACT;IACE,CAAA;GAGT,MAAQ,mBACN,EAAU,WAAW,IACpB,kBAAC,GAAD;IACE,cAAa;IACb,OAAM;IACN,aAAY;IACZ,QACE,kBAAC,GAAD;KAAQ,SAAQ;KAAU,eAAe,EAAS,2BAA2B;eAAE;KAEtE,CAAA;IAEX,CAAA,GAEF,kBAAC,OAAD;IAAK,WAAU;cACZ,EAAU,KAAK,EAAE,WAAQ,qBAAkB;KAC1C,IAAM,IAAe,MAAM,EAAO,GAAG,MAAM,GAAG,CAAC,aAAa,IACtD,IAAY,EAAW,EAAY;AACzC,YACE,kBAAC,GAAD;MAEE,OAAO,EAAO;MACH;MACX,MAAM;MACN,QAAO;MACO;MACd,kBACE,EAAoB,EAAO,OAAO,GAAW,GAAW,EAAa;MAEvE,EATK,EAAO,GASZ;MAEJ;IACE,CAAA;GAEN"}
|
|
1
|
+
{"version":3,"file":"LearningMyLearningPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/LearningMyLearningPagePage.tsx"],"sourcesContent":["import { useMemo, useState, type ComponentType } from 'react';\nimport { Award, BookOpen, GraduationCap, Layers, Play } from 'lucide-react';\nimport {\n Badge,\n Button,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n} from '@burdenoff/fe-libs/ui';\nimport { useNavigate } from 'react-router-dom';\nimport { useAuth } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { useCrewFoundryStore, type LearningCourse } from '../store/crewfoundryStore';\nimport {\n CertificateCard,\n chartPalette,\n RatingStars,\n SegmentedTabs,\n StatCard,\n} from '../components/viz';\nimport { readAllCourseProgress } from '../utils/courseProgress';\n\nconst ENROLLED_KEY = 'crewfoundry:course-enrolled:v1';\n\nfunction readEnrolledIds(): Set<string> {\n if (typeof window === 'undefined') return new Set();\n try {\n const raw = window.localStorage.getItem(ENROLLED_KEY);\n const ids: unknown = raw ? JSON.parse(raw) : [];\n return new Set(Array.isArray(ids) ? ids.filter((x): x is string => typeof x === 'string') : []);\n } catch {\n return new Set();\n }\n}\n\nfunction categoryAccent(category: string): string {\n const palette = chartPalette(8);\n let hash = 0;\n for (let i = 0; i < category.length; i += 1) {\n hash = (hash * 31 + category.charCodeAt(i)) >>> 0;\n }\n return palette[hash % palette.length];\n}\n\nfunction formatDate(iso?: string): string {\n if (!iso) return '—';\n const date = new Date(iso);\n if (Number.isNaN(date.getTime())) return '—';\n return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });\n}\n\n/** Generate a self-contained, printable certificate document (no app tokens, no raw colors). */\nfunction downloadCertificate(\n course: string,\n recipient: string,\n dateLabel: string,\n id: string\n): void {\n if (typeof document === 'undefined') return;\n const esc = (s: string) =>\n s.replace(\n /[&<>\"]/g,\n (c) => ({ '&': '&', '<': '<', '>': '>', '\"': '"' })[c] ?? c\n );\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><title>Certificate — ${esc(course)}</title>\n<style>\n body{font-family:Georgia,'Times New Roman',serif;margin:0;padding:48px;text-align:center}\n .frame{border:6px double currentColor;padding:56px 40px;max-width:760px;margin:0 auto}\n .kicker{letter-spacing:.3em;text-transform:uppercase;font-size:12px;opacity:.7}\n h1{font-size:34px;margin:24px 0 8px}\n .recipient{font-size:26px;margin:20px 0}\n .rule{width:200px;border:0;border-top:1px solid currentColor;margin:24px auto;opacity:.4}\n .meta{font-size:13px;opacity:.7;margin-top:32px}\n</style></head><body>\n <div class=\"frame\">\n <div class=\"kicker\">CrewFoundry · Certificate of Completion</div>\n <h1>${esc(course)}</h1>\n <div class=\"kicker\">Awarded to</div>\n <div class=\"recipient\">${esc(recipient)}</div>\n <hr class=\"rule\" />\n <div class=\"meta\">Issued ${esc(dateLabel)} · Credential ID: ${esc(id)}</div>\n </div>\n</body></html>`;\n const blob = new Blob([html], { type: 'text/html' });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement('a');\n anchor.href = url;\n anchor.download = `crewfoundry-certificate-${id}.html`;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n URL.revokeObjectURL(url);\n}\n\ntype Tab = 'in-progress' | 'completed' | 'certificates';\n\ninterface LearnerCourse {\n course: LearningCourse;\n completedModules: number;\n totalModules: number;\n pct: number;\n completedAt?: string;\n}\n\nconst CATEGORY_ICON: ComponentType<{ className?: string }> = BookOpen;\n\nexport function LearningMyLearningPagePage() {\n const { courses } = useCrewFoundryStore();\n const auth = useAuth();\n const navigate = useNavigate();\n const [tab, setTab] = useState<Tab>('in-progress');\n\n const recipient = auth.user?.name?.trim() || 'CrewFoundry Learner';\n\n const { inProgress, completed, modulesDone } = useMemo(() => {\n const progressMap = readAllCourseProgress();\n const enrolled = readEnrolledIds();\n const inProg: LearnerCourse[] = [];\n const done: LearnerCourse[] = [];\n let totalModulesDone = 0;\n\n for (const course of courses) {\n const entry = progressMap[course.id];\n const completedModules = entry?.completed.length ?? 0;\n totalModulesDone += completedModules;\n const totalModules = Math.max(course.modules, 1);\n const pct = Math.min(100, Math.round((completedModules / totalModules) * 100));\n const record: LearnerCourse = {\n course,\n completedModules,\n totalModules,\n pct,\n completedAt: entry?.completedAt,\n };\n if (completedModules >= totalModules && completedModules > 0) {\n done.push(record);\n } else if (completedModules > 0 || enrolled.has(course.id)) {\n inProg.push(record);\n }\n }\n inProg.sort((a, b) => b.pct - a.pct);\n done.sort((a, b) => (b.completedAt ?? '').localeCompare(a.completedAt ?? ''));\n return { inProgress: inProg, completed: done, modulesDone: totalModulesDone };\n }, [courses]);\n\n const openPlayer = (id: string) =>\n navigate(`../learning/course-learning-page?id=${encodeURIComponent(id)}`);\n\n return (\n <div className=\"space-y-6 p-6\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>My Learning</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">Track your learning progress and achievements</p>\n <PagePurpose className=\"mt-1\">\n Everything you're currently learning — track your enrolled courses, completed\n training and the certificates you've earned, all in one place.\n </PagePurpose>\n </div>\n\n <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-4\">\n <StatCard\n label=\"Courses in progress\"\n value={inProgress.length}\n icon={<BookOpen className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Completed\" value={completed.length} icon={<Award className=\"h-4 w-4\" />} />\n <StatCard\n label=\"Certificates\"\n value={completed.length}\n icon={<GraduationCap className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Modules done\" value={modulesDone} icon={<Layers className=\"h-4 w-4\" />} />\n </div>\n\n <div className=\"overflow-x-auto pb-1\">\n <SegmentedTabs\n aria-label=\"My learning\"\n value={tab}\n onChange={(id) => setTab(id as Tab)}\n className=\"w-max\"\n tabs={[\n { id: 'in-progress', label: 'In Progress', count: inProgress.length },\n { id: 'completed', label: 'Completed', count: completed.length },\n { id: 'certificates', label: 'Certificates', count: completed.length },\n ]}\n />\n </div>\n\n {tab === 'in-progress' &&\n (inProgress.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title=\"Nothing in progress yet\"\n description=\"Enroll in a course and it will show up here so you can pick up where you left off.\"\n action={\n <Button variant=\"outline\" onClick={() => navigate('../learning/courses-page')}>\n Browse courses\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {inProgress.map(({ course, pct, completedModules, totalModules }) => {\n const accent = categoryAccent(course.category);\n return (\n <GlassCard\n key={course.id}\n treatment=\"plain\"\n className=\"flex flex-col overflow-hidden\"\n >\n <div\n className=\"flex h-20 items-center justify-center\"\n style={{\n background: `linear-gradient(135deg, color-mix(in srgb, ${accent} 26%, var(--color-bg-surface)), color-mix(in srgb, ${accent} 8%, var(--color-bg-surface)))`,\n }}\n >\n <span\n className=\"flex h-10 w-10 items-center justify-center rounded-xl bg-bg-surface/70 shadow-elevation-1\"\n style={{ color: accent }}\n >\n <CATEGORY_ICON className=\"h-5 w-5\" />\n </span>\n </div>\n <div className=\"flex flex-1 flex-col gap-3 p-4\">\n <div>\n <p className=\"text-sm font-semibold\">{course.title}</p>\n <p className=\"text-xs text-text-secondary\">{course.instructor}</p>\n </div>\n <div className=\"mt-auto space-y-1\">\n <div className=\"flex justify-between text-xs text-text-secondary\">\n <span className=\"tabular-nums\">{pct}% complete</span>\n <span className=\"tabular-nums\">\n {completedModules}/{totalModules} modules\n </span>\n </div>\n <div className=\"h-2 overflow-hidden rounded-full bg-bg-sunken\">\n <div\n className=\"h-full rounded-full bg-action-primary-bg\"\n style={{ width: `${pct}%` }}\n />\n </div>\n </div>\n <Button className=\"w-full\" onClick={() => openPlayer(course.id)}>\n <Play className=\"mr-1 h-4 w-4\" />\n Continue\n </Button>\n </div>\n </GlassCard>\n );\n })}\n </div>\n ))}\n\n {tab === 'completed' &&\n (completed.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title=\"No completed courses yet\"\n description=\"Finish a course from the player and it will be marked complete here.\"\n />\n ) : (\n <div className=\"space-y-3\">\n {completed.map(({ course, completedAt }) => (\n <button\n key={course.id}\n type=\"button\"\n onClick={() => openPlayer(course.id)}\n className=\"flex w-full items-center justify-between gap-3 rounded-lg border border-border-subtle p-4 text-left transition-colors hover:border-border-strong hover:bg-bg-sunken\"\n >\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-medium\">{course.title}</p>\n <p className=\"text-xs text-text-secondary\">\n Completed {formatDate(completedAt)} · {course.instructor}\n </p>\n </div>\n <div className=\"flex shrink-0 items-center gap-3\">\n <RatingStars value={course.rating} size={13} showValue />\n <Badge variant=\"secondary\" className=\"text-[10px]\">\n Completed\n </Badge>\n </div>\n </button>\n ))}\n </div>\n ))}\n\n {tab === 'certificates' &&\n (completed.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title=\"No certificates yet\"\n description=\"Certificates are issued when you complete a course. Finish one and it will appear here to download.\"\n action={\n <Button variant=\"outline\" onClick={() => navigate('../learning/courses-page')}>\n Browse courses\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {completed.map(({ course, completedAt }) => {\n const credentialId = `CF-${course.id.slice(-6).toUpperCase()}`;\n const dateLabel = formatDate(completedAt);\n return (\n <CertificateCard\n key={course.id}\n title={course.title}\n recipient={recipient}\n date={dateLabel}\n issuer=\"CrewFoundry\"\n credentialId={credentialId}\n onDownload={() =>\n downloadCertificate(course.title, recipient, dateLabel, credentialId)\n }\n />\n );\n })}\n </div>\n ))}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MarketplaceMarketplacePagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/MarketplaceMarketplacePagePage.tsx"],"sourcesContent":["import { useEffect, useMemo, useState } from 'react';\nimport {\n Button,\n CardContent,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n Input,\n PagePurpose,\n Pagination,\n} from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { CheckCircle2, Clock3, PauseCircle, Search, ShoppingBag, Star, Users } from 'lucide-react';\nimport { useNavigate } from 'react-router-dom';\nimport { useCrewFoundryStore, type MarketplaceService } from '../store/crewfoundryStore';\nimport { RatingStars, SegmentedTabs, getInitials, type SegmentedTab } from '../components/viz';\n\n/** Honest availability derived from the real service `status` enum (never \"Booked\"). */\nfunction availabilityOf(status: MarketplaceService['status']): {\n label: string;\n icon: typeof CheckCircle2;\n className: string;\n} {\n switch (status) {\n case 'active':\n return {\n label: 'Available',\n icon: CheckCircle2,\n className: 'bg-status-success-bg text-status-success-text',\n };\n case 'pending':\n return {\n label: 'Pending',\n icon: Clock3,\n className: 'bg-status-warning-bg text-status-warning-text',\n };\n default:\n return {\n label: 'Unavailable',\n icon: PauseCircle,\n className: 'bg-bg-sunken text-text-secondary',\n };\n }\n}\n\nexport function MarketplaceMarketplacePagePage() {\n const { t: translate } = useI18n();\n const navigate = useNavigate();\n const { marketplaceServices } = useCrewFoundryStore();\n const t = (key: string, fallback: string) => {\n const value = translate(key);\n return value === key ? fallback : value;\n };\n const { page, pageSize, offset, setPage } = usePagination({ initialPageSize: 9 });\n const [search, setSearch] = useState('');\n const [category, setCategory] = useState('all');\n\n const categoryTabs = useMemo<SegmentedTab[]>(() => {\n const counts = new Map<string, number>();\n for (const svc of marketplaceServices) {\n counts.set(svc.category, (counts.get(svc.category) ?? 0) + 1);\n }\n const tabs: SegmentedTab[] = [{ id: 'all', label: 'All', count: marketplaceServices.length }];\n for (const [name, count] of [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {\n tabs.push({ id: name, label: name, count });\n }\n return tabs;\n }, [marketplaceServices]);\n\n const filtered = useMemo(() => {\n const query = search.trim().toLowerCase();\n return marketplaceServices.filter((svc) => {\n if (category !== 'all' && svc.category !== category) {\n return false;\n }\n if (!query) {\n return true;\n }\n return `${svc.title} ${svc.provider} ${svc.category} ${svc.skills.join(' ')}`\n .toLowerCase()\n .includes(query);\n });\n }, [marketplaceServices, search, category]);\n\n useEffect(() => {\n setPage(1);\n }, [search, category, setPage]);\n\n const avgRating =\n marketplaceServices.length > 0\n ? (\n marketplaceServices.reduce((s, m) => s + m.rating, 0) / marketplaceServices.length\n ).toFixed(1)\n : '0.0';\n const totalReviews = marketplaceServices.reduce((s, m) => s + m.reviews, 0);\n const pagedServices = filtered.slice(offset, offset + pageSize);\n const openService = (id: string) => navigate(`../marketplace/service-details-page?id=${id}`);\n\n return (\n <div className=\"space-y-6 p-6\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>{t('crewfoundry.marketplace.title', 'Talent Marketplace')}</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n {t('crewfoundry.marketplace.subtitle', 'Book vetted specialists and services')}\n </p>\n <PagePurpose className=\"mt-1\">\n Discover freelancers and services for your projects — search, filter by category, and hire\n the right talent on demand.\n </PagePurpose>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-3\">\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">\n {t('crewfoundry.marketplace.stats.services', 'Services')}\n </CardTitle>\n <ShoppingBag className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{marketplaceServices.length}</div>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">\n {t('crewfoundry.marketplace.stats.avgRating', 'Avg Rating')}\n </CardTitle>\n <Star className=\"h-4 w-4 text-status-warning-text\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{avgRating}</div>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">\n {t('crewfoundry.marketplace.stats.totalReviews', 'Total Reviews')}\n </CardTitle>\n <Users className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{totalReviews}</div>\n </CardContent>\n </GlassCard>\n </div>\n\n <div className=\"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between\">\n <div className=\"relative w-full max-w-sm\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder=\"Search services, providers, skills…\"\n className=\"pl-9\"\n value={search}\n onChange={(event) => setSearch(event.target.value)}\n aria-label=\"Search marketplace services\"\n />\n </div>\n <div className=\"-mx-1 overflow-x-auto px-1 pb-1\">\n <SegmentedTabs\n tabs={categoryTabs}\n value={category}\n onChange={setCategory}\n aria-label=\"Filter services by category\"\n />\n </div>\n </div>\n\n {pagedServices.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"No services found\"\n description=\"Try a different search term or category to find the help you need.\"\n />\n ) : (\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {pagedServices.map((svc) => {\n const availability = availabilityOf(svc.status);\n const AvailabilityIcon = availability.icon;\n return (\n <GlassCard\n key={svc.id}\n treatment=\"glass\"\n role=\"button\"\n tabIndex={0}\n aria-label={`View and book ${svc.title} by ${svc.provider}`}\n onClick={() => openService(svc.id)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n openService(svc.id);\n }\n }}\n className=\"cursor-pointer border-border-seam transition-shadow hover:shadow-elevation-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]\"\n >\n <CardContent className=\"flex h-full flex-col gap-3 p-4\">\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex items-center gap-2.5\">\n <span\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-action-primary-bg text-sm font-semibold text-action-primary-text\"\n aria-hidden=\"true\"\n >\n {getInitials(svc.provider)}\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-semibold\">{svc.provider}</p>\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2 py-0.5 text-[10px] text-text-secondary\">\n {svc.category}\n </span>\n </div>\n </div>\n <div className=\"shrink-0 text-right\">\n <p className=\"text-base font-bold tabular-nums\">${svc.price.toLocaleString()}</p>\n <p className=\"text-[10px] text-text-secondary\">/{svc.priceType}</p>\n </div>\n </div>\n\n <p className=\"text-sm font-medium\">{svc.title}</p>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <RatingStars value={svc.rating} count={svc.reviews} showValue size={14} />\n <span\n className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${availability.className}`}\n >\n <AvailabilityIcon className=\"h-3 w-3\" />\n {availability.label}\n </span>\n </div>\n\n <p className=\"line-clamp-2 text-xs text-text-secondary\">{svc.description}</p>\n\n <div className=\"flex flex-wrap gap-1\">\n {svc.skills.slice(0, 4).map((skill) => (\n <span\n key={skill}\n className=\"rounded-full bg-bg-sunken px-2 py-0.5 text-[10px] text-text-secondary\"\n >\n {skill}\n </span>\n ))}\n </div>\n\n <div className=\"mt-auto flex items-center justify-between pt-1 text-[11px] text-text-secondary\">\n <span className=\"flex items-center gap-1\">\n <Clock3 className=\"h-3 w-3\" />\n {svc.deliveryTime}\n </span>\n </div>\n\n <Button\n className=\"w-full\"\n onClick={(event) => {\n event.stopPropagation();\n openService(svc.id);\n }}\n >\n {t('crewfoundry.marketplace.card.book', 'View & Book')}\n </Button>\n </CardContent>\n </GlassCard>\n );\n })}\n </div>\n )}\n\n {filtered.length > pageSize ? (\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={filtered.length}\n onPageChange={setPage}\n />\n ) : null}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"MarketplaceMarketplacePagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/MarketplaceMarketplacePagePage.tsx"],"sourcesContent":["import { useEffect, useMemo, useState } from 'react';\nimport {\n Button,\n CardContent,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n Input,\n PagePurpose,\n Pagination,\n} from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { CheckCircle2, Clock3, PauseCircle, Search, ShoppingBag, Star, Users } from 'lucide-react';\nimport { useNavigate } from 'react-router-dom';\nimport { useCrewFoundryStore, type MarketplaceService } from '../store/crewfoundryStore';\nimport { RatingStars, SegmentedTabs, getInitials, type SegmentedTab } from '../components/viz';\n\n/** Honest availability derived from the real service `status` enum (never \"Booked\"). */\nfunction availabilityOf(status: MarketplaceService['status']): {\n label: string;\n icon: typeof CheckCircle2;\n className: string;\n} {\n switch (status) {\n case 'active':\n return {\n label: 'Available',\n icon: CheckCircle2,\n className: 'bg-status-success-bg text-status-success-text',\n };\n case 'pending':\n return {\n label: 'Pending',\n icon: Clock3,\n className: 'bg-status-warning-bg text-status-warning-text',\n };\n default:\n return {\n label: 'Unavailable',\n icon: PauseCircle,\n className: 'bg-bg-sunken text-text-secondary',\n };\n }\n}\n\nexport function MarketplaceMarketplacePagePage() {\n const { t: translate } = useI18n();\n const navigate = useNavigate();\n const { marketplaceServices } = useCrewFoundryStore();\n const t = (key: string, fallback: string) => {\n const value = translate(key);\n return value === key ? fallback : value;\n };\n const { page, pageSize, offset, setPage } = usePagination({ initialPageSize: 9 });\n const [search, setSearch] = useState('');\n const [category, setCategory] = useState('all');\n\n const categoryTabs = useMemo<SegmentedTab[]>(() => {\n const counts = new Map<string, number>();\n for (const svc of marketplaceServices) {\n counts.set(svc.category, (counts.get(svc.category) ?? 0) + 1);\n }\n const tabs: SegmentedTab[] = [{ id: 'all', label: 'All', count: marketplaceServices.length }];\n for (const [name, count] of [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {\n tabs.push({ id: name, label: name, count });\n }\n return tabs;\n }, [marketplaceServices]);\n\n const filtered = useMemo(() => {\n const query = search.trim().toLowerCase();\n return marketplaceServices.filter((svc) => {\n if (category !== 'all' && svc.category !== category) {\n return false;\n }\n if (!query) {\n return true;\n }\n return `${svc.title} ${svc.provider} ${svc.category} ${svc.skills.join(' ')}`\n .toLowerCase()\n .includes(query);\n });\n }, [marketplaceServices, search, category]);\n\n useEffect(() => {\n setPage(1);\n }, [search, category, setPage]);\n\n const avgRating =\n marketplaceServices.length > 0\n ? (\n marketplaceServices.reduce((s, m) => s + m.rating, 0) / marketplaceServices.length\n ).toFixed(1)\n : '0.0';\n const totalReviews = marketplaceServices.reduce((s, m) => s + m.reviews, 0);\n const pagedServices = filtered.slice(offset, offset + pageSize);\n const openService = (id: string) => navigate(`../marketplace/service-details-page?id=${id}`);\n\n return (\n <div className=\"space-y-6 p-6\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>{t('crewfoundry.marketplace.title', 'Talent Marketplace')}</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n {t('crewfoundry.marketplace.subtitle', 'Book vetted specialists and services')}\n </p>\n <PagePurpose className=\"mt-1\">\n Discover freelancers and services for your projects — search, filter by category, and hire\n the right talent on demand.\n </PagePurpose>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-3\">\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">\n {t('crewfoundry.marketplace.stats.services', 'Services')}\n </CardTitle>\n <ShoppingBag className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{marketplaceServices.length}</div>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">\n {t('crewfoundry.marketplace.stats.avgRating', 'Avg Rating')}\n </CardTitle>\n <Star className=\"h-4 w-4 text-status-warning-text\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{avgRating}</div>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">\n {t('crewfoundry.marketplace.stats.totalReviews', 'Total Reviews')}\n </CardTitle>\n <Users className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{totalReviews}</div>\n </CardContent>\n </GlassCard>\n </div>\n\n <div className=\"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between\">\n <div className=\"relative w-full max-w-sm\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n placeholder=\"Search services, providers, skills…\"\n className=\"pl-9\"\n value={search}\n onChange={(event) => setSearch(event.target.value)}\n aria-label=\"Search marketplace services\"\n />\n </div>\n <div className=\"-mx-1 overflow-x-auto px-1 pb-1\">\n <SegmentedTabs\n tabs={categoryTabs}\n value={category}\n onChange={setCategory}\n aria-label=\"Filter services by category\"\n />\n </div>\n </div>\n\n {pagedServices.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"No services found\"\n description=\"Try a different search term or category to find the help you need.\"\n />\n ) : (\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {pagedServices.map((svc) => {\n const availability = availabilityOf(svc.status);\n const AvailabilityIcon = availability.icon;\n return (\n <GlassCard\n key={svc.id}\n treatment=\"glass\"\n role=\"button\"\n tabIndex={0}\n aria-label={`View and book ${svc.title} by ${svc.provider}`}\n onClick={() => openService(svc.id)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n openService(svc.id);\n }\n }}\n className=\"cursor-pointer border-border-seam transition-shadow hover:shadow-elevation-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]\"\n >\n <CardContent className=\"flex h-full flex-col gap-3 p-4\">\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex items-center gap-2.5\">\n <span\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-action-primary-bg text-sm font-semibold text-action-primary-text\"\n aria-hidden=\"true\"\n >\n {getInitials(svc.provider)}\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-semibold\">{svc.provider}</p>\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2 py-0.5 text-[10px] text-text-secondary\">\n {svc.category}\n </span>\n </div>\n </div>\n <div className=\"shrink-0 text-right\">\n <p className=\"text-base font-bold tabular-nums\">\n ${svc.price.toLocaleString()}\n </p>\n <p className=\"text-[10px] text-text-secondary\">/{svc.priceType}</p>\n </div>\n </div>\n\n <p className=\"text-sm font-medium\">{svc.title}</p>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <RatingStars value={svc.rating} count={svc.reviews} showValue size={14} />\n <span\n className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${availability.className}`}\n >\n <AvailabilityIcon className=\"h-3 w-3\" />\n {availability.label}\n </span>\n </div>\n\n <p className=\"line-clamp-2 text-xs text-text-secondary\">{svc.description}</p>\n\n <div className=\"flex flex-wrap gap-1\">\n {svc.skills.slice(0, 4).map((skill) => (\n <span\n key={skill}\n className=\"rounded-full bg-bg-sunken px-2 py-0.5 text-[10px] text-text-secondary\"\n >\n {skill}\n </span>\n ))}\n </div>\n\n <div className=\"mt-auto flex items-center justify-between pt-1 text-[11px] text-text-secondary\">\n <span className=\"flex items-center gap-1\">\n <Clock3 className=\"h-3 w-3\" />\n {svc.deliveryTime}\n </span>\n </div>\n\n <Button\n className=\"w-full\"\n onClick={(event) => {\n event.stopPropagation();\n openService(svc.id);\n }}\n >\n {t('crewfoundry.marketplace.card.book', 'View & Book')}\n </Button>\n </CardContent>\n </GlassCard>\n );\n })}\n </div>\n )}\n\n {filtered.length > pageSize ? (\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={filtered.length}\n onPageChange={setPage}\n />\n ) : null}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MarketplaceServiceDetailsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/MarketplaceServiceDetailsPagePage.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n EmphasisPanel,\n GradientText,\n IllustratedEmptyState,\n Input,\n PagePurpose,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport {\n CalendarDays,\n CheckCircle2,\n Clock3,\n MessageSquare,\n PauseCircle,\n Star,\n} from 'lucide-react';\nimport { useLocation, useNavigate } from 'react-router-dom';\nimport { useCrewFoundryStore, type MarketplaceService } from '../store/crewfoundryStore';\nimport { RatingStars, getInitials } from '../components/viz';\n\n/** Honest availability derived from the real service `status` enum (never \"Booked\"). */\nfunction availabilityOf(status: MarketplaceService['status']): {\n label: string;\n icon: typeof CheckCircle2;\n className: string;\n} {\n switch (status) {\n case 'active':\n return {\n label: 'Available for new projects',\n icon: CheckCircle2,\n className: 'text-status-success-text',\n };\n case 'pending':\n return { label: 'Pending review', icon: Clock3, className: 'text-status-warning-text' };\n default:\n return { label: 'Not currently available', icon: PauseCircle, className: 'text-text-secondary' };\n }\n}\n\nexport function MarketplaceServiceDetailsPagePage() {\n const navigate = useNavigate();\n const location = useLocation();\n const { marketplaceServices, initialized } = useCrewFoundryStore();\n const selectedId = new URLSearchParams(location.search).get('id');\n const svc = marketplaceServices.find((s) => s.id === selectedId) ?? marketplaceServices[0];\n\n // Real \"packages\": every service this same provider actually offers. The\n // backend exposes a single price per service (no synthetic tiers), so each\n // package is a genuine listing — the viewed service is pre-selected.\n const providerPackages = useMemo(\n () =>\n svc\n ? marketplaceServices.filter((s) => s.provider === svc.provider)\n : ([] as MarketplaceService[]),\n [marketplaceServices, svc]\n );\n\n const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);\n const today = new Date().toISOString().slice(0, 10);\n const [bookingDate, setBookingDate] = useState(today);\n\n if (!initialized && !svc) {\n return (\n <div className=\"flex min-h-[60vh] items-center justify-center p-6\">\n <Spinner size=\"lg\" />\n </div>\n );\n }\n\n if (!svc) {\n return (\n <div className=\"p-6\">\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"Service not found\"\n description=\"This service is no longer available.\"\n />\n </div>\n );\n }\n\n const selectedPackage = providerPackages.find((p) => p.id === selectedPackageId) ?? svc;\n const availability = availabilityOf(svc.status);\n const AvailabilityIcon = availability.icon;\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"text-xs text-text-secondary\">\n <button\n type=\"button\"\n className=\"hover:text-text-primary\"\n onClick={() => navigate('../marketplace/marketplace-page')}\n >\n Marketplace\n </button>\n <span className=\"mx-1\">/</span>\n <span className=\"text-text-primary\">{svc.title}</span>\n </div>\n\n <div className=\"grid gap-6 lg:grid-cols-3\">\n {/* ── Left: provider + service detail ─────────────────────────── */}\n <div className=\"space-y-6 lg:col-span-2\">\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-start\">\n <span\n className=\"flex h-20 w-20 shrink-0 items-center justify-center rounded-full bg-action-primary-bg text-2xl font-semibold text-action-primary-text\"\n aria-hidden=\"true\"\n >\n {getInitials(svc.provider)}\n </span>\n <div className=\"min-w-0 flex-1 space-y-2\">\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>{svc.provider}</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">{svc.title}</p>\n <div className=\"flex flex-wrap items-center gap-3\">\n <RatingStars value={svc.rating} count={svc.reviews} showValue size={16} />\n <span className=\"flex items-center gap-1 text-xs text-text-secondary\">\n <Clock3 className=\"h-3 w-3\" />\n {svc.deliveryTime}\n </span>\n <Badge variant=\"outline\" className=\"text-[10px]\">\n {svc.category}\n </Badge>\n </div>\n <div className=\"flex flex-wrap gap-1.5 pt-1\">\n {svc.skills.map((skill) => (\n <Badge key={skill} variant=\"secondary\" className=\"text-xs\">\n {skill}\n </Badge>\n ))}\n </div>\n </div>\n </div>\n\n <PagePurpose>\n See exactly what this service includes, what it costs, and how others rated it — use the\n booking panel to pick a package and request an engagement.\n </PagePurpose>\n\n <EmphasisPanel>\n <div className=\"mb-2 text-lg font-semibold\">About this service</div>\n <p className=\"text-sm text-text-secondary\">{svc.description}</p>\n </EmphasisPanel>\n\n {/* Services offered — real listings from this provider */}\n <div className=\"space-y-3\">\n <h2 className=\"text-lg font-semibold\">Services offered</h2>\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {providerPackages.map((pkg) => {\n const active = pkg.id === selectedPackage.id;\n return (\n <button\n key={pkg.id}\n type=\"button\"\n onClick={() => setSelectedPackageId(pkg.id)}\n aria-pressed={active}\n className={`flex flex-col rounded-lg border p-4 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] ${\n active\n ? 'border-action-primary-bg bg-action-primary-bg/5'\n : 'border-border-subtle hover:bg-bg-sunken/50'\n }`}\n >\n <div className=\"flex items-start justify-between gap-2\">\n <p className=\"text-sm font-semibold\">{pkg.title}</p>\n <span className=\"whitespace-nowrap text-[11px] text-text-secondary\">\n {pkg.deliveryTime}\n </span>\n </div>\n <p className=\"mt-1 text-xl font-bold tabular-nums\">\n ${pkg.price.toLocaleString()}\n <span className=\"text-xs font-normal text-text-secondary\">/{pkg.priceType}</span>\n </p>\n <p className=\"mt-2 line-clamp-3 text-xs text-text-secondary\">{pkg.description}</p>\n </button>\n );\n })}\n </div>\n </div>\n\n {/* Reviews — backend stores only an aggregate rating + count, not\n individual review bodies, so we report that honestly rather than\n inventing reviewer cards. */}\n <div className=\"space-y-3\">\n <h2 className=\"text-lg font-semibold\">Reviews</h2>\n <Card>\n <CardContent className=\"flex flex-col items-center gap-2 p-6 text-center\">\n <div className=\"flex items-center gap-2\">\n <Star className=\"h-6 w-6 text-status-warning-text\" fill=\"currentColor\" />\n <span className=\"text-2xl font-bold tabular-nums\">{svc.rating.toFixed(1)}</span>\n <span className=\"text-sm text-text-secondary\">/ 5</span>\n </div>\n <p className=\"text-sm text-text-secondary\">\n Based on {svc.reviews.toLocaleString()}{' '}\n {svc.reviews === 1 ? 'review' : 'reviews'}. Individual review notes aren't\n published for this listing yet.\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n\n {/* ── Right: sticky booking panel ─────────────────────────────── */}\n <div className=\"lg:col-span-1\">\n <Card className=\"lg:sticky lg:top-6\">\n <CardHeader>\n <CardTitle className=\"text-lg\">Book this service</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <fieldset className=\"space-y-2\">\n <legend className=\"mb-1 text-sm font-medium\">Package</legend>\n {providerPackages.map((pkg) => {\n const active = pkg.id === selectedPackage.id;\n return (\n <button\n key={pkg.id}\n type=\"button\"\n onClick={() => setSelectedPackageId(pkg.id)}\n aria-pressed={active}\n className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] ${\n active\n ? 'border-action-primary-bg bg-action-primary-bg/5'\n : 'border-border-subtle hover:bg-bg-sunken/50'\n }`}\n >\n <span className=\"flex items-center gap-2 min-w-0\">\n <span\n className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border ${\n active ? 'border-action-primary-bg' : 'border-border-strong'\n }`}\n aria-hidden=\"true\"\n >\n {active ? (\n <span className=\"h-2 w-2 rounded-full bg-action-primary-bg\" />\n ) : null}\n </span>\n <span className=\"truncate text-sm\">{pkg.title}</span>\n </span>\n <span className=\"whitespace-nowrap text-sm font-semibold tabular-nums\">\n ${pkg.price.toLocaleString()}\n </span>\n </button>\n );\n })}\n </fieldset>\n\n <div className=\"space-y-1.5\">\n <label htmlFor=\"booking-date\" className=\"text-sm font-medium\">\n Date\n </label>\n <div className=\"relative\">\n <CalendarDays className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n id=\"booking-date\"\n type=\"date\"\n min={today}\n value={bookingDate}\n onChange={(event) => setBookingDate(event.target.value)}\n className=\"pl-9\"\n />\n </div>\n </div>\n\n <div className=\"flex items-center justify-between border-t border-border-subtle pt-3\">\n <span className=\"text-sm font-medium\">Total price</span>\n <span className=\"text-lg font-bold tabular-nums\">\n ${selectedPackage.price.toLocaleString()}\n <span className=\"text-xs font-normal text-text-secondary\">\n /{selectedPackage.priceType}\n </span>\n </span>\n </div>\n\n <div className=\"space-y-2\">\n {/* No createCrewBooking mutation exists in the backend yet, so\n Book Now stays an honest disabled control — the panel is\n fully interactive but does not fake a booking. */}\n <Button\n className=\"w-full\"\n disabled\n title=\"Booking is coming soon\"\n aria-label=\"Book now — coming soon\"\n >\n Book Now · Coming soon\n </Button>\n <Button\n variant=\"outline\"\n className=\"w-full\"\n onClick={() => navigate('../marketplace/messaging-page')}\n >\n <MessageSquare className=\"mr-1 h-4 w-4\" />\n Message\n </Button>\n </div>\n\n <p className={`flex items-center justify-center gap-1.5 text-xs ${availability.className}`}>\n <AvailabilityIcon className=\"h-3.5 w-3.5\" />\n {availability.label}\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;AA4BA,SAAS,EAAe,GAItB;AACA,SAAQ,GAAR;EACE,KAAK,SACH,QAAO;GACL,OAAO;GACP,MAAM;GACN,WAAW;GACZ;EACH,KAAK,UACH,QAAO;GAAE,OAAO;GAAkB,MAAM;GAAQ,WAAW;GAA4B;EACzF,QACE,QAAO;GAAE,OAAO;GAA2B,MAAM;GAAa,WAAW;GAAuB;;;AAItG,SAAgB,IAAoC;CAClD,IAAM,IAAW,GAAa,EACxB,IAAW,GAAa,EACxB,EAAE,wBAAqB,mBAAgB,GAAqB,EAC5D,IAAa,IAAI,gBAAgB,EAAS,OAAO,CAAC,IAAI,KAAK,EAC3D,IAAM,EAAoB,MAAM,MAAM,EAAE,OAAO,EAAW,IAAI,EAAoB,IAKlF,IAAmB,QAErB,IACI,EAAoB,QAAQ,MAAM,EAAE,aAAa,EAAI,SAAS,GAC7D,EAAE,EACT,CAAC,GAAqB,EAAI,CAC3B,EAEK,CAAC,GAAmB,KAAwB,EAAwB,KAAK,EACzE,qBAAQ,IAAI,MAAM,EAAC,aAAa,CAAC,MAAM,GAAG,GAAG,EAC7C,CAAC,GAAa,KAAkB,EAAS,EAAM;AAErD,KAAI,CAAC,KAAe,CAAC,EACnB,QACE,kBAAC,OAAD;EAAK,WAAU;YACb,kBAAC,GAAD,EAAS,MAAK,MAAO,CAAA;EACjB,CAAA;AAIV,KAAI,CAAC,EACH,QACE,kBAAC,OAAD;EAAK,WAAU;YACb,kBAAC,GAAD;GACE,cAAa;GACb,OAAM;GACN,aAAY;GACZ,CAAA;EACE,CAAA;CAIV,IAAM,IAAkB,EAAiB,MAAM,MAAM,EAAE,OAAO,EAAkB,IAAI,GAC9E,IAAe,EAAe,EAAI,OAAO,EACzC,IAAmB,EAAa;AAEtC,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf,CACE,kBAAC,OAAD;GAAK,WAAU;aAAf;IACE,kBAAC,UAAD;KACE,MAAK;KACL,WAAU;KACV,eAAe,EAAS,kCAAkC;eAC3D;KAEQ,CAAA;IACT,kBAAC,QAAD;KAAM,WAAU;eAAO;KAAQ,CAAA;IAC/B,kBAAC,QAAD;KAAM,WAAU;eAAqB,EAAI;KAAa,CAAA;IAClD;MAEN,kBAAC,OAAD;GAAK,WAAU;aAAf,CAEE,kBAAC,OAAD;IAAK,WAAU;cAAf;KACE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,QAAD;OACE,WAAU;OACV,eAAY;iBAEX,EAAY,EAAI,SAAS;OACrB,CAAA,EACP,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,MAAD;SAAI,WAAU;mBACZ,kBAAC,GAAD,EAAA,UAAe,EAAI,UAAwB,CAAA;SACxC,CAAA;QACL,kBAAC,KAAD;SAAG,WAAU;mBAA+B,EAAI;SAAU,CAAA;QAC1D,kBAAC,OAAD;SAAK,WAAU;mBAAf;UACE,kBAAC,GAAD;WAAa,OAAO,EAAI;WAAQ,OAAO,EAAI;WAAS,WAAA;WAAU,MAAM;WAAM,CAAA;UAC1E,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CACE,kBAAC,GAAD,EAAQ,WAAU,WAAY,CAAA,EAC7B,EAAI,aACA;;UACP,kBAAC,GAAD;WAAO,SAAQ;WAAU,WAAU;qBAChC,EAAI;WACC,CAAA;UACJ;;QACN,kBAAC,OAAD;SAAK,WAAU;mBACZ,EAAI,OAAO,KAAK,MACf,kBAAC,GAAD;UAAmB,SAAQ;UAAY,WAAU;oBAC9C;UACK,EAFI,EAEJ,CACR;SACE,CAAA;QACF;SACF;;KAEN,kBAAC,GAAD,EAAA,UAAa,uJAGC,CAAA;KAEd,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,OAAD;MAAK,WAAU;gBAA6B;MAAwB,CAAA,EACpE,kBAAC,KAAD;MAAG,WAAU;gBAA+B,EAAI;MAAgB,CAAA,CAClD,EAAA,CAAA;KAGhB,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,MAAD;OAAI,WAAU;iBAAwB;OAAqB,CAAA,EAC3D,kBAAC,OAAD;OAAK,WAAU;iBACZ,EAAiB,KAAK,MAAQ;QAC7B,IAAM,IAAS,EAAI,OAAO,EAAgB;AAC1C,eACE,kBAAC,UAAD;SAEE,MAAK;SACL,eAAe,EAAqB,EAAI,GAAG;SAC3C,gBAAc;SACd,WAAW,wJACT,IACI,oDACA;mBARR;UAWE,kBAAC,OAAD;WAAK,WAAU;qBAAf,CACE,kBAAC,KAAD;YAAG,WAAU;sBAAyB,EAAI;YAAU,CAAA,EACpD,kBAAC,QAAD;YAAM,WAAU;sBACb,EAAI;YACA,CAAA,CACH;;UACN,kBAAC,KAAD;WAAG,WAAU;qBAAb;YAAmD;YAC/C,EAAI,MAAM,gBAAgB;YAC5B,kBAAC,QAAD;aAAM,WAAU;uBAAhB,CAA0D,KAAE,EAAI,UAAiB;;YAC/E;;UACJ,kBAAC,KAAD;WAAG,WAAU;qBAAiD,EAAI;WAAgB,CAAA;UAC3E;WArBF,EAAI,GAqBF;SAEX;OACE,CAAA,CACF;;KAKN,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,MAAD;OAAI,WAAU;iBAAwB;OAAY,CAAA,EAClD,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;OAAa,WAAU;iBAAvB,CACE,kBAAC,OAAD;QAAK,WAAU;kBAAf;SACE,kBAAC,GAAD;UAAM,WAAU;UAAmC,MAAK;UAAiB,CAAA;SACzE,kBAAC,QAAD;UAAM,WAAU;oBAAmC,EAAI,OAAO,QAAQ,EAAE;UAAQ,CAAA;SAChF,kBAAC,QAAD;UAAM,WAAU;oBAA8B;UAAU,CAAA;SACpD;WACN,kBAAC,KAAD;QAAG,WAAU;kBAAb;SAA2C;SAC/B,EAAI,QAAQ,gBAAgB;SAAE;SACvC,EAAI,YAAY,IAAI,WAAW;SAAU;SAExC;UACQ;UACT,CAAA,CACH;;KACF;OAGN,kBAAC,OAAD;IAAK,WAAU;cACb,kBAAC,GAAD;KAAM,WAAU;eAAhB,CACE,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;MAAW,WAAU;gBAAU;MAA6B,CAAA,EACjD,CAAA,EACb,kBAAC,GAAD;MAAa,WAAU;gBAAvB;OACE,kBAAC,YAAD;QAAU,WAAU;kBAApB,CACE,kBAAC,UAAD;SAAQ,WAAU;mBAA2B;SAAgB,CAAA,EAC5D,EAAiB,KAAK,MAAQ;SAC7B,IAAM,IAAS,EAAI,OAAO,EAAgB;AAC1C,gBACE,kBAAC,UAAD;UAEE,MAAK;UACL,eAAe,EAAqB,EAAI,GAAG;UAC3C,gBAAc;UACd,WAAW,iMACT,IACI,oDACA;oBARR,CAWE,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CACE,kBAAC,QAAD;YACE,WAAW,yEACT,IAAS,6BAA6B;YAExC,eAAY;sBAEX,IACC,kBAAC,QAAD,EAAM,WAAU,6CAA8C,CAAA,GAC5D;YACC,CAAA,EACP,kBAAC,QAAD;YAAM,WAAU;sBAAoB,EAAI;YAAa,CAAA,CAChD;cACP,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CAAuE,KACnE,EAAI,MAAM,gBAAgB,CACvB;aACA;YA1BF,EAAI,GA0BF;UAEX,CACO;;OAEX,kBAAC,OAAD;QAAK,WAAU;kBAAf,CACE,kBAAC,SAAD;SAAO,SAAQ;SAAe,WAAU;mBAAsB;SAEtD,CAAA,EACR,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,GAAD,EAAc,WAAU,4FAA6F,CAAA,EACrH,kBAAC,GAAD;UACE,IAAG;UACH,MAAK;UACL,KAAK;UACL,OAAO;UACP,WAAW,MAAU,EAAe,EAAM,OAAO,MAAM;UACvD,WAAU;UACV,CAAA,CACE;WACF;;OAEN,kBAAC,OAAD;QAAK,WAAU;kBAAf,CACE,kBAAC,QAAD;SAAM,WAAU;mBAAsB;SAAkB,CAAA,EACxD,kBAAC,QAAD;SAAM,WAAU;mBAAhB;UAAiD;UAC7C,EAAgB,MAAM,gBAAgB;UACxC,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CAA0D,KACtD,EAAgB,UACb;;UACF;WACH;;OAEN,kBAAC,OAAD;QAAK,WAAU;kBAAf,CAIE,kBAAC,GAAD;SACE,WAAU;SACV,UAAA;SACA,OAAM;SACN,cAAW;mBACZ;SAEQ,CAAA,EACT,kBAAC,GAAD;SACE,SAAQ;SACR,WAAU;SACV,eAAe,EAAS,gCAAgC;mBAH1D,CAKE,kBAAC,GAAD,EAAe,WAAU,gBAAiB,CAAA,EAAA,UAEnC;WACL;;OAEN,kBAAC,KAAD;QAAG,WAAW,oDAAoD,EAAa;kBAA/E,CACE,kBAAC,GAAD,EAAkB,WAAU,eAAgB,CAAA,EAC3C,EAAa,MACZ;;OACQ;QACT;;IACH,CAAA,CACF;KACF"}
|
|
1
|
+
{"version":3,"file":"MarketplaceServiceDetailsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/MarketplaceServiceDetailsPagePage.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n EmphasisPanel,\n GradientText,\n IllustratedEmptyState,\n Input,\n PagePurpose,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport { CalendarDays, CheckCircle2, Clock3, MessageSquare, PauseCircle, Star } from 'lucide-react';\nimport { useLocation, useNavigate } from 'react-router-dom';\nimport { useCrewFoundryStore, type MarketplaceService } from '../store/crewfoundryStore';\nimport { RatingStars, getInitials } from '../components/viz';\n\n/** Honest availability derived from the real service `status` enum (never \"Booked\"). */\nfunction availabilityOf(status: MarketplaceService['status']): {\n label: string;\n icon: typeof CheckCircle2;\n className: string;\n} {\n switch (status) {\n case 'active':\n return {\n label: 'Available for new projects',\n icon: CheckCircle2,\n className: 'text-status-success-text',\n };\n case 'pending':\n return { label: 'Pending review', icon: Clock3, className: 'text-status-warning-text' };\n default:\n return {\n label: 'Not currently available',\n icon: PauseCircle,\n className: 'text-text-secondary',\n };\n }\n}\n\nexport function MarketplaceServiceDetailsPagePage() {\n const navigate = useNavigate();\n const location = useLocation();\n const { marketplaceServices, initialized } = useCrewFoundryStore();\n const selectedId = new URLSearchParams(location.search).get('id');\n const svc = marketplaceServices.find((s) => s.id === selectedId) ?? marketplaceServices[0];\n\n // Real \"packages\": every service this same provider actually offers. The\n // backend exposes a single price per service (no synthetic tiers), so each\n // package is a genuine listing — the viewed service is pre-selected.\n const providerPackages = useMemo(\n () =>\n svc\n ? marketplaceServices.filter((s) => s.provider === svc.provider)\n : ([] as MarketplaceService[]),\n [marketplaceServices, svc]\n );\n\n const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);\n const today = new Date().toISOString().slice(0, 10);\n const [bookingDate, setBookingDate] = useState(today);\n\n if (!initialized && !svc) {\n return (\n <div className=\"flex min-h-[60vh] items-center justify-center p-6\">\n <Spinner size=\"lg\" />\n </div>\n );\n }\n\n if (!svc) {\n return (\n <div className=\"p-6\">\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"Service not found\"\n description=\"This service is no longer available.\"\n />\n </div>\n );\n }\n\n const selectedPackage = providerPackages.find((p) => p.id === selectedPackageId) ?? svc;\n const availability = availabilityOf(svc.status);\n const AvailabilityIcon = availability.icon;\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"text-xs text-text-secondary\">\n <button\n type=\"button\"\n className=\"hover:text-text-primary\"\n onClick={() => navigate('../marketplace/marketplace-page')}\n >\n Marketplace\n </button>\n <span className=\"mx-1\">/</span>\n <span className=\"text-text-primary\">{svc.title}</span>\n </div>\n\n <div className=\"grid gap-6 lg:grid-cols-3\">\n {/* ── Left: provider + service detail ─────────────────────────── */}\n <div className=\"space-y-6 lg:col-span-2\">\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-start\">\n <span\n className=\"flex h-20 w-20 shrink-0 items-center justify-center rounded-full bg-action-primary-bg text-2xl font-semibold text-action-primary-text\"\n aria-hidden=\"true\"\n >\n {getInitials(svc.provider)}\n </span>\n <div className=\"min-w-0 flex-1 space-y-2\">\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>{svc.provider}</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">{svc.title}</p>\n <div className=\"flex flex-wrap items-center gap-3\">\n <RatingStars value={svc.rating} count={svc.reviews} showValue size={16} />\n <span className=\"flex items-center gap-1 text-xs text-text-secondary\">\n <Clock3 className=\"h-3 w-3\" />\n {svc.deliveryTime}\n </span>\n <Badge variant=\"outline\" className=\"text-[10px]\">\n {svc.category}\n </Badge>\n </div>\n <div className=\"flex flex-wrap gap-1.5 pt-1\">\n {svc.skills.map((skill) => (\n <Badge key={skill} variant=\"secondary\" className=\"text-xs\">\n {skill}\n </Badge>\n ))}\n </div>\n </div>\n </div>\n\n <PagePurpose>\n See exactly what this service includes, what it costs, and how others rated it — use the\n booking panel to pick a package and request an engagement.\n </PagePurpose>\n\n <EmphasisPanel>\n <div className=\"mb-2 text-lg font-semibold\">About this service</div>\n <p className=\"text-sm text-text-secondary\">{svc.description}</p>\n </EmphasisPanel>\n\n {/* Services offered — real listings from this provider */}\n <div className=\"space-y-3\">\n <h2 className=\"text-lg font-semibold\">Services offered</h2>\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {providerPackages.map((pkg) => {\n const active = pkg.id === selectedPackage.id;\n return (\n <button\n key={pkg.id}\n type=\"button\"\n onClick={() => setSelectedPackageId(pkg.id)}\n aria-pressed={active}\n className={`flex flex-col rounded-lg border p-4 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] ${\n active\n ? 'border-action-primary-bg bg-action-primary-bg/5'\n : 'border-border-subtle hover:bg-bg-sunken/50'\n }`}\n >\n <div className=\"flex items-start justify-between gap-2\">\n <p className=\"text-sm font-semibold\">{pkg.title}</p>\n <span className=\"whitespace-nowrap text-[11px] text-text-secondary\">\n {pkg.deliveryTime}\n </span>\n </div>\n <p className=\"mt-1 text-xl font-bold tabular-nums\">\n ${pkg.price.toLocaleString()}\n <span className=\"text-xs font-normal text-text-secondary\">\n /{pkg.priceType}\n </span>\n </p>\n <p className=\"mt-2 line-clamp-3 text-xs text-text-secondary\">\n {pkg.description}\n </p>\n </button>\n );\n })}\n </div>\n </div>\n\n {/* Reviews — backend stores only an aggregate rating + count, not\n individual review bodies, so we report that honestly rather than\n inventing reviewer cards. */}\n <div className=\"space-y-3\">\n <h2 className=\"text-lg font-semibold\">Reviews</h2>\n <Card>\n <CardContent className=\"flex flex-col items-center gap-2 p-6 text-center\">\n <div className=\"flex items-center gap-2\">\n <Star className=\"h-6 w-6 text-status-warning-text\" fill=\"currentColor\" />\n <span className=\"text-2xl font-bold tabular-nums\">{svc.rating.toFixed(1)}</span>\n <span className=\"text-sm text-text-secondary\">/ 5</span>\n </div>\n <p className=\"text-sm text-text-secondary\">\n Based on {svc.reviews.toLocaleString()} {svc.reviews === 1 ? 'review' : 'reviews'}\n . Individual review notes aren't published for this listing yet.\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n\n {/* ── Right: sticky booking panel ─────────────────────────────── */}\n <div className=\"lg:col-span-1\">\n <Card className=\"lg:sticky lg:top-6\">\n <CardHeader>\n <CardTitle className=\"text-lg\">Book this service</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <fieldset className=\"space-y-2\">\n <legend className=\"mb-1 text-sm font-medium\">Package</legend>\n {providerPackages.map((pkg) => {\n const active = pkg.id === selectedPackage.id;\n return (\n <button\n key={pkg.id}\n type=\"button\"\n onClick={() => setSelectedPackageId(pkg.id)}\n aria-pressed={active}\n className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] ${\n active\n ? 'border-action-primary-bg bg-action-primary-bg/5'\n : 'border-border-subtle hover:bg-bg-sunken/50'\n }`}\n >\n <span className=\"flex items-center gap-2 min-w-0\">\n <span\n className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border ${\n active ? 'border-action-primary-bg' : 'border-border-strong'\n }`}\n aria-hidden=\"true\"\n >\n {active ? (\n <span className=\"h-2 w-2 rounded-full bg-action-primary-bg\" />\n ) : null}\n </span>\n <span className=\"truncate text-sm\">{pkg.title}</span>\n </span>\n <span className=\"whitespace-nowrap text-sm font-semibold tabular-nums\">\n ${pkg.price.toLocaleString()}\n </span>\n </button>\n );\n })}\n </fieldset>\n\n <div className=\"space-y-1.5\">\n <label htmlFor=\"booking-date\" className=\"text-sm font-medium\">\n Date\n </label>\n <div className=\"relative\">\n <CalendarDays className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <Input\n id=\"booking-date\"\n type=\"date\"\n min={today}\n value={bookingDate}\n onChange={(event) => setBookingDate(event.target.value)}\n className=\"pl-9\"\n />\n </div>\n </div>\n\n <div className=\"flex items-center justify-between border-t border-border-subtle pt-3\">\n <span className=\"text-sm font-medium\">Total price</span>\n <span className=\"text-lg font-bold tabular-nums\">\n ${selectedPackage.price.toLocaleString()}\n <span className=\"text-xs font-normal text-text-secondary\">\n /{selectedPackage.priceType}\n </span>\n </span>\n </div>\n\n <div className=\"space-y-2\">\n {/* No createCrewBooking mutation exists in the backend yet, so\n Book Now stays an honest disabled control — the panel is\n fully interactive but does not fake a booking. */}\n <Button\n className=\"w-full\"\n disabled\n title=\"Booking is coming soon\"\n aria-label=\"Book now — coming soon\"\n >\n Book Now · Coming soon\n </Button>\n <Button\n variant=\"outline\"\n className=\"w-full\"\n onClick={() => navigate('../marketplace/messaging-page')}\n >\n <MessageSquare className=\"mr-1 h-4 w-4\" />\n Message\n </Button>\n </div>\n\n <p\n className={`flex items-center justify-center gap-1.5 text-xs ${availability.className}`}\n >\n <AvailabilityIcon className=\"h-3.5 w-3.5\" />\n {availability.label}\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PaymentsInvoicesPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/PaymentsInvoicesPagePage.tsx"],"sourcesContent":["import { useState } from 'react';\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n Badge,\n Button,\n CardContent,\n CardHeader,\n CardTitle,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Pagination,\n ResponsiveTable,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { CheckCircle2, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';\nimport {\n FormModal,\n useFormModal,\n type FieldConfig,\n type FormValues,\n} from '../components/forms/FormModal';\nimport { useCrewFoundryStore, type Invoice } from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { AreaChart, StatCard } from '../components/viz';\nimport { revenueTrend } from '../lib/analytics';\n\nconst INVOICE_FIELDS: FieldConfig[] = [\n {\n name: 'number',\n label: 'Invoice number',\n type: 'text',\n required: true,\n placeholder: 'INV-0001',\n },\n { name: 'clientName', label: 'Client name', type: 'text', required: true },\n { name: 'amount', label: 'Amount (USD)', type: 'number', required: true },\n {\n name: 'status',\n label: 'Status',\n type: 'select',\n options: [\n { label: 'Draft', value: 'draft' },\n { label: 'Sent', value: 'sent' },\n { label: 'Paid', value: 'paid' },\n { label: 'Overdue', value: 'overdue' },\n { label: 'Cancelled', value: 'cancelled' },\n ],\n },\n { name: 'issueDate', label: 'Issue date', type: 'date', required: true },\n { name: 'dueDate', label: 'Due date', type: 'date' },\n {\n name: 'lineItem',\n label: 'Line item description',\n type: 'text',\n colSpan: 2,\n placeholder: 'e.g. Consulting services',\n },\n];\n\nconst toInitialValues = (invoice: Invoice): FormValues => ({\n number: invoice.number,\n clientName: invoice.clientName,\n amount: invoice.amount,\n status: invoice.status,\n issueDate: invoice.issueDate ? invoice.issueDate.slice(0, 10) : '',\n dueDate: invoice.dueDate ? invoice.dueDate.slice(0, 10) : '',\n lineItem: invoice.items[0]?.description ?? '',\n});\n\nconst valuesToInvoice = (values: FormValues, base?: Invoice): Invoice => {\n const amount = typeof values.amount === 'number' ? values.amount : Number(values.amount) || 0;\n const description = String(values.lineItem ?? '').trim() || 'Services rendered';\n return {\n id: base?.id ?? crypto.randomUUID(),\n number: String(values.number ?? ''),\n clientName: String(values.clientName ?? ''),\n amount,\n status: (values.status as Invoice['status']) || 'draft',\n issueDate: String(values.issueDate ?? ''),\n dueDate: String(values.dueDate ?? ''),\n items: [{ description, amount }],\n };\n};\n\nconst statusVariant = (status: Invoice['status']) =>\n status === 'paid'\n ? 'default'\n : status === 'overdue'\n ? 'destructive'\n : status === 'draft'\n ? 'outline'\n : 'secondary';\n\nconst formatMoney = (value: number): string => `$${Math.round(value).toLocaleString()}`;\n\nexport function PaymentsInvoicesPagePage() {\n const { invoices, addInvoice, updateInvoice, removeInvoice } = useCrewFoundryStore();\n const modal = useFormModal<Invoice>();\n const { page, pageSize, offset, setPage } = usePagination({ initialPageSize: 25 });\n const [deleteTarget, setDeleteTarget] = useState<Invoice | null>(null);\n const [deleting, setDeleting] = useState(false);\n\n const sorted = [...invoices].sort((a, b) => (b.issueDate ?? '').localeCompare(a.issueDate ?? ''));\n const paged = sorted.slice(offset, offset + pageSize);\n\n // Finance KPIs — all computed from real invoice entities.\n const totalBilled = invoices\n .filter((inv) => inv.status !== 'cancelled')\n .reduce((sum, inv) => sum + inv.amount, 0);\n const totalPaid = invoices\n .filter((inv) => inv.status === 'paid')\n .reduce((sum, inv) => sum + inv.amount, 0);\n const totalOverdue = invoices\n .filter((inv) => inv.status === 'overdue')\n .reduce((sum, inv) => sum + inv.amount, 0);\n const outstanding = Math.max(totalBilled - totalPaid, 0);\n\n const revenueSeries = revenueTrend(invoices);\n\n const handleSubmit = async (values: FormValues) => {\n if (modal.context) {\n await updateInvoice(modal.context.id, valuesToInvoice(values, modal.context));\n } else {\n await addInvoice(valuesToInvoice(values));\n }\n };\n\n const confirmDelete = async () => {\n if (!deleteTarget) {\n return;\n }\n setDeleting(true);\n try {\n await removeInvoice(deleteTarget.id);\n setDeleteTarget(null);\n } finally {\n setDeleting(false);\n }\n };\n\n const columns: ResponsiveTableColumn<Invoice>[] = [\n {\n key: 'number',\n header: 'Invoice #',\n priority: 'primary',\n cell: (inv) => <span className=\"font-medium\">{inv.number}</span>,\n },\n {\n key: 'client',\n header: 'Client',\n priority: 'secondary',\n cell: (inv) => <span className=\"text-text-secondary\">{inv.clientName}</span>,\n },\n {\n key: 'issueDate',\n header: 'Issue Date',\n priority: 'hidden-on-mobile',\n cell: (inv) => <span className=\"text-text-secondary\">{inv.issueDate || '—'}</span>,\n },\n {\n key: 'dueDate',\n header: 'Due Date',\n priority: 'secondary',\n cell: (inv) => <span className=\"text-text-secondary\">{inv.dueDate || '—'}</span>,\n },\n {\n key: 'amount',\n header: 'Amount',\n priority: 'secondary',\n headerClassName: 'text-right',\n cellClassName: 'text-right',\n cell: (inv) => <span className=\"font-medium tabular-nums\">${inv.amount.toLocaleString()}</span>,\n },\n {\n key: 'status',\n header: 'Status',\n priority: 'secondary',\n cell: (inv) => (\n <Badge variant={statusVariant(inv.status)} className=\"text-[10px]\">\n {formatEnumLabel(inv.status)}\n </Badge>\n ),\n },\n ];\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex items-center justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Invoices</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">{invoices.length} invoices</p>\n <PagePurpose className=\"mt-1\">\n Create, send, and track invoices — see what's paid, pending, or overdue so you stay\n on top of getting paid.\n </PagePurpose>\n </div>\n <Button size=\"sm\" onClick={() => modal.openModal()}>\n <Plus className=\"h-4 w-4 mr-1\" />\n Create Invoice\n </Button>\n </div>\n\n {/* Finance header: 4 KPI tiles + revenue-trend chart */}\n <div className=\"grid gap-4 sm:grid-cols-2 xl:grid-cols-5\">\n <StatCard label=\"Total Billed\" value={formatMoney(totalBilled)} />\n <StatCard label=\"Paid\" value={formatMoney(totalPaid)} />\n <StatCard label=\"Outstanding\" value={formatMoney(outstanding)} />\n <StatCard\n label=\"Overdue\"\n value={<span className=\"text-status-error-text\">{formatMoney(totalOverdue)}</span>}\n />\n <div className=\"flex flex-col gap-2 rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-5 shadow-elevation-1 sm:col-span-2 xl:col-span-1\">\n <span className=\"text-sm font-medium text-text-secondary\">Revenue trend</span>\n {revenueSeries.length > 1 ? (\n <AreaChart\n series={revenueSeries}\n height={96}\n valueFormatter={(value) => formatMoney(value)}\n title=\"Recognised revenue from paid invoices by month\"\n />\n ) : (\n <p className=\"flex flex-1 items-center text-xs text-text-muted\">\n Revenue trend appears once paid invoices span more than one month.\n </p>\n )}\n </div>\n </div>\n\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader>\n <CardTitle>Invoices</CardTitle>\n </CardHeader>\n <CardContent className=\"p-4 pt-0\">\n <ResponsiveTable\n data={paged}\n rowKey={(inv) => inv.id}\n columns={columns}\n emptyState={\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No invoices yet\"\n description=\"Create your first invoice to start getting paid.\"\n />\n }\n rowActions={(inv) => (\n <div className=\"flex items-center justify-end gap-1\">\n {inv.status === 'paid' ? (\n <span className=\"inline-flex items-center gap-1 text-xs text-status-success-text\">\n <CheckCircle2 className=\"h-3.5 w-3.5\" />\n Paid\n </span>\n ) : (\n <Button\n variant=\"outline\"\n size=\"sm\"\n aria-label={`Mark invoice ${inv.number} as paid`}\n onClick={() => void updateInvoice(inv.id, { status: 'paid' })}\n >\n <CheckCircle2 className=\"h-3.5 w-3.5 mr-1\" />\n Mark Paid\n </Button>\n )}\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-8 w-8\"\n aria-label={`More actions for invoice ${inv.number}`}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuItem onSelect={() => modal.openModal(inv)}>\n <Pencil className=\"mr-2 h-4 w-4\" />\n Edit\n </DropdownMenuItem>\n {inv.status !== 'paid' && (\n <DropdownMenuItem\n onSelect={() => void updateInvoice(inv.id, { status: 'paid' })}\n >\n <CheckCircle2 className=\"mr-2 h-4 w-4\" />\n Mark as paid\n </DropdownMenuItem>\n )}\n <DropdownMenuSeparator />\n <DropdownMenuItem\n className=\"text-status-error-text\"\n onSelect={() => setDeleteTarget(inv)}\n >\n <Trash2 className=\"mr-2 h-4 w-4\" />\n Delete\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n )}\n />\n {sorted.length > pageSize && (\n <div className=\"mt-4\">\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={sorted.length}\n onPageChange={setPage}\n />\n </div>\n )}\n </CardContent>\n </GlassCard>\n\n <FormModal\n open={modal.open}\n onOpenChange={modal.onOpenChange}\n title={modal.context ? 'Edit invoice' : 'Create invoice'}\n description=\"Issue a new invoice to a client and track its payment status.\"\n fields={INVOICE_FIELDS}\n initialValues={modal.context ? toInitialValues(modal.context) : undefined}\n submitLabel={modal.context ? 'Save changes' : 'Create invoice'}\n onSubmit={handleSubmit}\n />\n\n <AlertDialog\n open={deleteTarget !== null}\n onOpenChange={(open) => {\n if (!open) {\n setDeleteTarget(null);\n }\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>Delete invoice?</AlertDialogTitle>\n <AlertDialogDescription>\n {deleteTarget\n ? `Invoice ${deleteTarget.number} will be permanently removed. This cannot be undone.`\n : ''}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>\n <AlertDialogAction\n onClick={(event) => {\n event.preventDefault();\n void confirmDelete();\n }}\n disabled={deleting}\n >\n {deleting ? 'Deleting…' : 'Delete'}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"PaymentsInvoicesPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/PaymentsInvoicesPagePage.tsx"],"sourcesContent":["import { useState } from 'react';\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n Badge,\n Button,\n CardContent,\n CardHeader,\n CardTitle,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Pagination,\n ResponsiveTable,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { CheckCircle2, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';\nimport {\n FormModal,\n useFormModal,\n type FieldConfig,\n type FormValues,\n} from '../components/forms/FormModal';\nimport { useCrewFoundryStore, type Invoice } from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { AreaChart, StatCard } from '../components/viz';\nimport { revenueTrend } from '../lib/analytics';\n\nconst INVOICE_FIELDS: FieldConfig[] = [\n {\n name: 'number',\n label: 'Invoice number',\n type: 'text',\n required: true,\n placeholder: 'INV-0001',\n },\n { name: 'clientName', label: 'Client name', type: 'text', required: true },\n { name: 'amount', label: 'Amount (USD)', type: 'number', required: true },\n {\n name: 'status',\n label: 'Status',\n type: 'select',\n options: [\n { label: 'Draft', value: 'draft' },\n { label: 'Sent', value: 'sent' },\n { label: 'Paid', value: 'paid' },\n { label: 'Overdue', value: 'overdue' },\n { label: 'Cancelled', value: 'cancelled' },\n ],\n },\n { name: 'issueDate', label: 'Issue date', type: 'date', required: true },\n { name: 'dueDate', label: 'Due date', type: 'date' },\n {\n name: 'lineItem',\n label: 'Line item description',\n type: 'text',\n colSpan: 2,\n placeholder: 'e.g. Consulting services',\n },\n];\n\nconst toInitialValues = (invoice: Invoice): FormValues => ({\n number: invoice.number,\n clientName: invoice.clientName,\n amount: invoice.amount,\n status: invoice.status,\n issueDate: invoice.issueDate ? invoice.issueDate.slice(0, 10) : '',\n dueDate: invoice.dueDate ? invoice.dueDate.slice(0, 10) : '',\n lineItem: invoice.items[0]?.description ?? '',\n});\n\nconst valuesToInvoice = (values: FormValues, base?: Invoice): Invoice => {\n const amount = typeof values.amount === 'number' ? values.amount : Number(values.amount) || 0;\n const description = String(values.lineItem ?? '').trim() || 'Services rendered';\n return {\n id: base?.id ?? crypto.randomUUID(),\n number: String(values.number ?? ''),\n clientName: String(values.clientName ?? ''),\n amount,\n status: (values.status as Invoice['status']) || 'draft',\n issueDate: String(values.issueDate ?? ''),\n dueDate: String(values.dueDate ?? ''),\n items: [{ description, amount }],\n };\n};\n\nconst statusVariant = (status: Invoice['status']) =>\n status === 'paid'\n ? 'default'\n : status === 'overdue'\n ? 'destructive'\n : status === 'draft'\n ? 'outline'\n : 'secondary';\n\nconst formatMoney = (value: number): string => `$${Math.round(value).toLocaleString()}`;\n\nexport function PaymentsInvoicesPagePage() {\n const { invoices, addInvoice, updateInvoice, removeInvoice } = useCrewFoundryStore();\n const modal = useFormModal<Invoice>();\n const { page, pageSize, offset, setPage } = usePagination({ initialPageSize: 25 });\n const [deleteTarget, setDeleteTarget] = useState<Invoice | null>(null);\n const [deleting, setDeleting] = useState(false);\n\n const sorted = [...invoices].sort((a, b) => (b.issueDate ?? '').localeCompare(a.issueDate ?? ''));\n const paged = sorted.slice(offset, offset + pageSize);\n\n // Finance KPIs — all computed from real invoice entities.\n const totalBilled = invoices\n .filter((inv) => inv.status !== 'cancelled')\n .reduce((sum, inv) => sum + inv.amount, 0);\n const totalPaid = invoices\n .filter((inv) => inv.status === 'paid')\n .reduce((sum, inv) => sum + inv.amount, 0);\n const totalOverdue = invoices\n .filter((inv) => inv.status === 'overdue')\n .reduce((sum, inv) => sum + inv.amount, 0);\n const outstanding = Math.max(totalBilled - totalPaid, 0);\n\n const revenueSeries = revenueTrend(invoices);\n\n const handleSubmit = async (values: FormValues) => {\n if (modal.context) {\n await updateInvoice(modal.context.id, valuesToInvoice(values, modal.context));\n } else {\n await addInvoice(valuesToInvoice(values));\n }\n };\n\n const confirmDelete = async () => {\n if (!deleteTarget) {\n return;\n }\n setDeleting(true);\n try {\n await removeInvoice(deleteTarget.id);\n setDeleteTarget(null);\n } finally {\n setDeleting(false);\n }\n };\n\n const columns: ResponsiveTableColumn<Invoice>[] = [\n {\n key: 'number',\n header: 'Invoice #',\n priority: 'primary',\n cell: (inv) => <span className=\"font-medium\">{inv.number}</span>,\n },\n {\n key: 'client',\n header: 'Client',\n priority: 'secondary',\n cell: (inv) => <span className=\"text-text-secondary\">{inv.clientName}</span>,\n },\n {\n key: 'issueDate',\n header: 'Issue Date',\n priority: 'hidden-on-mobile',\n cell: (inv) => <span className=\"text-text-secondary\">{inv.issueDate || '—'}</span>,\n },\n {\n key: 'dueDate',\n header: 'Due Date',\n priority: 'secondary',\n cell: (inv) => <span className=\"text-text-secondary\">{inv.dueDate || '—'}</span>,\n },\n {\n key: 'amount',\n header: 'Amount',\n priority: 'secondary',\n headerClassName: 'text-right',\n cellClassName: 'text-right',\n cell: (inv) => (\n <span className=\"font-medium tabular-nums\">${inv.amount.toLocaleString()}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n priority: 'secondary',\n cell: (inv) => (\n <Badge variant={statusVariant(inv.status)} className=\"text-[10px]\">\n {formatEnumLabel(inv.status)}\n </Badge>\n ),\n },\n ];\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex items-center justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Invoices</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">{invoices.length} invoices</p>\n <PagePurpose className=\"mt-1\">\n Create, send, and track invoices — see what's paid, pending, or overdue so you stay\n on top of getting paid.\n </PagePurpose>\n </div>\n <Button size=\"sm\" onClick={() => modal.openModal()}>\n <Plus className=\"h-4 w-4 mr-1\" />\n Create Invoice\n </Button>\n </div>\n\n {/* Finance header: 4 KPI tiles + revenue-trend chart */}\n <div className=\"grid gap-4 sm:grid-cols-2 xl:grid-cols-5\">\n <StatCard label=\"Total Billed\" value={formatMoney(totalBilled)} />\n <StatCard label=\"Paid\" value={formatMoney(totalPaid)} />\n <StatCard label=\"Outstanding\" value={formatMoney(outstanding)} />\n <StatCard\n label=\"Overdue\"\n value={<span className=\"text-status-error-text\">{formatMoney(totalOverdue)}</span>}\n />\n <div className=\"flex flex-col gap-2 rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-5 shadow-elevation-1 sm:col-span-2 xl:col-span-1\">\n <span className=\"text-sm font-medium text-text-secondary\">Revenue trend</span>\n {revenueSeries.length > 1 ? (\n <AreaChart\n series={revenueSeries}\n height={96}\n valueFormatter={(value) => formatMoney(value)}\n title=\"Recognised revenue from paid invoices by month\"\n />\n ) : (\n <p className=\"flex flex-1 items-center text-xs text-text-muted\">\n Revenue trend appears once paid invoices span more than one month.\n </p>\n )}\n </div>\n </div>\n\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader>\n <CardTitle>Invoices</CardTitle>\n </CardHeader>\n <CardContent className=\"p-4 pt-0\">\n <ResponsiveTable\n data={paged}\n rowKey={(inv) => inv.id}\n columns={columns}\n emptyState={\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No invoices yet\"\n description=\"Create your first invoice to start getting paid.\"\n />\n }\n rowActions={(inv) => (\n <div className=\"flex items-center justify-end gap-1\">\n {inv.status === 'paid' ? (\n <span className=\"inline-flex items-center gap-1 text-xs text-status-success-text\">\n <CheckCircle2 className=\"h-3.5 w-3.5\" />\n Paid\n </span>\n ) : (\n <Button\n variant=\"outline\"\n size=\"sm\"\n aria-label={`Mark invoice ${inv.number} as paid`}\n onClick={() => void updateInvoice(inv.id, { status: 'paid' })}\n >\n <CheckCircle2 className=\"h-3.5 w-3.5 mr-1\" />\n Mark Paid\n </Button>\n )}\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"h-8 w-8\"\n aria-label={`More actions for invoice ${inv.number}`}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuItem onSelect={() => modal.openModal(inv)}>\n <Pencil className=\"mr-2 h-4 w-4\" />\n Edit\n </DropdownMenuItem>\n {inv.status !== 'paid' && (\n <DropdownMenuItem\n onSelect={() => void updateInvoice(inv.id, { status: 'paid' })}\n >\n <CheckCircle2 className=\"mr-2 h-4 w-4\" />\n Mark as paid\n </DropdownMenuItem>\n )}\n <DropdownMenuSeparator />\n <DropdownMenuItem\n className=\"text-status-error-text\"\n onSelect={() => setDeleteTarget(inv)}\n >\n <Trash2 className=\"mr-2 h-4 w-4\" />\n Delete\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n )}\n />\n {sorted.length > pageSize && (\n <div className=\"mt-4\">\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={sorted.length}\n onPageChange={setPage}\n />\n </div>\n )}\n </CardContent>\n </GlassCard>\n\n <FormModal\n open={modal.open}\n onOpenChange={modal.onOpenChange}\n title={modal.context ? 'Edit invoice' : 'Create invoice'}\n description=\"Issue a new invoice to a client and track its payment status.\"\n fields={INVOICE_FIELDS}\n initialValues={modal.context ? toInitialValues(modal.context) : undefined}\n submitLabel={modal.context ? 'Save changes' : 'Create invoice'}\n onSubmit={handleSubmit}\n />\n\n <AlertDialog\n open={deleteTarget !== null}\n onOpenChange={(open) => {\n if (!open) {\n setDeleteTarget(null);\n }\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>Delete invoice?</AlertDialogTitle>\n <AlertDialogDescription>\n {deleteTarget\n ? `Invoice ${deleteTarget.number} will be permanently removed. This cannot be undone.`\n : ''}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>\n <AlertDialogAction\n onClick={(event) => {\n event.preventDefault();\n void confirmDelete();\n }}\n disabled={deleting}\n >\n {deleting ? 'Deleting…' : 'Delete'}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProjectsProjectsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/ProjectsProjectsPagePage.tsx"],"sourcesContent":["import {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Pagination,\n ResponsiveTable,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { ArrowRight, Gauge, LayoutGrid, Plus, Table2 } from 'lucide-react';\nimport { useNavigate } from 'react-router-dom';\nimport { useMemo, useState } from 'react';\nimport {\n useCrewFoundryStore,\n type Employee,\n type Project,\n} from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { AllocationBar, AvatarStack, SegmentedTabs, type Person } from '../components/viz';\nimport { allocationByPerson } from '../lib/analytics';\n\nconst MAX_ALLOCATION_ROWS = 6;\n\nfunction statusVariant(status: Project['status']): 'default' | 'secondary' | 'outline' {\n if (status === 'active') {\n return 'default';\n }\n if (status === 'completed') {\n return 'secondary';\n }\n return 'outline';\n}\n\nfunction priorityVariant(\n priority: Project['priority']\n): 'destructive' | 'default' | 'secondary' {\n if (priority === 'critical') {\n return 'destructive';\n }\n if (priority === 'high') {\n return 'default';\n }\n return 'secondary';\n}\n\nexport function ProjectsProjectsPagePage() {\n const { t: translate } = useI18n();\n const navigate = useNavigate();\n const { projects, teams, employees, timeEntries } = useCrewFoundryStore();\n const activeProjects = projects.filter((p) => p.status === 'active');\n const { page, pageSize, setPage } = usePagination({ initialPageSize: 12 });\n const [view, setView] = useState<'board' | 'table'>('board');\n const t = (key: string, fallback: string) => {\n const value = translate(key);\n return value === key ? fallback : value;\n };\n\n const employeeById = useMemo(\n () => new Map(employees.map((employee) => [employee.id, employee])),\n [employees]\n );\n const teamById = useMemo(() => new Map(teams.map((team) => [team.id, team])), [teams]);\n\n const teamPeople = (project: Project): Person[] => {\n const team = teamById.get(project.teamId);\n if (!team) {\n return [];\n }\n return team.members\n .map((memberId) => employeeById.get(memberId))\n .filter((employee): employee is Employee => employee !== undefined)\n .map((employee) => ({\n name: `${employee.firstName} ${employee.lastName}`.trim(),\n avatarUrl: employee.avatar,\n role: employee.position,\n }));\n };\n\n const allocation = useMemo(\n () => allocationByPerson(timeEntries, employees).slice(0, MAX_ALLOCATION_ROWS),\n [timeEntries, employees]\n );\n\n const pageProjects = projects.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize);\n\n const openProject = (project: Project) =>\n navigate(`../projects/project-details-page?id=${encodeURIComponent(project.id)}`);\n\n const columns: ResponsiveTableColumn<Project>[] = [\n {\n key: 'name',\n header: t('crewfoundry.projects.table.project', 'Project'),\n priority: 'primary',\n cell: (project) => (\n <span className=\"font-medium text-text-primary\">{project.name}</span>\n ),\n },\n {\n key: 'status',\n header: t('crewfoundry.projects.table.status', 'Status'),\n priority: 'primary',\n cell: (project) => (\n <Badge variant={statusVariant(project.status)} className=\"text-[10px]\">\n {formatEnumLabel(project.status)}\n </Badge>\n ),\n },\n {\n key: 'progress',\n header: t('crewfoundry.projects.table.progress', 'Progress'),\n priority: 'secondary',\n cell: (project) => (\n <div className=\"flex items-center gap-2 md:min-w-[160px]\">\n <div className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-bg-sunken\">\n <div\n className=\"h-1.5 rounded-full bg-action-primary-bg\"\n style={{ width: `${project.progress}%` }}\n />\n </div>\n <span className=\"w-9 shrink-0 text-right text-xs tabular-nums text-text-secondary\">\n {project.progress}%\n </span>\n </div>\n ),\n },\n {\n key: 'team',\n header: t('crewfoundry.projects.table.team', 'Team'),\n priority: 'secondary',\n cell: (project) => {\n const people = teamPeople(project);\n return people.length > 0 ? (\n <AvatarStack people={people} max={4} size={26} />\n ) : (\n <span className=\"text-xs text-text-secondary\">{project.teamName || '—'}</span>\n );\n },\n },\n {\n key: 'timeline',\n header: t('crewfoundry.projects.table.timeline', 'Timeline'),\n priority: 'secondary',\n cell: (project) => (\n <span className=\"whitespace-nowrap text-xs text-text-secondary\">\n {project.startDate} — {project.endDate}\n </span>\n ),\n },\n {\n key: 'budget',\n header: t('crewfoundry.projects.table.budget', 'Budget'),\n priority: 'primary',\n cell: (project) => (\n <span className=\"tabular-nums md:text-right md:block\">\n ${(project.budget / 1000).toFixed(0)}K\n </span>\n ),\n },\n ];\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>{t('crewfoundry.projects.title', 'Projects')}</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n {activeProjects.length}{' '}\n {t('crewfoundry.projects.summary.activeProjects', 'active projects')}\n </p>\n <PagePurpose className=\"mt-1\">\n All your projects in one view — track status, progress, and budgets so nothing stalls\n and everything ships on time.\n </PagePurpose>\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n <SegmentedTabs\n aria-label=\"Projects view\"\n value={view}\n onChange={(id) => setView(id as 'board' | 'table')}\n tabs={[\n { id: 'board', label: 'Board', icon: <LayoutGrid className=\"h-4 w-4\" /> },\n { id: 'table', label: 'Table', icon: <Table2 className=\"h-4 w-4\" /> },\n ]}\n />\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => navigate('../projects/resourcing-page')}\n >\n <Gauge className=\"h-4 w-4 mr-1\" />\n {t('crewfoundry.projects.actions.resourcing', 'Resourcing & Utilization')}\n </Button>\n <Button size=\"sm\" onClick={() => navigate('../projects/create-project-page')}>\n <Plus className=\"h-4 w-4 mr-1\" />\n {t('crewfoundry.projects.actions.newProject', 'New Project')}\n </Button>\n </div>\n </div>\n\n {projects.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No projects yet\"\n description=\"Create your first project to start tracking work.\"\n />\n ) : view === 'table' ? (\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardContent className=\"p-4\">\n <ResponsiveTable\n columns={columns}\n data={pageProjects}\n rowKey={(project) => project.id}\n onRowClick={openProject}\n />\n {projects.length > pageSize && (\n <div className=\"mt-4\">\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={projects.length}\n onPageChange={setPage}\n />\n </div>\n )}\n </CardContent>\n </GlassCard>\n ) : (\n <>\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {pageProjects.map((project) => {\n const people = teamPeople(project);\n return (\n <GlassCard\n key={project.id}\n role=\"button\"\n tabIndex={0}\n aria-label={`Open ${project.name} project detail`}\n onClick={() => openProject(project)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n openProject(project);\n }\n }}\n className=\"cursor-pointer transition-shadow hover:shadow-md focus-visible:shadow-[var(--shadow-focus)] focus-visible:outline-none\"\n >\n <CardHeader className=\"pb-3\">\n <div className=\"flex items-center justify-between\">\n <CardTitle className=\"text-base\">{project.name}</CardTitle>\n <Badge variant={statusVariant(project.status)} className=\"text-[10px]\">\n {formatEnumLabel(project.status)}\n </Badge>\n </div>\n <CardDescription>{project.description}</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-2 mb-3\">\n <div className=\"flex justify-between text-xs\">\n <span>\n {project.completedTasks}/{project.tasks}{' '}\n {t('crewfoundry.projects.card.tasks', 'tasks')}\n </span>\n <span className=\"tabular-nums\">{project.progress}%</span>\n </div>\n <div className=\"h-2 overflow-hidden rounded bg-bg-sunken\">\n <div\n className=\"h-2 rounded bg-action-primary-bg\"\n style={{ width: `${project.progress}%` }}\n />\n </div>\n </div>\n <div className=\"mb-3 flex items-center justify-between gap-2\">\n {people.length > 0 ? (\n <AvatarStack people={people} max={4} size={26} />\n ) : (\n <span className=\"text-xs text-text-secondary\">\n {t('crewfoundry.projects.card.team', 'Team')}: {project.teamName}\n </span>\n )}\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n ${(project.spent / 1000).toFixed(0)}K / $\n {(project.budget / 1000).toFixed(0)}K\n </span>\n </div>\n <div className=\"flex flex-wrap gap-1\">\n <Badge variant={priorityVariant(project.priority)} className=\"text-[10px]\">\n {formatEnumLabel(project.priority)}\n </Badge>\n <Badge variant=\"outline\" className=\"text-[10px]\">\n {project.startDate} — {project.endDate}\n </Badge>\n </div>\n </CardContent>\n </GlassCard>\n );\n })}\n </div>\n\n {projects.length > pageSize && (\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={projects.length}\n onPageChange={setPage}\n />\n )}\n </>\n )}\n\n {/* Resourcing — People allocation this week */}\n {allocation.length > 0 && (\n <Card>\n <CardHeader>\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div>\n <CardTitle className=\"text-base\">\n {t('crewfoundry.projects.resourcing.title', 'Resourcing')}\n </CardTitle>\n <CardDescription>\n {t(\n 'crewfoundry.projects.resourcing.subtitle',\n 'People allocation for the latest week with logged time'\n )}{' '}\n ({allocation[0].weekLabel})\n </CardDescription>\n </div>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => navigate('../projects/resourcing-page')}\n >\n {t('crewfoundry.projects.resourcing.viewAll', 'View full resourcing')}\n <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n {allocation.map((row) => (\n <AllocationBar\n key={row.employeeId}\n segments={row.segments}\n label={row.name}\n rightLabel={`${row.utilization}% utilized`}\n />\n ))}\n </CardContent>\n </Card>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ProjectsProjectsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/ProjectsProjectsPagePage.tsx"],"sourcesContent":["import {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Pagination,\n ResponsiveTable,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { ArrowRight, Gauge, LayoutGrid, Plus, Table2 } from 'lucide-react';\nimport { useNavigate } from 'react-router-dom';\nimport { useMemo, useState } from 'react';\nimport { useCrewFoundryStore, type Employee, type Project } from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { AllocationBar, AvatarStack, SegmentedTabs, type Person } from '../components/viz';\nimport { allocationByPerson } from '../lib/analytics';\n\nconst MAX_ALLOCATION_ROWS = 6;\n\nfunction statusVariant(status: Project['status']): 'default' | 'secondary' | 'outline' {\n if (status === 'active') {\n return 'default';\n }\n if (status === 'completed') {\n return 'secondary';\n }\n return 'outline';\n}\n\nfunction priorityVariant(priority: Project['priority']): 'destructive' | 'default' | 'secondary' {\n if (priority === 'critical') {\n return 'destructive';\n }\n if (priority === 'high') {\n return 'default';\n }\n return 'secondary';\n}\n\nexport function ProjectsProjectsPagePage() {\n const { t: translate } = useI18n();\n const navigate = useNavigate();\n const { projects, teams, employees, timeEntries } = useCrewFoundryStore();\n const activeProjects = projects.filter((p) => p.status === 'active');\n const { page, pageSize, setPage } = usePagination({ initialPageSize: 12 });\n const [view, setView] = useState<'board' | 'table'>('board');\n const t = (key: string, fallback: string) => {\n const value = translate(key);\n return value === key ? fallback : value;\n };\n\n const employeeById = useMemo(\n () => new Map(employees.map((employee) => [employee.id, employee])),\n [employees]\n );\n const teamById = useMemo(() => new Map(teams.map((team) => [team.id, team])), [teams]);\n\n const teamPeople = (project: Project): Person[] => {\n const team = teamById.get(project.teamId);\n if (!team) {\n return [];\n }\n return team.members\n .map((memberId) => employeeById.get(memberId))\n .filter((employee): employee is Employee => employee !== undefined)\n .map((employee) => ({\n name: `${employee.firstName} ${employee.lastName}`.trim(),\n avatarUrl: employee.avatar,\n role: employee.position,\n }));\n };\n\n const allocation = useMemo(\n () => allocationByPerson(timeEntries, employees).slice(0, MAX_ALLOCATION_ROWS),\n [timeEntries, employees]\n );\n\n const pageProjects = projects.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize);\n\n const openProject = (project: Project) =>\n navigate(`../projects/project-details-page?id=${encodeURIComponent(project.id)}`);\n\n const columns: ResponsiveTableColumn<Project>[] = [\n {\n key: 'name',\n header: t('crewfoundry.projects.table.project', 'Project'),\n priority: 'primary',\n cell: (project) => <span className=\"font-medium text-text-primary\">{project.name}</span>,\n },\n {\n key: 'status',\n header: t('crewfoundry.projects.table.status', 'Status'),\n priority: 'primary',\n cell: (project) => (\n <Badge variant={statusVariant(project.status)} className=\"text-[10px]\">\n {formatEnumLabel(project.status)}\n </Badge>\n ),\n },\n {\n key: 'progress',\n header: t('crewfoundry.projects.table.progress', 'Progress'),\n priority: 'secondary',\n cell: (project) => (\n <div className=\"flex items-center gap-2 md:min-w-[160px]\">\n <div className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-bg-sunken\">\n <div\n className=\"h-1.5 rounded-full bg-action-primary-bg\"\n style={{ width: `${project.progress}%` }}\n />\n </div>\n <span className=\"w-9 shrink-0 text-right text-xs tabular-nums text-text-secondary\">\n {project.progress}%\n </span>\n </div>\n ),\n },\n {\n key: 'team',\n header: t('crewfoundry.projects.table.team', 'Team'),\n priority: 'secondary',\n cell: (project) => {\n const people = teamPeople(project);\n return people.length > 0 ? (\n <AvatarStack people={people} max={4} size={26} />\n ) : (\n <span className=\"text-xs text-text-secondary\">{project.teamName || '—'}</span>\n );\n },\n },\n {\n key: 'timeline',\n header: t('crewfoundry.projects.table.timeline', 'Timeline'),\n priority: 'secondary',\n cell: (project) => (\n <span className=\"whitespace-nowrap text-xs text-text-secondary\">\n {project.startDate} — {project.endDate}\n </span>\n ),\n },\n {\n key: 'budget',\n header: t('crewfoundry.projects.table.budget', 'Budget'),\n priority: 'primary',\n cell: (project) => (\n <span className=\"tabular-nums md:text-right md:block\">\n ${(project.budget / 1000).toFixed(0)}K\n </span>\n ),\n },\n ];\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>{t('crewfoundry.projects.title', 'Projects')}</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n {activeProjects.length}{' '}\n {t('crewfoundry.projects.summary.activeProjects', 'active projects')}\n </p>\n <PagePurpose className=\"mt-1\">\n All your projects in one view — track status, progress, and budgets so nothing stalls\n and everything ships on time.\n </PagePurpose>\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n <SegmentedTabs\n aria-label=\"Projects view\"\n value={view}\n onChange={(id) => setView(id as 'board' | 'table')}\n tabs={[\n { id: 'board', label: 'Board', icon: <LayoutGrid className=\"h-4 w-4\" /> },\n { id: 'table', label: 'Table', icon: <Table2 className=\"h-4 w-4\" /> },\n ]}\n />\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => navigate('../projects/resourcing-page')}\n >\n <Gauge className=\"h-4 w-4 mr-1\" />\n {t('crewfoundry.projects.actions.resourcing', 'Resourcing & Utilization')}\n </Button>\n <Button size=\"sm\" onClick={() => navigate('../projects/create-project-page')}>\n <Plus className=\"h-4 w-4 mr-1\" />\n {t('crewfoundry.projects.actions.newProject', 'New Project')}\n </Button>\n </div>\n </div>\n\n {projects.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No projects yet\"\n description=\"Create your first project to start tracking work.\"\n />\n ) : view === 'table' ? (\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardContent className=\"p-4\">\n <ResponsiveTable\n columns={columns}\n data={pageProjects}\n rowKey={(project) => project.id}\n onRowClick={openProject}\n />\n {projects.length > pageSize && (\n <div className=\"mt-4\">\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={projects.length}\n onPageChange={setPage}\n />\n </div>\n )}\n </CardContent>\n </GlassCard>\n ) : (\n <>\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {pageProjects.map((project) => {\n const people = teamPeople(project);\n return (\n <GlassCard\n key={project.id}\n role=\"button\"\n tabIndex={0}\n aria-label={`Open ${project.name} project detail`}\n onClick={() => openProject(project)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n openProject(project);\n }\n }}\n className=\"cursor-pointer transition-shadow hover:shadow-md focus-visible:shadow-[var(--shadow-focus)] focus-visible:outline-none\"\n >\n <CardHeader className=\"pb-3\">\n <div className=\"flex items-center justify-between\">\n <CardTitle className=\"text-base\">{project.name}</CardTitle>\n <Badge variant={statusVariant(project.status)} className=\"text-[10px]\">\n {formatEnumLabel(project.status)}\n </Badge>\n </div>\n <CardDescription>{project.description}</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-2 mb-3\">\n <div className=\"flex justify-between text-xs\">\n <span>\n {project.completedTasks}/{project.tasks}{' '}\n {t('crewfoundry.projects.card.tasks', 'tasks')}\n </span>\n <span className=\"tabular-nums\">{project.progress}%</span>\n </div>\n <div className=\"h-2 overflow-hidden rounded bg-bg-sunken\">\n <div\n className=\"h-2 rounded bg-action-primary-bg\"\n style={{ width: `${project.progress}%` }}\n />\n </div>\n </div>\n <div className=\"mb-3 flex items-center justify-between gap-2\">\n {people.length > 0 ? (\n <AvatarStack people={people} max={4} size={26} />\n ) : (\n <span className=\"text-xs text-text-secondary\">\n {t('crewfoundry.projects.card.team', 'Team')}: {project.teamName}\n </span>\n )}\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n ${(project.spent / 1000).toFixed(0)}K / $\n {(project.budget / 1000).toFixed(0)}K\n </span>\n </div>\n <div className=\"flex flex-wrap gap-1\">\n <Badge variant={priorityVariant(project.priority)} className=\"text-[10px]\">\n {formatEnumLabel(project.priority)}\n </Badge>\n <Badge variant=\"outline\" className=\"text-[10px]\">\n {project.startDate} — {project.endDate}\n </Badge>\n </div>\n </CardContent>\n </GlassCard>\n );\n })}\n </div>\n\n {projects.length > pageSize && (\n <Pagination\n page={page}\n pageSize={pageSize}\n totalItems={projects.length}\n onPageChange={setPage}\n />\n )}\n </>\n )}\n\n {/* Resourcing — People allocation this week */}\n {allocation.length > 0 && (\n <Card>\n <CardHeader>\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div>\n <CardTitle className=\"text-base\">\n {t('crewfoundry.projects.resourcing.title', 'Resourcing')}\n </CardTitle>\n <CardDescription>\n {t(\n 'crewfoundry.projects.resourcing.subtitle',\n 'People allocation for the latest week with logged time'\n )}{' '}\n ({allocation[0].weekLabel})\n </CardDescription>\n </div>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => navigate('../projects/resourcing-page')}\n >\n {t('crewfoundry.projects.resourcing.viewAll', 'View full resourcing')}\n <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </div>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n {allocation.map((row) => (\n <AllocationBar\n key={row.employeeId}\n segments={row.segments}\n label={row.name}\n rightLabel={`${row.utilization}% utilized`}\n />\n ))}\n </CardContent>\n </Card>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProjectsResourcingPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/ProjectsResourcingPagePage.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport { AlertTriangle, ArrowLeft, TrendingUp, Users } from 'lucide-react';\nimport { useNavigate } from 'react-router-dom';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport {\n AllocationBar,\n Heatmap,\n StatCard,\n} from '../components/viz';\nimport {\n allocationByPerson,\n benchAndOverallocated,\n skillMatch,\n utilizationByPersonWeek,\n} from '../lib/analytics';\n\nconst UTILIZATION_WEEKS = 8;\nconst MAX_ALLOCATION_ROWS = 12;\n\nexport function ProjectsResourcingPagePage() {\n const navigate = useNavigate();\n const timeEntries = useCrewFoundryStore((state) => state.timeEntries);\n const employees = useCrewFoundryStore((state) => state.employees);\n const jobPostings = useCrewFoundryStore((state) => state.jobPostings);\n const candidates = useCrewFoundryStore((state) => state.candidates);\n const initialized = useCrewFoundryStore((state) => state.initialized);\n\n const matrix = useMemo(\n () => utilizationByPersonWeek(timeEntries, employees, UTILIZATION_WEEKS),\n [timeEntries, employees]\n );\n const kpis = useMemo(() => benchAndOverallocated(matrix, employees), [matrix, employees]);\n const allocation = useMemo(\n () => allocationByPerson(timeEntries, employees).slice(0, MAX_ALLOCATION_ROWS),\n [timeEntries, employees]\n );\n const openJob = useMemo(\n () => jobPostings.find((job) => job.status === 'active'),\n [jobPostings]\n );\n // Prefer real employee skills; skillMatch falls back to the candidate pool\n // when no employee has skills recorded.\n const skills = useMemo(\n () => (openJob ? skillMatch(openJob, employees, candidates) : null),\n [openJob, employees, candidates]\n );\n\n if (!initialized) {\n return (\n <div className=\"flex min-h-[60vh] items-center justify-center p-6\">\n <Spinner size=\"lg\" />\n </div>\n );\n }\n\n const weekRangeCaption =\n matrix.hasData && matrix.weekRanges.length > 0\n ? `${matrix.weekRanges[0]} → ${matrix.weekRanges[matrix.weekRanges.length - 1]}`\n : null;\n\n return (\n <div className=\"space-y-6 p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigate('../projects/projects-page')}>\n <ArrowLeft className=\"mr-1 h-4 w-4\" />\n Back to projects\n </Button>\n\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <div>\n <p className=\"text-sm text-text-secondary\">Skills-based staffing & utilization</p>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Resourcing & Utilization</GradientText>\n </h1>\n <PagePurpose className=\"mt-1\">\n See who is over- or under-utilized week by week, how people are allocated across\n projects, and which candidates best match an open role — all from real logged time.\n </PagePurpose>\n </div>\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-3 lg:w-auto\">\n <StatCard\n label=\"Avg utilization\"\n value={`${kpis.avgUtilization}%`}\n icon={<TrendingUp className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Bench\" value={kpis.bench} icon={<Users className=\"h-4 w-4\" />} />\n <StatCard\n label=\"Over-allocated\"\n value={kpis.overAllocated}\n icon={<AlertTriangle className=\"h-4 w-4\" />}\n />\n </div>\n </div>\n\n <div className=\"grid gap-4 lg:grid-cols-3\">\n {/* Utilization heatmap */}\n <Card className=\"lg:col-span-2\">\n <CardHeader>\n <CardTitle className=\"text-base\">Utilization heatmap</CardTitle>\n <CardDescription>\n {weekRangeCaption\n ? `Weekly utilization (Σ logged hours ÷ 40h capacity) · ${weekRangeCaption}`\n : 'Weekly utilization from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent>\n {matrix.hasData ? (\n <div className=\"space-y-3\">\n <Heatmap\n rows={matrix.rows}\n colLabels={matrix.weekLabels}\n showValues\n valueFormatter={(value) => `${value}%`}\n title=\"Per-person weekly utilization\"\n />\n <ul className=\"flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted\">\n {matrix.weekLabels.map((label, index) => (\n <li key={label}>\n <span className=\"font-medium text-text-secondary\">{label}</span>{' '}\n {matrix.weekRanges[index]}\n </li>\n ))}\n </ul>\n </div>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No time entries yet\"\n description=\"Once your team logs time, per-person weekly utilization will appear here. Nothing is shown for weeks without real entries.\"\n />\n )}\n </CardContent>\n </Card>\n\n {/* Skills match for open role */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Skills match for open role</CardTitle>\n {skills ? (\n <CardDescription>\n {skills.roleTitle} ·{' '}\n {skills.source === 'employee' ? 'internal team' : 'candidate pool'}\n </CardDescription>\n ) : null}\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {!openJob ? (\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"No active roles\"\n description=\"Publish a job posting to match candidates against its required skills.\"\n />\n ) : skills && skills.requiredSkills.length > 0 ? (\n <>\n <div className=\"flex flex-wrap gap-1.5\">\n {skills.requiredSkills.map((skill) => (\n <Badge key={skill} variant=\"outline\" className=\"text-[11px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {skills.matches.length > 0 ? (\n <ol className=\"space-y-3\">\n {skills.matches.map((match, index) => (\n <li\n key={match.id}\n className=\"rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3\"\n >\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-sm font-medium text-text-primary\">\n {index + 1}. {match.name}\n </span>\n <span className=\"text-sm font-semibold tabular-nums text-status-success-text\">\n {match.matchPercent}% match\n </span>\n </div>\n <div className=\"mt-2 flex flex-wrap gap-1\">\n {match.matchedSkills.map((skill) => (\n <Badge key={skill} variant=\"secondary\" className=\"text-[10px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {match.detail ? (\n <p className=\"mt-2 text-xs text-text-muted\">{match.detail}</p>\n ) : null}\n </li>\n ))}\n </ol>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-people\"\n title=\"No people match yet\"\n description=\"No recorded skills overlap this role’s required skills.\"\n />\n )}\n </>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skill tags on this role\"\n description=\"Add recognised skill keywords to this role’s requirements to rank candidate matches.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n {/* People allocation this week */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">People allocation</CardTitle>\n <CardDescription>\n {allocation.length > 0\n ? `Project split for the latest week with logged time (${allocation[0].weekLabel})`\n : 'Per-person project allocation from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n {allocation.length > 0 ? (\n allocation.map((row) => (\n <AllocationBar\n key={row.employeeId}\n segments={row.segments}\n label={row.name}\n rightLabel={`${row.utilization}% utilized`}\n />\n ))\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No allocation to show\"\n description=\"No time entries have been logged yet, so there is nothing to allocate across projects.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ProjectsResourcingPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/ProjectsResourcingPagePage.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport { AlertTriangle, ArrowLeft, TrendingUp, Users } from 'lucide-react';\nimport { useNavigate } from 'react-router-dom';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { AllocationBar, Heatmap, StatCard } from '../components/viz';\nimport {\n allocationByPerson,\n benchAndOverallocated,\n skillMatch,\n utilizationByPersonWeek,\n} from '../lib/analytics';\n\nconst UTILIZATION_WEEKS = 8;\nconst MAX_ALLOCATION_ROWS = 12;\n\nexport function ProjectsResourcingPagePage() {\n const navigate = useNavigate();\n const timeEntries = useCrewFoundryStore((state) => state.timeEntries);\n const employees = useCrewFoundryStore((state) => state.employees);\n const jobPostings = useCrewFoundryStore((state) => state.jobPostings);\n const candidates = useCrewFoundryStore((state) => state.candidates);\n const initialized = useCrewFoundryStore((state) => state.initialized);\n\n const matrix = useMemo(\n () => utilizationByPersonWeek(timeEntries, employees, UTILIZATION_WEEKS),\n [timeEntries, employees]\n );\n const kpis = useMemo(() => benchAndOverallocated(matrix, employees), [matrix, employees]);\n const allocation = useMemo(\n () => allocationByPerson(timeEntries, employees).slice(0, MAX_ALLOCATION_ROWS),\n [timeEntries, employees]\n );\n const openJob = useMemo(() => jobPostings.find((job) => job.status === 'active'), [jobPostings]);\n // Prefer real employee skills; skillMatch falls back to the candidate pool\n // when no employee has skills recorded.\n const skills = useMemo(\n () => (openJob ? skillMatch(openJob, employees, candidates) : null),\n [openJob, employees, candidates]\n );\n\n if (!initialized) {\n return (\n <div className=\"flex min-h-[60vh] items-center justify-center p-6\">\n <Spinner size=\"lg\" />\n </div>\n );\n }\n\n const weekRangeCaption =\n matrix.hasData && matrix.weekRanges.length > 0\n ? `${matrix.weekRanges[0]} → ${matrix.weekRanges[matrix.weekRanges.length - 1]}`\n : null;\n\n return (\n <div className=\"space-y-6 p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigate('../projects/projects-page')}>\n <ArrowLeft className=\"mr-1 h-4 w-4\" />\n Back to projects\n </Button>\n\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <div>\n <p className=\"text-sm text-text-secondary\">Skills-based staffing & utilization</p>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Resourcing & Utilization</GradientText>\n </h1>\n <PagePurpose className=\"mt-1\">\n See who is over- or under-utilized week by week, how people are allocated across\n projects, and which candidates best match an open role — all from real logged time.\n </PagePurpose>\n </div>\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-3 lg:w-auto\">\n <StatCard\n label=\"Avg utilization\"\n value={`${kpis.avgUtilization}%`}\n icon={<TrendingUp className=\"h-4 w-4\" />}\n />\n <StatCard label=\"Bench\" value={kpis.bench} icon={<Users className=\"h-4 w-4\" />} />\n <StatCard\n label=\"Over-allocated\"\n value={kpis.overAllocated}\n icon={<AlertTriangle className=\"h-4 w-4\" />}\n />\n </div>\n </div>\n\n <div className=\"grid gap-4 lg:grid-cols-3\">\n {/* Utilization heatmap */}\n <Card className=\"lg:col-span-2\">\n <CardHeader>\n <CardTitle className=\"text-base\">Utilization heatmap</CardTitle>\n <CardDescription>\n {weekRangeCaption\n ? `Weekly utilization (Σ logged hours ÷ 40h capacity) · ${weekRangeCaption}`\n : 'Weekly utilization from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent>\n {matrix.hasData ? (\n <div className=\"space-y-3\">\n <Heatmap\n rows={matrix.rows}\n colLabels={matrix.weekLabels}\n showValues\n valueFormatter={(value) => `${value}%`}\n title=\"Per-person weekly utilization\"\n />\n <ul className=\"flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted\">\n {matrix.weekLabels.map((label, index) => (\n <li key={label}>\n <span className=\"font-medium text-text-secondary\">{label}</span>{' '}\n {matrix.weekRanges[index]}\n </li>\n ))}\n </ul>\n </div>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No time entries yet\"\n description=\"Once your team logs time, per-person weekly utilization will appear here. Nothing is shown for weeks without real entries.\"\n />\n )}\n </CardContent>\n </Card>\n\n {/* Skills match for open role */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Skills match for open role</CardTitle>\n {skills ? (\n <CardDescription>\n {skills.roleTitle} ·{' '}\n {skills.source === 'employee' ? 'internal team' : 'candidate pool'}\n </CardDescription>\n ) : null}\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {!openJob ? (\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"No active roles\"\n description=\"Publish a job posting to match candidates against its required skills.\"\n />\n ) : skills && skills.requiredSkills.length > 0 ? (\n <>\n <div className=\"flex flex-wrap gap-1.5\">\n {skills.requiredSkills.map((skill) => (\n <Badge key={skill} variant=\"outline\" className=\"text-[11px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {skills.matches.length > 0 ? (\n <ol className=\"space-y-3\">\n {skills.matches.map((match, index) => (\n <li\n key={match.id}\n className=\"rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3\"\n >\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-sm font-medium text-text-primary\">\n {index + 1}. {match.name}\n </span>\n <span className=\"text-sm font-semibold tabular-nums text-status-success-text\">\n {match.matchPercent}% match\n </span>\n </div>\n <div className=\"mt-2 flex flex-wrap gap-1\">\n {match.matchedSkills.map((skill) => (\n <Badge key={skill} variant=\"secondary\" className=\"text-[10px]\">\n {skill}\n </Badge>\n ))}\n </div>\n {match.detail ? (\n <p className=\"mt-2 text-xs text-text-muted\">{match.detail}</p>\n ) : null}\n </li>\n ))}\n </ol>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-people\"\n title=\"No people match yet\"\n description=\"No recorded skills overlap this role’s required skills.\"\n />\n )}\n </>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skill tags on this role\"\n description=\"Add recognised skill keywords to this role’s requirements to rank candidate matches.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n {/* People allocation this week */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">People allocation</CardTitle>\n <CardDescription>\n {allocation.length > 0\n ? `Project split for the latest week with logged time (${allocation[0].weekLabel})`\n : 'Per-person project allocation from logged time'}\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n {allocation.length > 0 ? (\n allocation.map((row) => (\n <AllocationBar\n key={row.employeeId}\n segments={row.segments}\n label={row.name}\n rightLabel={`${row.utilization}% utilized`}\n />\n ))\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No allocation to show\"\n description=\"No time entries have been logged yet, so there is nothing to allocate across projects.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
|