Skip to content

Commit 322cc64

Browse files
author
Bogdan Tsechoev
committedJan 30, 2025
Consulting section in Console
1 parent aebe3c3 commit 322cc64

File tree

9 files changed

+420
-0
lines changed

9 files changed

+420
-0
lines changed
 

‎ui/packages/platform/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"mobx": "^6.3.2",
5656
"mobx-react-lite": "^3.2.0",
5757
"moment": "^2.24.0",
58+
"postgres-interval": "^4.0.2",
5859
"prop-types": "^15.7.2",
5960
"qs": "^6.11.0",
6061
"react": "^17.0.2",

‎ui/packages/platform/src/components/IndexPage/IndexPage.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { NotificationWrapper } from 'components/Notification/NotificationWrapper
7373
import { SharedUrlWrapper } from 'components/SharedUrl/SharedUrlWrapper'
7474
import { ShareUrlDialogWrapper } from 'components/ShareUrlDialog/ShareUrlDialogWrapper'
7575
import { BotWrapper } from "pages/Bot/BotWrapper";
76+
import { ConsultingWrapper } from "pages/Consulting/ConsultingWrapper";
7677

7778
import Actions from '../../actions/actions'
7879
import JoeConfig from '../JoeConfig'
@@ -623,6 +624,23 @@ function OrganizationMenu(parentProps: OrganizationMenuProps) {
623624
Audit
624625
</NavLink>
625626
</ListItem>)}
627+
<ListItem
628+
button
629+
className={parentProps.classes.menuSectionHeader}
630+
disabled={isBlocked}
631+
id="menuConsultingTitle"
632+
>
633+
<NavLink
634+
className={parentProps.classes.menuSectionHeaderLink}
635+
activeClassName={cn(parentProps.classes.menuSectionHeaderActiveLink, parentProps.classes.menuSingleSectionHeaderActiveLink)}
636+
to={'/' + org + '/consulting'}
637+
>
638+
<span className={parentProps.classes.menuSectionHeaderIcon}>
639+
{icons.consultingIcon}
640+
</span>
641+
Consulting
642+
</NavLink>
643+
</ListItem>
626644
<ListItem
627645
button
628646
className={cn(parentProps.classes.menuSectionHeader, parentProps.classes.menuSectionHeaderCollapsible)}
@@ -987,6 +1005,13 @@ function OrganizationWrapper(parentProps: OrganizationWrapperProps) {
9871005
return <Redirect to={`/${org}/assistant`} />;
9881006
}}
9891007
/>
1008+
<Route
1009+
path="/:org/consulting"
1010+
exact
1011+
render={(props) => (
1012+
<ConsultingWrapper {...props} {...customProps} {...queryProps} />
1013+
)}
1014+
/>
9901015
<Route
9911016
path="/:org/joe-instances"
9921017
render={(props) => (

‎ui/packages/platform/src/components/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface Orgs {
4040
owner_user_id: number
4141
is_chat_public_by_default: boolean
4242
chats_private_allowed: boolean
43+
consulting_type: string | null
4344
data: {
4445
plan: string
4546
} | null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from "react";
2+
import { Consulting } from "./index";
3+
import { RouteComponentProps } from "react-router";
4+
5+
export interface ConsultingWrapperProps {
6+
orgId?: number;
7+
history: RouteComponentProps['history']
8+
project?: string
9+
match: {
10+
params: {
11+
org?: string
12+
}
13+
}
14+
orgData: {
15+
consulting_type: string | null
16+
alias: string
17+
role: {
18+
id: number
19+
}
20+
}
21+
}
22+
23+
export const ConsultingWrapper = (props: ConsultingWrapperProps) => {
24+
return <Consulting {...props} />;
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import React, { useEffect } from "react";
2+
import ConsolePageTitle from "../../components/ConsolePageTitle";
3+
import Table from '@mui/material/Table';
4+
import TableBody from '@mui/material/TableBody';
5+
import TableCell from '@mui/material/TableCell';
6+
import TableContainer from '@mui/material/TableContainer';
7+
import TableHead from '@mui/material/TableHead';
8+
import TableRow from '@mui/material/TableRow';
9+
import { Grid, Paper, Typography } from "@mui/material";
10+
import Button from "@mui/material/Button";
11+
import Box from "@mui/material/Box/Box";
12+
import { observer } from "mobx-react-lite";
13+
import { consultingStore } from "../../stores/consulting";
14+
import { ConsultingWrapperProps } from "./ConsultingWrapper";
15+
import { makeStyles } from "@material-ui/core";
16+
import { PageSpinner } from "@postgres.ai/shared/components/PageSpinner";
17+
import { ProductCardWrapper } from "../../components/ProductCard/ProductCardWrapper";
18+
import { Link } from "@postgres.ai/shared/components/Link2";
19+
import Permissions from "../../utils/permissions";
20+
import { WarningWrapper } from "../../components/Warning/WarningWrapper";
21+
import { messages } from "../../assets/messages";
22+
import { ConsoleBreadcrumbsWrapper } from "../../components/ConsoleBreadcrumbs/ConsoleBreadcrumbsWrapper";
23+
import { formatPostgresInterval } from "./utils";
24+
25+
26+
27+
const useStyles = makeStyles((theme) => ({
28+
sectionLabel: {
29+
fontSize: '14px!important',
30+
fontWeight: '700!important' as 'bold',
31+
},
32+
productCardProjects: {
33+
flex: '1 1 0',
34+
marginRight: '20px',
35+
height: 'maxContent',
36+
gap: 20,
37+
maxHeight: '100%',
38+
39+
'& svg': {
40+
width: '206px',
41+
height: '130px',
42+
},
43+
44+
[theme.breakpoints.down('sm')]: {
45+
flex: '100%',
46+
marginTop: '20px',
47+
minHeight: 'auto !important',
48+
49+
'&:nth-child(1) svg': {
50+
marginBottom: 0,
51+
},
52+
53+
'&:nth-child(2) svg': {
54+
marginBottom: 0,
55+
},
56+
},
57+
},
58+
}))
59+
60+
export const Consulting = observer((props: ConsultingWrapperProps) => {
61+
const { orgId, orgData, match } = props;
62+
63+
const classes = useStyles();
64+
65+
useEffect(() => {
66+
if (orgId) {
67+
consultingStore.getOrgBalance(orgId);
68+
consultingStore.getTransactions(orgId);
69+
}
70+
}, [orgId]);
71+
72+
const breadcrumbs = (
73+
<ConsoleBreadcrumbsWrapper
74+
org={match.params.org}
75+
breadcrumbs={[{ name: "Consulting" }]}
76+
/>
77+
)
78+
79+
if (consultingStore.loading) {
80+
return (
81+
<Box>
82+
{breadcrumbs}
83+
<ConsolePageTitle title={"Consulting"} />
84+
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
85+
<PageSpinner />
86+
</Box>
87+
</Box>
88+
)
89+
}
90+
91+
if (orgData === null || !Permissions.isAdmin(orgData)) {
92+
return (
93+
<Box>
94+
{breadcrumbs}
95+
<ConsolePageTitle title={"Consulting"} />
96+
<WarningWrapper>{messages.noPermissionPage}</WarningWrapper>
97+
</Box>
98+
)
99+
}
100+
101+
if (orgData.consulting_type === null) {
102+
return (
103+
<Box>
104+
{breadcrumbs}
105+
<ConsolePageTitle title={"Consulting"} />
106+
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
107+
<ProductCardWrapper
108+
inline
109+
className={classes.productCardProjects}
110+
title="Not a customer yet"
111+
actions={[
112+
{
113+
id: 'learn-more',
114+
content: (<Link to="https://postgres.ai/consulting" external target="_blank">Learn more</Link>)
115+
}
116+
]}
117+
>
118+
<p>
119+
Your organization is not a consulting customer yet. To learn more about Postgres.AI consulting, visit this page: <Link to="https://postgres.ai/consulting" external target="_blank">Consulting</Link>.
120+
</p>
121+
<p>
122+
Reach out to the team to discuss consulting opportunities: <Link to="mailto:consulting@postgres.ai" external target="_blank">consulting@postgres.ai</Link>.
123+
</p>
124+
</ProductCardWrapper>
125+
</Box>
126+
</Box>
127+
)
128+
}
129+
130+
return (
131+
<div>
132+
{breadcrumbs}
133+
<ConsolePageTitle title={"Consulting"} />
134+
<Grid container spacing={3}>
135+
{orgData.consulting_type === 'retainer' && <Grid item xs={12} md={8}>
136+
<Typography variant="h6" classes={{root: classes.sectionLabel}}>
137+
Retainer balance:
138+
</Typography>
139+
<Typography variant="h5" sx={{ marginTop: 1}}>
140+
{formatPostgresInterval(consultingStore.orgBalance?.[0]?.balance || '00') || 0}
141+
</Typography>
142+
</Grid>}
143+
<Grid item xs={12} md={8}>
144+
<Box>
145+
<Button variant="contained" component="a" href="https://buy.stripe.com/7sI5odeXt3tB0Eg3cm" target="_blank">
146+
Replenish consulting hours
147+
</Button>
148+
</Box>
149+
</Grid>
150+
<Grid item xs={12} md={8}>
151+
<Box>
152+
<Typography variant="h6" classes={{root: classes.sectionLabel}}>
153+
Issue tracker (GitLab):
154+
</Typography>
155+
<Typography variant="body1" sx={{ marginTop: 1, fontSize: 14}}>
156+
<Link to={`https://gitlab.com/postgres-ai/postgresql-consulting/support/${orgData.alias}`} external target="_blank">
157+
https://gitlab.com/postgres-ai/postgresql-consulting/support/{orgData.alias}
158+
</Link>
159+
</Typography>
160+
</Box>
161+
</Grid>
162+
<Grid item xs={12} md={8}>
163+
<Box>
164+
<Typography variant="h6" classes={{root: classes.sectionLabel}}>
165+
Book a Zoom call:
166+
</Typography>
167+
<Typography variant="body1" sx={{ marginTop: 1, fontSize: 14}}>
168+
<Link to={`https://calend.ly/postgres`} external target="_blank">
169+
https://calend.ly/postgres
170+
</Link>
171+
</Typography>
172+
</Box>
173+
</Grid>
174+
<Grid item xs={12} md={8}>
175+
<Typography variant="h6" classes={{root: classes.sectionLabel}}>
176+
Activity:
177+
</Typography>
178+
{
179+
consultingStore.transactions?.length === 0
180+
? <Typography variant="body1" sx={{ marginTop: 1}}>
181+
No activity yet
182+
</Typography>
183+
: <TableContainer component={Paper} sx={{ marginTop: 1}}>
184+
<Table>
185+
<TableHead>
186+
<TableRow>
187+
<TableCell>Action</TableCell>
188+
<TableCell>Amount</TableCell>
189+
<TableCell>Date</TableCell>
190+
<TableCell>Details</TableCell>
191+
</TableRow>
192+
</TableHead>
193+
<TableBody>
194+
{
195+
consultingStore.transactions.map((transaction, index) => {
196+
return (
197+
<TableRow key={index}>
198+
<TableCell sx={{whiteSpace: 'nowrap'}}>{transaction.amount.charAt(0) === '-' ? 'Utilize' : 'Replenish'}</TableCell>
199+
<TableCell sx={{color: transaction.amount.charAt(0) === '-' ? 'red' : 'green', whiteSpace: 'nowrap'}}>
200+
{formatPostgresInterval(transaction.amount || '00')}
201+
</TableCell>
202+
<TableCell sx={{whiteSpace: 'nowrap'}}>{new Date(transaction.created_at)?.toISOString()?.split('T')?.[0]}</TableCell>
203+
<TableCell>
204+
{transaction.issue_id
205+
? <Link external to={`https://gitlab.com/postgres-ai/postgresql-consulting/support/${orgData.alias}/-/issues/${transaction.issue_id}`} target="_blank">
206+
{transaction.description}
207+
</Link>
208+
: transaction.description
209+
}
210+
</TableCell>
211+
</TableRow>
212+
);
213+
})
214+
}
215+
</TableBody>
216+
</Table>
217+
</TableContainer>
218+
}
219+
</Grid>
220+
</Grid>
221+
</div>
222+
);
223+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import parse, { IPostgresInterval } from "postgres-interval"
2+
3+
export function formatPostgresInterval(balance: string): string {
4+
const interval: IPostgresInterval = parse(balance);
5+
6+
const units: Partial<Record<keyof Omit<IPostgresInterval, 'toPostgres' | 'toISO' | 'toISOString' | 'toISOStringShort'>, string>> = {
7+
years: 'y',
8+
months: 'mo',
9+
days: 'd',
10+
hours: 'h',
11+
minutes: 'm',
12+
seconds: 's',
13+
milliseconds: 'ms',
14+
};
15+
16+
const sign = Object.keys(units)
17+
.map((key) => interval[key as keyof IPostgresInterval] || 0)
18+
.find((value) => value !== 0) ?? 0;
19+
20+
const isNegative = sign < 0;
21+
22+
const formattedParts = (Object.keys(units) as (keyof typeof units)[])
23+
.map((key) => {
24+
const value = interval[key];
25+
return value && Math.abs(value) > 0 ? `${Math.abs(value)}${units[key]}` : null;
26+
})
27+
.filter(Boolean);
28+
29+
return (isNegative ? '-' : '') + formattedParts.join(' ');
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { makeAutoObservable, runInAction } from "mobx";
2+
import { request } from "../helpers/request";
3+
4+
const apiServer = process.env.REACT_APP_API_URL_PREFIX || '';
5+
6+
interface Transaction {
7+
id: string;
8+
org_id: number;
9+
issue_id: number;
10+
amount: string;
11+
description?: string;
12+
source: string;
13+
created_at: string;
14+
}
15+
16+
interface OrgBalance {
17+
org_id: number;
18+
balance: string;
19+
}
20+
21+
class ConsultingStore {
22+
orgBalance: OrgBalance[] | null = null;
23+
transactions: Transaction[] = [];
24+
loading: boolean = false;
25+
error: string | null = null;
26+
27+
constructor() {
28+
makeAutoObservable(this);
29+
}
30+
31+
async getOrgBalance(orgId: number) {
32+
this.loading = true;
33+
this.error = null;
34+
35+
try {
36+
const response = await request(`${apiServer}/org_balance?org_id=eq.${orgId}`, {
37+
method: "GET",
38+
headers: {
39+
40+
Prefer: "return=representation",
41+
},
42+
});
43+
if (!response.ok) {
44+
console.error(`Error: ${response.statusText}`);
45+
}
46+
47+
const data: OrgBalance[] = await response.json();
48+
runInAction(() => {
49+
this.orgBalance = data;
50+
});
51+
} catch (err: unknown) {
52+
runInAction(() => {
53+
if (err instanceof Error) {
54+
this.error = err.message || "Failed to fetch org_balance";
55+
} else {
56+
this.error = err as string;
57+
}
58+
});
59+
} finally {
60+
runInAction(() => {
61+
this.loading = false;
62+
});
63+
}
64+
}
65+
66+
async getTransactions(orgId: number) {
67+
this.loading = true;
68+
this.error = null;
69+
70+
try {
71+
const response = await request(`${apiServer}/consulting_transactions?org_id=eq.${orgId}`, {
72+
method: "GET",
73+
headers: {
74+
Prefer: "return=representation",
75+
},
76+
});
77+
if (!response.ok) {
78+
console.error(`Error: ${response.statusText}`);
79+
}
80+
81+
const data: Transaction[] = await response.json();
82+
runInAction(() => {
83+
this.transactions = data;
84+
});
85+
} catch (err: unknown) {
86+
runInAction(() => {
87+
if (err instanceof Error) {
88+
this.error = err.message || "Failed to fetch transactions";
89+
} else {
90+
this.error = err as string;
91+
}
92+
});
93+
} finally {
94+
runInAction(() => {
95+
this.loading = false;
96+
});
97+
}
98+
}
99+
}
100+
101+
export const consultingStore = new ConsultingStore();

‎ui/packages/shared/styles/icons.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -1907,5 +1907,11 @@ export const icons = {
19071907
d="m384 85.3333337 85.333333 85.3333333v256H42.66666678L42.66525 193.996358c10.0983011 15.352321 24.2153849 33.106855 42.6673443 48.165701L85.3333334 384H426.666667V181.333334l-53.333334-53.3333337-39.735846.0017872c-5.439498-10.6533523-14.584184-26.4898523-27.734229-42.6683963zM384 320v21.333334H128.0000001V320zm0-64v21.333334H256l-.000063-20.370657c.541196-.318106 1.079687-.63898 1.615477-.962551zM181.333333 42.666667C278.4 42.666667 320 149.333334 320 149.333334S278.4 256 181.333333 256C84.2666668 256 42.66666678 149.333334 42.66666678 149.333334S84.2666668 42.666667 181.333333 42.666667zm0 26.6666667c-61.2906662 0-97.0666662 57.0666666-108.2986662 80.0000003 11.232 22.933333 47.008 80 108.2986662 80 61.290667 0 97.066667-57.066667 108.298667-80-11.232-22.9333337-47.008-80.0000003-108.298667-80.0000003zm0 33.3333333c26.80422 0 48.533334 20.8933783 48.533334 46.666667 0 25.773288-21.729114 46.666666-48.533334 46.666666-26.804219 0-48.5333329-20.893378-48.5333329-46.666666 0-25.7732887 21.7291139-46.666667 48.5333329-46.666667zm0 26.6666667c-11.487522 0-20.8 8.954305-20.8 20.0000003 0 11.045695 9.312478 20 20.8 20 11.487523 0 20.8-8.954305 20.8-20 0-11.0456953-9.312477-20.0000003-20.8-20.0000003z"
19081908
/>
19091909
</svg>
1910+
),
1911+
consultingIcon: (
1912+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16">
1913+
<path
1914+
d="M13 3c-.538.515-1.185.92-1.902 1.178-.748.132-2.818-.828-3.838.152-.17.17-.38.34-.6.51-.48-.21-1.22-.53-1.76-.84S3 3 3 3L0 6.5s.74 1 1.2 1.66c.3.44.67 1.11.91 1.56l-.34.4c-.058.115-.093.25-.093.393 0 .235.092.449.243.607.138.103.311.165.5.165s.362-.062.502-.167c-.094.109-.149.249-.149.402 0 .193.088.365.226.479.144.085.317.135.501.135s.357-.05.505-.137c-.112.139-.177.313-.177.503s.065.364.174.502c.099.035.214.056.334.056.207 0 .399-.063.558-.17-.043.095-.065.203-.065.317 0 .234.096.445.252.595.13.059.283.093.443.093.226 0 .437-.068.611-.185l.516-.467c.472.47 1.123.761 1.842.761.02 0 .041 0 .061-.001.494-.042.908-.356 1.094-.791.146.056.312.094.488.094.236 0 .455-.068.64-.185.585-.387.445-.687.445-.687.125.055.27.087.423.087.321 0 .61-.142.806-.366.176-.181.283-.427.283-.697 0-.19-.053-.367-.145-.518.008.005.015.005.021.005.421 0 .787-.232.978-.574.068-.171.105-.363.105-.563 0-.342-.11-.659-.296-.917l.003.005c.82-.16.79-.57 1.19-1.17.384-.494.852-.902 1.387-1.208zm-.05 7.06c-.44.44-.78.25-1.53-.32S9.18 8.1 9.18 8.1c.061.305.202.57.401.781.319.359 1.269 1.179 1.719 1.599.28.26 1 .78.58 1.18s-.75 0-1.44-.56-2.23-1.94-2.23-1.94c-.001.018-.002.038-.002.059 0 .258.104.491.272.661.17.2 1.12 1.12 1.52 1.54s.75.67.41 1-1.03-.19-1.41-.58c-.59-.57-1.76-1.63-1.76-1.63-.001.016-.001.034-.001.053 0 .284.098.544.263.75.288.378.848.868 1.188 1.248s.54.7 0 1-1.34-.44-1.69-.8v-.002c0-.103-.038-.197-.1-.269-.159-.147-.374-.238-.609-.238-.104 0-.204.018-.297.05.128-.114.204-.274.204-.452s-.076-.338-.198-.45c-.126-.095-.284-.152-.455-.152s-.33.057-.457.153c.117-.113.189-.268.189-.441 0-.213-.109-.4-.274-.509-.153-.097-.336-.153-.532-.153-.244 0-.468.088-.642.233.095-.114.151-.26.151-.42 0-.195-.085-.37-.219-.491-.178-.165-.417-.266-.679-.266-.185 0-.358.05-.507.138L1.91 8.069c-.46-.73-1-1.49-1-1.49l2.28-2.77s.81.5 1.48.88c.33.19.9.44 1.33.64-.68.51-1.25 1-1.08 1.34.297.214.668.343 1.069.343.376 0 .726-.113 1.018-.307.373-.251.84-.403 1.343-.403.347 0 .677.072.976.203.554.374 1.574 1.294 2.504 1.874 1.17.85 1.4 1.4 1.12 1.68z"/>
1915+
</svg>
19101916
)
19111917
}

‎ui/pnpm-lock.yaml

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.