diff --git a/Makefile b/Makefile
index 597d9fa03..4f95eae99 100755
--- a/Makefile
+++ b/Makefile
@@ -28,7 +28,7 @@ install:
.PHONY: asdf-install
asdf-install:
cat .tool-versions | cut -f 1 -d ' ' | xargs -n 1 asdf plugin-add || true
- asdf plugin-update --all
+ asdf plugin update --all
asdf install
asdf reshim
diff --git a/app/app/api/billing/search/route.ts b/app/app/api/billing/search/route.ts
index 19d4013cb..ad3250357 100644
--- a/app/app/api/billing/search/route.ts
+++ b/app/app/api/billing/search/route.ts
@@ -1,13 +1,63 @@
+import { DecisionStatus, RequestType } from '@prisma/client';
import { GlobalPermissions } from '@/constants';
import createApiHandler from '@/core/api-handler';
+import prisma from '@/core/prisma';
import { OkResponse } from '@/core/responses';
import { searchBilling } from '@/services/db/billing';
+import { BillingSearchResponsePayload } from '@/types/billing';
import { billingSearchBodySchema } from '@/validation-schemas/billing';
export const POST = createApiHandler({
permissions: [GlobalPermissions.ViewBilling],
validations: { body: billingSearchBodySchema },
})(async ({ body }) => {
- const result = await searchBilling(body);
+ const { data, totalCount } = await searchBilling(body);
+
+ const billingIds = data.map(({ id }) => id);
+
+ const [publicProducts, publicCreateRequests] = await Promise.all([
+ prisma.publicCloudProject.findMany({
+ where: { billingId: { in: billingIds } },
+ select: { id: true, licencePlate: true, name: true, billingId: true, provider: true },
+ }),
+ prisma.publicCloudRequest.findMany({
+ where: {
+ type: RequestType.CREATE,
+ decisionStatus: DecisionStatus.PENDING,
+ decisionData: { billingId: { in: billingIds } },
+ },
+ select: {
+ id: true,
+ licencePlate: true,
+ decisionData: { select: { name: true, billingId: true, provider: true } },
+ },
+ }),
+ ]);
+
+ const result: BillingSearchResponsePayload = {
+ data,
+ totalCount,
+ metadata: {
+ publicProducts: publicProducts.map(({ id, licencePlate, name, provider, billingId }) => ({
+ type: 'product',
+ url: `/public-cloud/products/${licencePlate}/edit`,
+ id,
+ licencePlate,
+ name,
+ context: provider,
+ billingId,
+ })),
+ publicRequests: publicCreateRequests.map(({ id, licencePlate, decisionData }) => ({
+ type: 'request',
+ url: `/public-cloud/requests/${id}/summary`,
+ id,
+ licencePlate,
+ name: decisionData.name,
+ context: decisionData.provider,
+ billingId: decisionData.billingId,
+ })),
+ },
+ };
+
return OkResponse(result);
});
diff --git a/app/app/billing/all/FilterPanel.tsx b/app/app/billing/all/FilterPanel.tsx
index 756184d75..9be22a0cf 100644
--- a/app/app/billing/all/FilterPanel.tsx
+++ b/app/app/billing/all/FilterPanel.tsx
@@ -21,7 +21,7 @@ export default function FilterPanel({ isLoading = false }: { isLoading?: boolean
loaderProps={{ color: 'pink', type: 'bars' }}
/>
-
+
-
-
-
diff --git a/app/app/billing/all/TableBody.tsx b/app/app/billing/all/TableBody.tsx
index 59d61a3a6..7bc8790a8 100644
--- a/app/app/billing/all/TableBody.tsx
+++ b/app/app/billing/all/TableBody.tsx
@@ -1,182 +1,124 @@
'use client';
-import { Avatar, Group, Table, Text } from '@mantine/core';
+import { Badge, Table, Button } from '@mantine/core';
+import { Provider } from '@prisma/client';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import _difference from 'lodash-es/difference';
+import _flatten from 'lodash-es/flatten';
import _uniq from 'lodash-es/uniq';
-import { useForm } from 'react-hook-form';
-import MinistryBadge from '@/components/badges/MinistryBadge';
+import { Session } from 'next-auth';
+import { useState } from 'react';
import CopyableButton from '@/components/generic/button/CopyableButton';
-import { formatFullName } from '@/helpers/user';
-import { getUserImageData } from '@/helpers/user-image';
-import { SearchBilling } from '@/services/db/billing';
+import ExternalLink from '@/components/generic/button/ExternalLink';
+import KeyValueTable from '@/components/generic/KeyValueTable';
+import UserProfile from '@/components/users/UserProfile';
+import { getEmouFileName } from '@/helpers/emou';
+import { downloadBilling } from '@/services/backend/billing';
+import { BillingSearchResponseDataItem, BillingSearchResponseMetadata } from '@/types/billing';
import { formatDate } from '@/utils/js';
interface TableProps {
- data: SearchBilling[];
+ data: BillingSearchResponseDataItem[];
+ metadata: BillingSearchResponseMetadata;
+ session: Session;
}
-export default function TableBody({ data }: TableProps) {
- const methods = useForm({
- values: {
- billings: data,
- },
- });
+export default function TableBody({ data, metadata, session }: TableProps) {
+ const [downloading, setDownloading] = useState(false);
- const UniqueLicencePlates = ({
- projects,
- }: {
- projects: {
- licencePlate: string;
- }[];
- }) => {
- const uniquePlates = _uniq(projects.map((project) => project.licencePlate));
+ const rows =
+ data.length > 0 ? (
+ data.map((billing, index) => {
+ const associations = [
+ ...metadata.publicProducts.filter(({ billingId }) => billingId === billing.id),
+ ...metadata.publicRequests.filter(({ billingId }) => billingId === billing.id),
+ ];
- if (uniquePlates.length === 0) {
- return (
-
- No data
-
- );
- }
+ return (
+
+
+
- return (
-
- {uniquePlates.map((licencePlate) => (
-
- {licencePlate}
-
- ))}
-
- );
- };
+ {billing.accountCoding}
+
- const [billings] = methods.watch(['billings']);
+
+ {associations.map(({ id, name, url, type, context, licencePlate }) => {
+ return (
+
+
{name}
+
({licencePlate})
+ {type === 'request' && (
+
+ New
+
+ )}
+ {session.permissions.downloadBillingMou && (
+
+ )}
- const rows =
- billings.length > 0 ? (
- billings.map((billing, index) => (
-
- {/* Account coding */}
-
-
- Client code
-
-
- {billing.accountCoding.slice(0, 3)}
-
-
- Responsibility centre
-
-
- {billing.accountCoding.slice(3, 8)}
-
-
- Service line
-
-
- {billing.accountCoding.slice(8, 13)}
-
-
- Standard object of expense
-
-
- {billing.accountCoding.slice(13, 17)}
-
-
- Project code
-
-
- {billing.accountCoding.slice(17, 24)}
-
+
+
+ );
+ })}
+
-
- {billing.accountCoding}
-
-
+
+
- ))
+ {billing.approved && billing.approvedBy && (
+
+
+ Reviewed at {formatDate(billing.approvedAt)}; by
+
+
+
+ )}
+
+
+
+ );
+ })
) : (
@@ -192,9 +134,7 @@ export default function TableBody({ data }: TableProps) {
Account coding
Products and requests
- Dates
- Approved by
- Signed by
+ Status
{rows}
diff --git a/app/app/billing/all/page.tsx b/app/app/billing/all/page.tsx
index d18524400..1ff4e612d 100644
--- a/app/app/billing/all/page.tsx
+++ b/app/app/billing/all/page.tsx
@@ -7,7 +7,7 @@ import { GlobalPermissions } from '@/constants';
import { billingSorts } from '@/constants/billing';
import createClientPage from '@/core/client-page';
import { searchBilling, downloadBillings } from '@/services/backend/billing';
-import { SearchBilling } from '@/services/db/billing';
+import { BillingSearchResponseDataItem, BillingSearchResponseMetadata } from '@/types/billing';
import FilterPanel from './FilterPanel';
import { pageState } from './state';
import TableBody from './TableBody';
@@ -17,10 +17,11 @@ const billingPage = createClientPage({
fallbackUrl: 'login?callbackUrl=/home',
});
-export default billingPage(() => {
+export default billingPage(({ session }) => {
const snap = useSnapshot(pageState);
let totalCount = 0;
- let billings: SearchBilling[] = [];
+ let billings: BillingSearchResponseDataItem[] = [];
+ let metadata!: BillingSearchResponseMetadata;
const { data, isLoading } = useQuery({
queryKey: ['billings', snap],
@@ -30,6 +31,7 @@ export default billingPage(() => {
if (!isLoading && data) {
billings = data.data;
totalCount = data.totalCount;
+ metadata = data.metadata;
}
return (
@@ -60,7 +62,7 @@ export default billingPage(() => {
filters={}
isLoading={isLoading}
>
-
+
>
);
diff --git a/app/components/generic/KeyValueTable.tsx b/app/components/generic/KeyValueTable.tsx
index af0779647..05387727a 100644
--- a/app/components/generic/KeyValueTable.tsx
+++ b/app/components/generic/KeyValueTable.tsx
@@ -1,24 +1,26 @@
import _isEmpty from 'lodash-es/isEmpty';
import _isPlainObject from 'lodash-es/isPlainObject';
-export default function KeyValueTable({ data }: { data: any }) {
+export default function KeyValueTable({ data, showHeader = true }: { data: any; showHeader?: boolean }) {
if (!_isPlainObject(data)) return String(data);
if (_isEmpty(data)) return null;
return (
-
-
- Field |
- Value |
-
-
+ {showHeader && (
+
+
+ Field |
+ Value |
+
+
+ )}
{Object.entries(data).map(([key, value]) => (
{key} |
-
+
|
))}
diff --git a/app/helpers/emou.ts b/app/helpers/emou.ts
index 56ec2b3ae..2362c8864 100644
--- a/app/helpers/emou.ts
+++ b/app/helpers/emou.ts
@@ -1,6 +1,6 @@
import { Provider } from '@prisma/client';
-export function getEmouFileName(productName: string, provider: Provider) {
- const isAWS = provider === Provider.AWS || provider === Provider.AWS_LZA;
+export function getEmouFileName(productName: string, context: string) {
+ const isAWS = context === Provider.AWS || context === Provider.AWS_LZA;
return `OCIO and ${productName} - ${isAWS ? 'AWS' : 'Microsoft Azure'} MOU.pdf`;
}
diff --git a/app/services/backend/billing.ts b/app/services/backend/billing.ts
index cb49311d4..80329c756 100644
--- a/app/services/backend/billing.ts
+++ b/app/services/backend/billing.ts
@@ -1,7 +1,6 @@
import axios from 'axios';
import { billingSorts } from '@/constants/billing';
-import { SearchBilling } from '@/services/db/billing';
-import { BillingGetPayload } from '@/types/billing';
+import { BillingGetPayload, BillingSearchResponsePayload } from '@/types/billing';
import { downloadFile } from '@/utils/browser';
import { BillingSearchBody } from '@/validation-schemas/billing';
import { instance as baseInstance } from './axios';
@@ -56,7 +55,7 @@ export async function downloadBilling(
export async function searchBilling(data: BillingSearchBody) {
const reqData = prepareSearchPayload(data);
- const result = await instance.post<{ data: SearchBilling[]; totalCount: number }>('search', reqData);
+ const result = await instance.post('search', reqData);
return result.data;
}
diff --git a/app/services/db/billing.ts b/app/services/db/billing.ts
index 4b4a08380..396e9b696 100644
--- a/app/services/db/billing.ts
+++ b/app/services/db/billing.ts
@@ -2,64 +2,10 @@ import { Prisma } from '@prisma/client';
import _isNumber from 'lodash-es/isNumber';
import prisma from '@/core/prisma';
import { parsePaginationParams } from '@/helpers/pagination';
+import { BillingSearchResponseDataItem } from '@/types/billing';
import { BillingSearchBody } from '@/validation-schemas/billing';
-const defaultSortKey = 'createdAt';
-export type SearchBilling = Prisma.BillingGetPayload<{
- select: {
- id: true;
- accountCoding: true;
- licencePlate: true;
- signed: true;
- approved: true;
- createdAt: true;
- signedAt: true;
- approvedAt: true;
- updatedAt: true;
- approvedBy: {
- select: {
- firstName: true;
- lastName: true;
- email: true;
- jobTitle: true;
- image: true;
- ministry: true;
- };
- };
- expenseAuthority: {
- select: {
- firstName: true;
- lastName: true;
- email: true;
- jobTitle: true;
- image: true;
- ministry: true;
- };
- };
- signedBy: {
- select: {
- firstName: true;
- lastName: true;
- email: true;
- jobTitle: true;
- image: true;
- ministry: true;
- };
- };
- publicCloudProjects: {
- select: {
- licencePlate: true;
- provider: true;
- };
- };
- publicCloudRequestedProjects: {
- select: {
- licencePlate: true;
- provider: true;
- };
- };
- };
-}>;
+const defaultSortKey = 'createdAt';
export async function searchBilling({
billings = [],
@@ -73,7 +19,7 @@ export async function searchBilling({
}: BillingSearchBody & {
skip?: number;
take?: number;
-}): Promise<{ data: SearchBilling[]; totalCount: number }> {
+}): Promise<{ data: BillingSearchResponseDataItem[]; totalCount: number }> {
const isBillingSearch = billings.length > 0;
if (!_isNumber(skip) && !_isNumber(take) && page && pageSize) {
({ skip, take } = parsePaginationParams(page, pageSize, 10));
@@ -171,18 +117,6 @@ export async function searchBilling({
ministry: true,
},
},
- publicCloudProjects: {
- select: {
- licencePlate: true,
- provider: true,
- },
- },
- publicCloudRequestedProjects: {
- select: {
- licencePlate: true,
- provider: true,
- },
- },
},
}),
prisma.billing.count({ where: filters }),
diff --git a/app/types/billing.ts b/app/types/billing.ts
index 2128f6342..5257f678b 100644
--- a/app/types/billing.ts
+++ b/app/types/billing.ts
@@ -1,4 +1,5 @@
import { Prisma } from '@prisma/client';
+import { number } from 'zod';
export type BillingGetPayload = Prisma.BillingGetPayload<{
include: {
@@ -7,3 +8,68 @@ export type BillingGetPayload = Prisma.BillingGetPayload<{
approvedBy: true;
};
}>;
+
+export type BillingSearchResponseDataItem = Prisma.BillingGetPayload<{
+ select: {
+ id: true;
+ accountCoding: true;
+ licencePlate: true;
+ signed: true;
+ approved: true;
+ createdAt: true;
+ signedAt: true;
+ approvedAt: true;
+ updatedAt: true;
+ approvedBy: {
+ select: {
+ firstName: true;
+ lastName: true;
+ email: true;
+ jobTitle: true;
+ image: true;
+ ministry: true;
+ };
+ };
+ expenseAuthority: {
+ select: {
+ firstName: true;
+ lastName: true;
+ email: true;
+ jobTitle: true;
+ image: true;
+ ministry: true;
+ };
+ };
+ signedBy: {
+ select: {
+ firstName: true;
+ lastName: true;
+ email: true;
+ jobTitle: true;
+ image: true;
+ ministry: true;
+ };
+ };
+ };
+}>;
+
+interface BillingSearchResponseMetadataItem {
+ id: string;
+ licencePlate: string;
+ name: string;
+ url: string;
+ type: 'product' | 'request';
+ context: string;
+ billingId?: string | null;
+}
+
+export interface BillingSearchResponseMetadata {
+ publicProducts: BillingSearchResponseMetadataItem[];
+ publicRequests: BillingSearchResponseMetadataItem[];
+}
+
+export interface BillingSearchResponsePayload {
+ data: BillingSearchResponseDataItem[];
+ totalCount: number;
+ metadata: BillingSearchResponseMetadata;
+}