move clients settings into projects
This commit is contained in:
@@ -117,11 +117,6 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
|||||||
label="Projects"
|
label="Projects"
|
||||||
href={`/${params.organizationId}/${projectId}/settings/projects`}
|
href={`/${params.organizationId}/${projectId}/settings/projects`}
|
||||||
/>
|
/>
|
||||||
<LinkWithIcon
|
|
||||||
icon={KeySquareIcon}
|
|
||||||
label="Clients"
|
|
||||||
href={`/${params.organizationId}/${projectId}/settings/clients`}
|
|
||||||
/>
|
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={UserIcon}
|
icon={UserIcon}
|
||||||
label="Profile (yours)"
|
label="Profile (yours)"
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
|
||||||
import { columns } from '@/components/clients/table';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { pushModal } from '@/modals';
|
|
||||||
import { PlusIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { getClientsByOrganizationId } from '@openpanel/db';
|
|
||||||
|
|
||||||
interface ListClientsProps {
|
|
||||||
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
|
|
||||||
}
|
|
||||||
export default function ListClients({ clients }: ListClientsProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StickyBelowHeader>
|
|
||||||
<div className="flex items-center justify-between p-4">
|
|
||||||
<div />
|
|
||||||
<Button icon={PlusIcon} onClick={() => pushModal('AddClient')}>
|
|
||||||
<span className="max-sm:hidden">Create client</span>
|
|
||||||
<span className="sm:hidden">Client</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</StickyBelowHeader>
|
|
||||||
<div className="p-4">
|
|
||||||
<DataTable data={clients} columns={columns} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
|
||||||
|
|
||||||
import { getClientsByOrganizationId } from '@openpanel/db';
|
|
||||||
|
|
||||||
import ListClients from './list-clients';
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: {
|
|
||||||
organizationId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
|
||||||
const clients = await getClientsByOrganizationId(organizationId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout title="Clients" organizationSlug={organizationId}>
|
|
||||||
<ListClients clients={clients} />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { ClientActions } from '@/components/clients/client-actions';
|
||||||
import { columns } from '@/components/projects/table';
|
import { ProjectActions } from '@/components/projects/project-actions';
|
||||||
|
// import { columns } from '@/components/projects/table';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@/components/ui/accordion';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { PlusIcon } from 'lucide-react';
|
import { InfoIcon, PlusIcon, PlusSquareIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { getProjectsByOrganizationSlug } from '@openpanel/db';
|
import type { IServiceClientWithProject, IServiceProject } from '@openpanel/db';
|
||||||
|
|
||||||
interface ListProjectsProps {
|
interface ListProjectsProps {
|
||||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
|
projects: IServiceProject[];
|
||||||
|
clients: IServiceClientWithProject[];
|
||||||
}
|
}
|
||||||
export default function ListProjects({ projects }: ListProjectsProps) {
|
export default function ListProjects({ projects, clients }: ListProjectsProps) {
|
||||||
const organizationId = useAppParams().organizationId;
|
const organizationId = useAppParams().organizationId;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -34,7 +44,83 @@ export default function ListProjects({ projects }: ListProjectsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</StickyBelowHeader>
|
</StickyBelowHeader>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<DataTable data={projects} columns={columns} />
|
<div className="card p-4">
|
||||||
|
<Alert className="mb-4">
|
||||||
|
<InfoIcon size={16} />
|
||||||
|
<AlertTitle>What is a project</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
A project can be a website, mobile app or any other application
|
||||||
|
that you want to track event for. Each project can have one or
|
||||||
|
more clients. The client is used to send events to the project.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Accordion type="single" collapsible className="-mx-4">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const pClients = clients.filter(
|
||||||
|
(client) => client.project_id === project.id
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<AccordionItem
|
||||||
|
value={project.id}
|
||||||
|
key={project.id}
|
||||||
|
className="last:border-b-0"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-4">
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
{project.name}
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{pClients.length > 0
|
||||||
|
? `(${pClients.length} clients)`
|
||||||
|
: 'No clients created yet'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mx-4"></div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4">
|
||||||
|
<ProjectActions {...project} />
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
{pClients.map((item) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative rounded border border-border p-4"
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<div className="mb-1 font-medium">{item.name}</div>
|
||||||
|
<Tooltiper
|
||||||
|
className="text-muted-foreground"
|
||||||
|
content={item.id}
|
||||||
|
>
|
||||||
|
Client ID: ...{item.id.slice(-12)}
|
||||||
|
</Tooltiper>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{item.secret
|
||||||
|
? 'Secret: Hidden'
|
||||||
|
: `Website: ${item.cors}`}
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<ClientActions {...item} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
pushModal('AddClient', {
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
|
||||||
|
>
|
||||||
|
<PlusSquareIcon />
|
||||||
|
<div className="font-medium">New client</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||||
|
|
||||||
import { getProjectsByOrganizationSlug } from '@openpanel/db';
|
import {
|
||||||
|
getClientsByOrganizationId,
|
||||||
|
getProjectsByOrganizationSlug,
|
||||||
|
} from '@openpanel/db';
|
||||||
|
|
||||||
import ListProjects from './list-projects';
|
import ListProjects from './list-projects';
|
||||||
|
|
||||||
@@ -11,11 +14,14 @@ interface PageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||||
const projects = await getProjectsByOrganizationSlug(organizationId);
|
const [projects, clients] = await Promise.all([
|
||||||
|
getProjectsByOrganizationSlug(organizationId),
|
||||||
|
getClientsByOrganizationId(organizationId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Projects" organizationSlug={organizationId}>
|
<PageLayout title="Projects" organizationSlug={organizationId}>
|
||||||
<ListProjects projects={projects} />
|
<ListProjects projects={projects} clients={clients} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,13 @@
|
|||||||
|
|
||||||
import { pushModal, showConfirm } from '@/modals';
|
import { pushModal, showConfirm } from '@/modals';
|
||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
import { clipboard } from '@/utils/clipboard';
|
import { Edit2Icon, TrashIcon } from 'lucide-react';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { IServiceProject } from '@openpanel/db';
|
import type { IServiceProject } from '@openpanel/db';
|
||||||
|
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '../ui/dropdown-menu';
|
|
||||||
|
|
||||||
export function ProjectActions(project: Exclude<IServiceProject, null>) {
|
export function ProjectActions(project: Exclude<IServiceProject, null>) {
|
||||||
const { id } = project;
|
const { id } = project;
|
||||||
@@ -32,27 +23,18 @@ export function ProjectActions(project: Exclude<IServiceProject, null>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<div className="flex gap-2">
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
variant="secondary"
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem onClick={() => clipboard(id)}>
|
|
||||||
Copy project ID
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pushModal('EditProject', project);
|
pushModal('EditProject', project);
|
||||||
}}
|
}}
|
||||||
|
icon={Edit2Icon}
|
||||||
>
|
>
|
||||||
Edit
|
Edit project
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
<DropdownMenuSeparator />
|
<Button
|
||||||
<DropdownMenuItem
|
variant="secondary"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showConfirm({
|
showConfirm({
|
||||||
@@ -65,10 +47,10 @@ export function ProjectActions(project: Exclude<IServiceProject, null>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
icon={TrashIcon}
|
||||||
>
|
>
|
||||||
Delete
|
Delete project
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const AccordionTrigger = React.forwardRef<
|
|||||||
<AccordionPrimitive.Trigger
|
<AccordionPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
'flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=closed]]:hover:bg-muted/30 [&[data-state=open]>svg]:rotate-180',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -33,14 +33,22 @@ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
|
|
||||||
interface TooltiperProps {
|
interface TooltiperProps {
|
||||||
asChild: boolean;
|
asChild?: boolean;
|
||||||
content: string;
|
content: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
export function Tooltiper({ asChild, content, children }: TooltiperProps) {
|
export function Tooltiper({
|
||||||
|
asChild,
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: TooltiperProps) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
|
<TooltipTrigger asChild={asChild} className={className}>
|
||||||
|
{children}
|
||||||
|
</TooltipTrigger>
|
||||||
<TooltipContent>{content}</TooltipContent>
|
<TooltipContent>{content}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ const validation = z
|
|||||||
|
|
||||||
type IForm = z.infer<typeof validation>;
|
type IForm = z.infer<typeof validation>;
|
||||||
|
|
||||||
export default function AddClient() {
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
export default function AddClient(props: Props) {
|
||||||
const { organizationId, projectId } = useAppParams();
|
const { organizationId, projectId } = useAppParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<IForm>({
|
const form = useForm<IForm>({
|
||||||
@@ -49,7 +52,7 @@ export default function AddClient() {
|
|||||||
name: '',
|
name: '',
|
||||||
cors: '',
|
cors: '',
|
||||||
tab: 'website',
|
tab: 'website',
|
||||||
projectId,
|
projectId: props.projectId ?? projectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mutation = api.client.create.useMutation({
|
const mutation = api.client.create.useMutation({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
import { escape } from 'sqlstring';
|
||||||
|
|
||||||
import { createEvent } from '@openpanel/db';
|
import { chQuery, createEvent, db } from '@openpanel/db';
|
||||||
import type {
|
import type {
|
||||||
EventsQueuePayload,
|
EventsQueuePayload,
|
||||||
EventsQueuePayloadCreateSessionEnd,
|
EventsQueuePayloadCreateSessionEnd,
|
||||||
@@ -19,7 +20,17 @@ export async function eventsJob(job: Job<EventsQueuePayload>) {
|
|||||||
if (job.attemptsStarted > 1 && job.data.payload.duration < 0) {
|
if (job.attemptsStarted > 1 && job.data.payload.duration < 0) {
|
||||||
job.data.payload.duration = 0;
|
job.data.payload.duration = 0;
|
||||||
}
|
}
|
||||||
return await createEvent(job.data.payload);
|
const createdEvent = await createEvent(job.data.payload);
|
||||||
|
try {
|
||||||
|
await updateEventsCount(job.data.payload.projectId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
job.log(`Failed to update events count: ${e.message}`);
|
||||||
|
} else {
|
||||||
|
job.log(`Failed to update events count: Unknown issue`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createdEvent;
|
||||||
}
|
}
|
||||||
case 'createSessionEnd': {
|
case 'createSessionEnd': {
|
||||||
return await createSessionEnd(
|
return await createSessionEnd(
|
||||||
@@ -28,3 +39,20 @@ export async function eventsJob(job: Job<EventsQueuePayload>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateEventsCount(projectId: string) {
|
||||||
|
const res = await chQuery<{ count: number }>(
|
||||||
|
`SELECT * FROM events WHERE project_id = ${escape(projectId)}`
|
||||||
|
);
|
||||||
|
const count = res[0]?.count;
|
||||||
|
if (count) {
|
||||||
|
await db.project.update({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
eventsCount: count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export async function getProjectsByOrganizationSlug(slug: string) {
|
|||||||
where: {
|
where: {
|
||||||
organization_slug: slug,
|
organization_slug: slug,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.map(transformProject);
|
return res.map(transformProject);
|
||||||
|
|||||||
Reference in New Issue
Block a user