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} - - + +
    +
  • +
    + Created at {formatDate(billing.createdAt)} +
    +
  • - {/* products and requests */} - - - Initial licence plate - - - {billing.licencePlate} - - - Requested products - - - - Products - - - + {billing.signed && billing.signedBy && ( +
  • +
    + Signed at {formatDate(billing.signedAt)}; by +
    + +
  • + )} - {/* Dates */} - - - Created at - - - {formatDate(billing.createdAt)} - - - Updated at - - - {formatDate(billing.updatedAt)} - - - - {/* Approved By */} - - {billing.approved ? ( - - -
    - - {formatFullName(billing.approvedBy)} - - - - {billing.approvedBy?.email} - - - At: {formatDate(billing.approvedAt)} - -
    -
    - ) : ( - - No data - - )} -
    - {/* Signed By */} - - {billing.signed ? ( - - -
    - - {formatFullName(billing.signedBy)} - - - - {billing.signedBy?.email} - - - At: {formatDate(billing.signedAt)} - -
    -
    - ) : ( - - No data - - )} -
    - - )) + {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 ( - - - - - - + {showHeader && ( + + + + + + + )} {Object.entries(data).map(([key, value]) => ( ))} 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; +}
FieldValue
FieldValue
{key} - +