From 4c36abf327c309862c02997452f44901e1029f7e Mon Sep 17 00:00:00 2001 From: Santiago Sanz Date: Fri, 2 Sep 2022 14:14:01 +0200 Subject: [PATCH 1/5] feat: export module --- graphql/schema.graphql | 4 + graphql/types/ExportReturnRequests.graphql | 57 ++++ node/clients/index.ts | 16 + node/clients/report.ts | 42 +++ node/report/json_return-request-map.ts | 314 ++++++++++++++++++ node/resolvers/exportReturnRequests.ts | 16 + node/resolvers/exportStatus.ts | 11 + node/resolvers/index.ts | 4 + node/services/exportReturnRequestsService.ts | 67 ++++ node/services/exportStatusService.ts | 56 ++++ node/typings/report.d.ts | 89 +++++ node/utils/constants.ts | 1 + node/utils/createReportConfig.ts | 60 ++++ node/utils/dateHelpers.ts | 19 ++ node/utils/verifyReportMap.ts | 25 ++ react/admin/ExportReturnRequestsModal.tsx | 3 + .../ExportModule.tsx | 56 ++++ .../components/Content.tsx | 45 +++ .../components/ReportContainer.tsx | 27 ++ .../components/ReportForm.tsx | 170 ++++++++++ .../components/ReportLoader.tsx | 55 +++ .../components/ReportStatus.tsx | 56 ++++ .../components/TagStatus.tsx | 41 +++ .../graphql/exportReturnRequests.gql | 8 + .../graphql/exportStatusFragment.gql | 11 + .../graphql/getExportStatus.gql | 7 + .../hooks/useExportModule.ts | 5 + .../provider/ExportProvider.tsx | 141 ++++++++ .../admin/ReturnList/ReturnListContainer.tsx | 5 +- react/common/constants/returnsRequest.ts | 6 + react/typings/vtex.return-app.d.ts | 9 + react/utils/createExportFilters.ts | 18 + 32 files changed, 1443 insertions(+), 1 deletion(-) create mode 100644 graphql/types/ExportReturnRequests.graphql create mode 100644 node/clients/report.ts create mode 100644 node/report/json_return-request-map.ts create mode 100644 node/resolvers/exportReturnRequests.ts create mode 100644 node/resolvers/exportStatus.ts create mode 100644 node/services/exportReturnRequestsService.ts create mode 100644 node/services/exportStatusService.ts create mode 100644 node/typings/report.d.ts create mode 100644 node/utils/createReportConfig.ts create mode 100644 node/utils/verifyReportMap.ts create mode 100644 react/admin/ExportReturnRequestsModal.tsx create mode 100644 react/admin/ExportReturnRequestsModal/ExportModule.tsx create mode 100644 react/admin/ExportReturnRequestsModal/components/Content.tsx create mode 100644 react/admin/ExportReturnRequestsModal/components/ReportContainer.tsx create mode 100644 react/admin/ExportReturnRequestsModal/components/ReportForm.tsx create mode 100644 react/admin/ExportReturnRequestsModal/components/ReportLoader.tsx create mode 100644 react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx create mode 100644 react/admin/ExportReturnRequestsModal/components/TagStatus.tsx create mode 100644 react/admin/ExportReturnRequestsModal/graphql/exportReturnRequests.gql create mode 100644 react/admin/ExportReturnRequestsModal/graphql/exportStatusFragment.gql create mode 100644 react/admin/ExportReturnRequestsModal/graphql/getExportStatus.gql create mode 100644 react/admin/ExportReturnRequestsModal/hooks/useExportModule.ts create mode 100644 react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx create mode 100644 react/utils/createExportFilters.ts diff --git a/graphql/schema.graphql b/graphql/schema.graphql index a99d4aafb..0539cd1a6 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -30,6 +30,7 @@ type Query { @withUserProfile @cacheControl(scope: PRIVATE, maxAge: SHORT) nearestPickupPoints(lat: String!, long: String!): NearPickupPointQueryResponse + exportStatus: ExportReportData @cacheControl(scope: PRIVATE, maxAge: ZERO) } type Mutation { @@ -42,4 +43,7 @@ type Mutation { comment: ReturnRequestCommentInput refundData: RefundDataInput ): ReturnRequestResponse @withUserProfile + exportReturnRequests( + exportData: ExportReturnRequestsInput! + ): ExportReportData @withUserProfile } diff --git a/graphql/types/ExportReturnRequests.graphql b/graphql/types/ExportReturnRequests.graphql new file mode 100644 index 000000000..dafc3ab82 --- /dev/null +++ b/graphql/types/ExportReturnRequests.graphql @@ -0,0 +1,57 @@ +type ExportReportData { + id: String + inProgress: Boolean + percentageProcessed: Float + requestedBy: String + completedDate: String + """ + Masterdata _WHERE filter + """ + selectedFilters: String + downloadLink: String + """ + Download link expires 6 hours after completed + """ + staleLink: Boolean + lastErrorMessage: String +} + +input ExportReturnRequestsInput { + fileFormat: ExportFormatInput! + """ + Masterdata _WHERE filter + """ + documentsFilter: String + """ + If not provided, the file will only be accesible via export status download link + """ + deliveryConfiguration: DeliveryConfigurationData +} + +enum ExportFormatInput { + XLSX + """ + UNTESTED + """ + CSV + """ + UNTESTED + """ + JSON +} + +input DeliveryConfigurationData { + type: DeliveryTypeInput! + value: String! +} + +enum DeliveryTypeInput { + """ + Send file to endpoint + """ + ENDPOINT + """ + Send file to email + """ + EMAIL +} diff --git a/node/clients/index.ts b/node/clients/index.ts index 2a1a2d876..fd2145252 100644 --- a/node/clients/index.ts +++ b/node/clients/index.ts @@ -1,6 +1,7 @@ import { IOClients, Sphinx } from '@vtex/api' import { vbaseFor, masterDataFor } from '@vtex/clients' import { ReturnAppSettings, ReturnRequest } from 'vtex.return-app' +import type { ExportReportData } from 'vtex.return-app' import { Catalog } from './catalog' import { OMSCustom as OMS } from './oms' @@ -9,8 +10,15 @@ import { MailClient } from './mail' import Checkout from './checkout' import { VtexId } from './vtexId' import { CatalogGQL } from './catalogGQL' +import { ReturnRequestReport } from './report' + +type ExportReportDataVBase = Pick< + ExportReportData, + 'id' | 'selectedFilters' | 'requestedBy' +> const ReturnAppSettings = vbaseFor('appSettings') +const ExportReport = vbaseFor('exportReport') const ReturnRequest = masterDataFor('returnRequest') export class Clients extends IOClients { @@ -22,6 +30,10 @@ export class Clients extends IOClients { return this.getOrSet('appSettings', ReturnAppSettings) } + public get exportReport() { + return this.getOrSet('exportReport', ExportReport) + } + public get catalog() { return this.getOrSet('catalog', Catalog) } @@ -34,6 +46,10 @@ export class Clients extends IOClients { return this.getOrSet('returnRequest', ReturnRequest) } + public get report() { + return this.getOrSet('report', ReturnRequestReport) + } + public get giftCard() { return this.getOrSet('giftCard', GiftCard) } diff --git a/node/clients/report.ts b/node/clients/report.ts new file mode 100644 index 000000000..f5f05cb2b --- /dev/null +++ b/node/clients/report.ts @@ -0,0 +1,42 @@ +import type { InstanceOptions, IOContext } from '@vtex/api' +import { JanusClient } from '@vtex/api' + +const baseURL = '/api/report' + +const routes = { + masterdata: `${baseURL}/masterdata`, + inProgress: `${baseURL}/inprogress`, + report: (id: string) => `${baseURL}/${id}`, + map: (id: string) => `${baseURL}/map/${id}`, +} + +/** + * API used to create a complete report of a masterdata entity + */ +export class ReturnRequestReport extends JanusClient { + constructor(context: IOContext, options?: InstanceOptions) { + super(context, { + ...options, + headers: { + VtexIdclientAutCookie: context.authToken, + ...(options?.headers ?? {}), + }, + }) + } + + public getMap = (mapId: string) => this.http.get(routes.map(mapId)) + + public createOrUpdateMap = (map: ReportMap) => + this.http.put(routes.map(map.id), map) + + public deleteMap = (mapId: string) => this.http.delete(routes.map(mapId)) + + public inProgressReports = () => + this.http.get(routes.inProgress) + + public getReport = (reportId: string) => + this.http.get(routes.report(reportId)) + + public generateReport = (reportConfig: MasterdataReportsConfig) => + this.http.post(routes.masterdata, reportConfig) +} diff --git a/node/report/json_return-request-map.ts b/node/report/json_return-request-map.ts new file mode 100644 index 000000000..dff381a7e --- /dev/null +++ b/node/report/json_return-request-map.ts @@ -0,0 +1,314 @@ +export const localMap: ReportMap = { + id: '7a42a323-1faa-11ed-835d-16acdede38c5', + name: 'Return Requests', + path: 'items', + domain: null, + skipRecordOnError: false, + isGlobal: false, + columns: [ + { + header: 'status', + query: 'status', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'order_id', + query: 'orderId', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sequence_number', + query: 'sequenceNumber', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'date_submitted', + query: 'dateSubmitted', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount', + query: 'refundableAmount', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount_items', + query: "refundableAmountTotals[id='items'].value", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount_shipping', + query: "refundableAmountTotals[id='shipping'].value", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount_tax', + query: "refundableAmountTotals[id='tax'].value", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_profile_data_id', + query: 'customerProfileData.userId', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_profile_data_name', + query: 'customerProfileData.name', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_profile_data_email', + query: 'customerProfileData.email', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_profile_data_phone', + query: 'customerProfileData.phoneNumber', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_id', + query: 'pickupReturnData.addressId', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_address', + query: 'pickupReturnData.address', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_city', + query: 'pickupReturnData.city', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_state', + query: 'pickupReturnData.state', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_country', + query: 'pickupReturnData.country', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_zip_code', + query: 'pickupReturnData.zipCode', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_type', + query: 'pickupReturnData.addressType', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_data_return_label', + query: "pickupReturnData.( returnLabel ? returnLabel : 'NA' )", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_data_payment_method', + query: 'refundPaymentData.refundPaymentMethod', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_data_payment_iban', + query: "refundPaymentData.( iban ? iban : 'NA' )", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_data_payment_account_holder_name', + query: + "refundPaymentData.( accountHolderName ? accountHolderName : 'NA' )", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_data_payment_automatic_refund', + query: + "refundPaymentData.( automaticallyRefundPaymentMethod ? automaticallyRefundPaymentMethod : 'NA' )", + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'culture_info_data_currency_code', + query: 'cultureInfoData.currencyCode', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'culture_info_data_currency_locale', + query: 'cultureInfoData.locale', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_status_new', + query: + "($a := refundStatusData.[status = 'new'].Product; $a ? $a : 'NA')", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_order_item_index', + query: 'orderItemIndex', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_id', + query: 'id', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_product_id', + query: 'productId', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_ref_id', + query: 'refId', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_name', + query: 'name', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_localized_name', + query: "( localizedName ? localizedName : 'NA' )", + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_image', + query: 'imageUrl', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_seller_id', + query: 'sellerId', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_seller_name', + query: 'sellerName', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_quantity', + query: 'quantity', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_unit_multiplier', + query: 'unitMultiplier', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_condition', + query: 'condition', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_selling_price', + query: 'sellingPrice', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_tax', + query: 'tax', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_return_reason', + query: 'returnReason.reason', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_other_return_reson', + query: "returnReason.( otherReason ? otherReason : 'NA' )", + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + ], +} diff --git a/node/resolvers/exportReturnRequests.ts b/node/resolvers/exportReturnRequests.ts new file mode 100644 index 000000000..a7ca4e78f --- /dev/null +++ b/node/resolvers/exportReturnRequests.ts @@ -0,0 +1,16 @@ +import type { + MutationExportReturnRequestsArgs, + ExportReportData, +} from 'vtex.return-app' + +import { exportReturnRequestsService } from '../services/exportReturnRequestsService' + +export const exportReturnRequests = async ( + _: unknown, + args: MutationExportReturnRequestsArgs, + ctx: Context +): Promise => { + const { exportData } = args + + return exportReturnRequestsService(ctx, exportData) +} diff --git a/node/resolvers/exportStatus.ts b/node/resolvers/exportStatus.ts new file mode 100644 index 000000000..2605cc654 --- /dev/null +++ b/node/resolvers/exportStatus.ts @@ -0,0 +1,11 @@ +import type { ExportReportData } from 'vtex.return-app' + +import { exportStatusService } from '../services/exportStatusService' + +export const exportStatus = async ( + _: unknown, + __: unknown, + ctx: Context +): Promise => { + return exportStatusService(ctx) +} diff --git a/node/resolvers/index.ts b/node/resolvers/index.ts index e462ffcbf..7e65cdb91 100644 --- a/node/resolvers/index.ts +++ b/node/resolvers/index.ts @@ -11,10 +11,13 @@ import { returnRequestList } from './returnRequestList' import { ReturnRequestResponse } from './ReturnRequestResponse' import { updateReturnRequestStatus } from './updateReturnRequestStatus' import { nearestPickupPoints } from './nearestPickupPoints' +import { exportStatus } from './exportStatus' +import { exportReturnRequests } from './exportReturnRequests' export const mutations = { createReturnRequest, updateReturnRequestStatus, + exportReturnRequests, ...settingsMutation, } @@ -26,6 +29,7 @@ export const queries = { returnRequest, returnRequestList, nearestPickupPoints, + exportStatus, } export const resolvers = { ReturnRequestResponse } diff --git a/node/services/exportReturnRequestsService.ts b/node/services/exportReturnRequestsService.ts new file mode 100644 index 000000000..070854278 --- /dev/null +++ b/node/services/exportReturnRequestsService.ts @@ -0,0 +1,67 @@ +import { ResolverError, UserInputError } from '@vtex/api' +import type { + ExportReturnRequestsInput, + ExportReportData, +} from 'vtex.return-app' + +import { verifyReportMap } from '../utils/verifyReportMap' +import { EXPORT_DATA_PATH } from '../utils/constants' +import { createReportConfig } from '../utils/createReportConfig' + +export const exportReturnRequestsService = async ( + ctx: Context, + args: ExportReturnRequestsInput +): Promise => { + const { + state: { userProfile }, + clients: { report: returnRequestReport, exportReport }, + } = ctx + + if (!args.fileFormat || args.fileFormat !== 'XLSX') { + throw new UserInputError( + 'File format not supported or specified. Supported formats: XLSX' + ) + } + + /* If the MAP is missing/outdated we update it from our local map found in ../report */ + await verifyReportMap(returnRequestReport) + + /* Creates the Report api body needed */ + const reportConfig = createReportConfig(ctx, args) + + try { + const report = await returnRequestReport.generateReport(reportConfig) + const { + id, + finished, + linkToDownload, + lastErrorMessage, + percentageProcessed, + completedDate, + } = report + + const documentsFilter = args.documentsFilter ?? null + + await exportReport.save(EXPORT_DATA_PATH, { + id, + selectedFilters: documentsFilter, + requestedBy: userProfile?.email ?? null, + }) + + return { + id, + inProgress: !finished, + percentageProcessed, + requestedBy: userProfile?.email ?? null, + completedDate, + selectedFilters: documentsFilter, + downloadLink: linkToDownload, + staleLink: false, + lastErrorMessage, + } + } catch (error) { + throw new ResolverError( + `An unexpected error ocurred while requesting a new report: ${error.message}` + ) + } +} diff --git a/node/services/exportStatusService.ts b/node/services/exportStatusService.ts new file mode 100644 index 000000000..f702b6924 --- /dev/null +++ b/node/services/exportStatusService.ts @@ -0,0 +1,56 @@ +import { ResolverError } from '@vtex/api' +import type { ExportReportData } from 'vtex.return-app' + +import { EXPORT_DATA_PATH } from '../utils/constants' +import { isReportStale } from '../utils/dateHelpers' + +export const exportStatusService = async ( + ctx: Context +): Promise => { + const { + clients: { report: returnRequestReport, exportReport }, + } = ctx + + const reportReference = await exportReport.get(EXPORT_DATA_PATH, true) + + if (!reportReference?.id) { + return null + } + + const { id, selectedFilters, requestedBy } = reportReference + + try { + const report = await returnRequestReport.getReport(id) + const { + finished, + linkToDownload, + completedDate, + lastErrorMessage, + percentageProcessed, + } = report + + /** + * @bug + * A report can be in progress but completed and available. + * The reason is unknown, so we still allow the user to download the file + */ + const reportCompleted = + Boolean(linkToDownload) && percentageProcessed === 100 + + return { + id, + inProgress: !reportCompleted ?? finished === false, + percentageProcessed, + requestedBy: requestedBy ?? 'unknown', + completedDate, + selectedFilters: selectedFilters ?? null, + downloadLink: linkToDownload, + staleLink: linkToDownload ? isReportStale(completedDate) : null, + lastErrorMessage, + } + } catch (error) { + throw new ResolverError( + `An unexpected error ocurred while requesting export status: ${error.message}` + ) + } +} diff --git a/node/typings/report.d.ts b/node/typings/report.d.ts new file mode 100644 index 000000000..e8d0935d2 --- /dev/null +++ b/node/typings/report.d.ts @@ -0,0 +1,89 @@ +interface Report { + canceled: boolean + completedDate: string | null + email: string + enqueueDate: string + finished: boolean + id: string + lastUpdateTime: string | null + linkToDownload: string | null + outputType: string + recordsProcessed: number | null + percentageProcessed: number + recordsSum: number | null + startDate: string | null + zipped: boolean + lastErrorMessage: string | null + deliveredDate: string | null + language: string | null + utcTime: string | null + deliveryConfig: DeliveryConfig +} + +interface ReportField { + header: string + query: string + usePath: boolean + translationPrefix: string | null + defaultLanguage: string | null +} + +interface ReportMap { + id: string + isGlobal: boolean + path: string + name: string + skipRecordOnError: boolean + domain: string | null + columns: ReportField[] +} + +interface DeliveryEndpoint { + endpoint: string + type: 'Endpoint' +} + +interface DeliveryEmail { + templateName: string + email: string + /* Not a typo */ + type: 'EMail' +} + +interface DeliveryNone { + type: 'None' +} + +type ReportOutput = 'XLSX' | 'CSV' | 'JSON' + +type DeliveryConfig = DeliveryNone | DeliveryEmail | DeliveryEndpoint + +interface MasterdataReportsConfig { + mapId: string + mapIds: string[] + where: string + outputType: ReportOutput + zipped: boolean + entityName: string + schema: string + queryAllStores: boolean + deliveryConfig: DeliveryConfig +} + +interface MasterdataReportsResponse { + canceled: boolean + completedDate: string + email: string + enqueueDate: string + finished: boolean + id: string + lastUpdateTime: string + linkToDownload: string + outputType: string + percentageProcessed: number + recordsSum: number + startDate: string + zipped: boolean + lastErrorMessage: string + deliveryConfig: DeliveryConfig +} diff --git a/node/utils/constants.ts b/node/utils/constants.ts index 6ddfc5ddd..f3b10d810 100644 --- a/node/utils/constants.ts +++ b/node/utils/constants.ts @@ -6,6 +6,7 @@ import type { } from '../typings/mailClient' export const SETTINGS_PATH = 'app-settings' +export const EXPORT_DATA_PATH = 'export-data' export const ORDER_TO_RETURN_VALIDATON: Record< OrderToReturnValidation, diff --git a/node/utils/createReportConfig.ts b/node/utils/createReportConfig.ts new file mode 100644 index 000000000..965fa44ef --- /dev/null +++ b/node/utils/createReportConfig.ts @@ -0,0 +1,60 @@ +import { parseAppId } from '@vtex/api' +import type { ExportReturnRequestsInput } from 'vtex.return-app' + +import { localMap } from '../report/json_return-request-map' + +const BASE_CONFIG = { + DELIVERY: { + /* Not a typo */ + EMAIL: 'EMail', + NONE: 'None', + ENDPOINT: 'Endpoint', + }, + ENTITY: 'vtex_return_app_returnRequest', + FORMAT: { + XLSX: 'XLSX', + JSON: 'JSON', + CSV: 'CSV', + }, + TEMPLATE: 'report-report-finished', +} as const + +const versionDescriptor = (isProduction: boolean, workspace: string) => + isProduction ? '' : `-${workspace}` + +/** + * Creates the data required for the masterdata report api + * @info For the moment, we only support 'no delivery' and 'email' + * as delivery options; but can be upgraded to suppport an endpoint call + */ +export const createReportConfig = ( + ctx: Context, + args: ExportReturnRequestsInput +): MasterdataReportsConfig => { + const { + vtex: { production, workspace }, + } = ctx + + const { fileFormat, documentsFilter, deliveryConfiguration } = args + + const app = parseAppId(process.env.VTEX_APP_ID as string) + const schema = `${app.version}${versionDescriptor(production, workspace)}` + + return { + mapId: localMap.id, + mapIds: [localMap.id], + outputType: fileFormat ?? BASE_CONFIG.FORMAT.XLSX, + zipped: true, + where: documentsFilter ?? '', + entityName: BASE_CONFIG.ENTITY, + schema, + queryAllStores: true, + deliveryConfig: deliveryConfiguration + ? { + type: BASE_CONFIG.DELIVERY.EMAIL, + templateName: BASE_CONFIG.TEMPLATE, + email: deliveryConfiguration.value, + } + : { type: BASE_CONFIG.DELIVERY.NONE }, + } +} diff --git a/node/utils/dateHelpers.ts b/node/utils/dateHelpers.ts index f197622f9..bffd80372 100644 --- a/node/utils/dateHelpers.ts +++ b/node/utils/dateHelpers.ts @@ -19,3 +19,22 @@ export const isWithinMaxDaysToReturn = ( return new Date(orderCreationDate) > new Date(limitDateToReturn) } + +/** + * Compares a given ISO date vs today + * @returns if the report download link expired after 6 hours + */ +export const isReportStale = (completedDate: string | null) => { + if (!completedDate) return null + + const today = new Date() + const completed = new Date(completedDate) + + const difference = (today.getTime() - completed.getTime()) / 3600000 + + if (difference > 6) { + return true + } + + return false +} diff --git a/node/utils/verifyReportMap.ts b/node/utils/verifyReportMap.ts new file mode 100644 index 000000000..fd33a748b --- /dev/null +++ b/node/utils/verifyReportMap.ts @@ -0,0 +1,25 @@ +import assert from 'assert' + +import { ResolverError } from '@vtex/api' + +import { localMap } from '../report/json_return-request-map' +import type { ReturnRequestReport } from '../clients/report' + +export const verifyReportMap = async ( + reportClient: ReturnRequestReport +): Promise => { + try { + const remoteMap = await reportClient.getMap(localMap.id) + + /* Tests for deep equality, throws error with a custom message if it fails */ + assert.deepStrictEqual(remoteMap, localMap, 'unequal-maps') + } catch (error) { + if (error.message !== 'unequal-maps' && error.response.status !== 404) { + throw new ResolverError( + `An error ocurred while verifying if the local report map differs from to the remote one: ${error.message}` + ) + } + + await reportClient.createOrUpdateMap(localMap) + } +} diff --git a/react/admin/ExportReturnRequestsModal.tsx b/react/admin/ExportReturnRequestsModal.tsx new file mode 100644 index 000000000..3f87d2fd5 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal.tsx @@ -0,0 +1,3 @@ +import ExportReturnRequestsModal from './ExportReturnRequestsModal/ExportModule' + +export default ExportReturnRequestsModal diff --git a/react/admin/ExportReturnRequestsModal/ExportModule.tsx b/react/admin/ExportReturnRequestsModal/ExportModule.tsx new file mode 100644 index 000000000..1d7305f1c --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/ExportModule.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { + Button, + EXPERIMENTAL_Modal as Modal, + ButtonWithIcon, + utils, + IconDownload, +} from 'vtex.styleguide' + +import { ExportProvider } from './provider/ExportProvider' +import Content from './components/Content' +import ReportContainer from './components/ReportContainer' + +const ExportModule = () => { + const { isOpen, onOpen, onClose } = utils.useDisclosure() + + return ( + <> + } + iconPosition="left" + variation="primary" + onClick={onOpen} + > + EXPORT RETURNS + + + + + + } + > +
+
+ +
+
+ + + +
+
+
+ + ) +} + +export default ExportModule diff --git a/react/admin/ExportReturnRequestsModal/components/Content.tsx b/react/admin/ExportReturnRequestsModal/components/Content.tsx new file mode 100644 index 000000000..a1b676905 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/Content.tsx @@ -0,0 +1,45 @@ +import React from 'react' + +import { SUPPORTED_REPORT_FORMATS } from '../../../common/constants/returnsRequest' + +const Content = () => { + return ( + <> +

+ The Export module is the system responsible for merging all return + requests into a single file called{' '} + report, this can be then sent to + an email or downloaded directly from this module +

+

+ Keep in mind that download links are not permanent, they expire after{' '} + 6 hours following their + availability +

+
+ It is important to remember that depending on the quantity of return + requests, the process can take a long time to finish. If you chose to + generate a download link, you can leave the module open and it will + update when its available for download +
+

+ Supported formats:  + {SUPPORTED_REPORT_FORMATS.map((format) => `.${format} `)} +

+

+ Documents containing errors will be skipped +

+ + ) +} + +export default Content diff --git a/react/admin/ExportReturnRequestsModal/components/ReportContainer.tsx b/react/admin/ExportReturnRequestsModal/components/ReportContainer.tsx new file mode 100644 index 000000000..7cf42023b --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportContainer.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { Divider } from 'vtex.styleguide' + +import ReportStatus from './ReportStatus' +import ReportForm from './ReportForm' +import { useExportModule } from '../hooks/useExportModule' +import ReportLoader from './ReportLoader' + +const ReportContainer = () => { + const { loadingStatus } = useExportModule() + + if (loadingStatus) { + return + } + + return ( + <> + +
+ +
+ + + ) +} + +export default ReportContainer diff --git a/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx b/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx new file mode 100644 index 000000000..e368dfe2d --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx @@ -0,0 +1,170 @@ +import React, { useState } from 'react' +import type { FormEvent } from 'react' +import { + Button, + DatePicker, + Input, + IconUser, + Dropdown, + Toggle, +} from 'vtex.styleguide' +import { FormattedMessage } from 'react-intl' +import type { ExportReturnRequestsInput } from 'vtex.return-app' + +import { useExportModule } from '../hooks/useExportModule' +import { SUPPORTED_REPORT_FORMATS } from '../../../common/constants/returnsRequest' +import { createExportFilters } from '../../../utils/createExportFilters' + +const supportedOptions = SUPPORTED_REPORT_FORMATS.map((format) => { + return { value: format, label: format } +}) + +const initialFilters = { + from: '', + to: '', +} as ExportDateFilters + +type SupportedFilters = 'from' | 'to' | 'email' + +const ReportForm = () => { + const [exportAll, setExportPreference] = useState(true) + const [dateFilters, setFilters] = useState(initialFilters) + const [sendEmail, setEmailPreference] = useState(false) + const [recipientEmail, setRecipientEmail] = useState('') + + const { exportStatus, _handleExport, submitInProgress } = useExportModule() + + const { inProgress, lastErrorMessage } = exportStatus ?? {} + + const retryAvailable = inProgress && lastErrorMessage + + const fromDate = dateFilters.from ? new Date(dateFilters.from) : '' + const toDate = dateFilters.to ? new Date(dateFilters.to) : '' + + const missingInputs = + (!exportAll && !fromDate) || (sendEmail && !recipientEmail) + + const handleOnChange = (key: SupportedFilters, value: string) => { + if (key === 'to' || key === 'from') { + const filterDates = { + ...dateFilters, + [key]: value, + } + + if (!filterDates.to) { + filterDates.to = new Date().toISOString() + } + + if (!filterDates.from) { + filterDates.from = new Date(filterDates.to).toISOString() + } + + setFilters({ + ...filterDates, + }) + } + + if (key === 'email') { + setRecipientEmail(value) + } + } + + const handleSubmit = () => { + const exportData = { + fileFormat: 'XLSX', + documentsFilter: exportAll + ? null + : createExportFilters('dates', dateFilters), + deliveryConfiguration: sendEmail + ? { + type: 'EMAIL', + value: recipientEmail, + } + : null, + } as ExportReturnRequestsInput + + _handleExport(exportData) + } + + return ( + <> +

New report

+
+ +
+ setExportPreference(!exportAll)} + /> +
+ + {(formattedMessage) => ( + + handleOnChange('from', new Date(date).toISOString()) + } + value={fromDate} + disabled={exportAll} + /> + )} + +
+
+ + {(formattedMessage) => ( + + handleOnChange('to', new Date(date).toISOString()) + } + value={toDate} + disabled={exportAll} + /> + )} + +
+ setEmailPreference(!sendEmail)} + /> +
+ } + disabled={!sendEmail} + onChange={(e: FormEvent) => + handleOnChange('email', e.currentTarget.value) + } + /> +
+
+ +
+ + ) +} + +export default ReportForm diff --git a/react/admin/ExportReturnRequestsModal/components/ReportLoader.tsx b/react/admin/ExportReturnRequestsModal/components/ReportLoader.tsx new file mode 100644 index 000000000..8e97b01a9 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportLoader.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { SkeletonPiece } from 'vtex.my-account-commons' +import { Divider } from 'vtex.styleguide' + +const ReportLoader = () => { + return ( + <> +
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) +} + +export default ReportLoader diff --git a/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx new file mode 100644 index 000000000..b14d162b6 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { ButtonPlain, IconExternalLink } from 'vtex.styleguide' + +import { useExportModule } from '../hooks/useExportModule' +import TagStatus from './TagStatus' + +const ReportStatus = () => { + const { exportStatus } = useExportModule() + + const { + inProgress, + requestedBy, + completedDate, + selectedFilters, + downloadLink, + staleLink, + } = exportStatus ?? {} + + return ( + <> +

Last report

+
+

+ + Download link  + + +

+

+ +

+
+

+ Requested by: + {requestedBy ?? 'N/A'} +

+

+ Start date: + + {completedDate ? new Date(completedDate).toLocaleString() : 'N/A'} + +

+

+ Filter range: + {selectedFilters ?? 'N/A'} +

+ + ) +} + +export default ReportStatus diff --git a/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx b/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx new file mode 100644 index 000000000..bc4c2025d --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { Tag, Spinner } from 'vtex.styleguide' + +import { useExportModule } from '../hooks/useExportModule' +import { TAG_STATUSES } from '../provider/ExportProvider' + +const TagStatus = () => { + const { exportStatus, tagStatus } = useExportModule() + + const { percentageProcessed } = exportStatus ?? {} + + /* Parts of the report api flow are the 'Delivery' and 'Merge' stages. + This can represent 100% records processed, but not that the report is finished. + Representing 100% in the UI is not intuitive */ + const maxPercievedPercentage = + Boolean(percentageProcessed) && percentageProcessed === 100 + ? 99 + : percentageProcessed + + switch (tagStatus) { + case TAG_STATUSES.ERROR: + return Error, please try again + + case TAG_STATUSES.INPROGRESS: + return ( + + + +  In Progress  + {maxPercievedPercentage ? ~~maxPercievedPercentage : 0}% + + + ) + + default: + case TAG_STATUSES.READY: + return Ready + } +} + +export default TagStatus diff --git a/react/admin/ExportReturnRequestsModal/graphql/exportReturnRequests.gql b/react/admin/ExportReturnRequestsModal/graphql/exportReturnRequests.gql new file mode 100644 index 000000000..3a9bd5239 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/graphql/exportReturnRequests.gql @@ -0,0 +1,8 @@ +#import './exportStatusFragment.gql' + +mutation exportReturnRequests($exportData: ExportReturnRequestsInput!) { + exportReturnRequests(exportData: $exportData) + @context(provider: "vtex.return-app") { + ...ExportReportDataAdminFragment + } +} diff --git a/react/admin/ExportReturnRequestsModal/graphql/exportStatusFragment.gql b/react/admin/ExportReturnRequestsModal/graphql/exportStatusFragment.gql new file mode 100644 index 000000000..52d839909 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/graphql/exportStatusFragment.gql @@ -0,0 +1,11 @@ +fragment ExportReportDataAdminFragment on ExportReportData { + id + inProgress + percentageProcessed + requestedBy + completedDate + selectedFilters + downloadLink + staleLink + lastErrorMessage +} diff --git a/react/admin/ExportReturnRequestsModal/graphql/getExportStatus.gql b/react/admin/ExportReturnRequestsModal/graphql/getExportStatus.gql new file mode 100644 index 000000000..b7f4f2aa4 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/graphql/getExportStatus.gql @@ -0,0 +1,7 @@ +#import './exportStatusFragment.gql' + +query exportStatus { + exportStatus @context(provider: "vtex.return-app") { + ...ExportReportDataAdminFragment + } +} diff --git a/react/admin/ExportReturnRequestsModal/hooks/useExportModule.ts b/react/admin/ExportReturnRequestsModal/hooks/useExportModule.ts new file mode 100644 index 000000000..9c9d27851 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/hooks/useExportModule.ts @@ -0,0 +1,5 @@ +import { useContext } from 'react' + +import { ExportContext } from '../provider/ExportProvider' + +export const useExportModule = () => useContext(ExportContext) diff --git a/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx b/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx new file mode 100644 index 000000000..a6a39c965 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx @@ -0,0 +1,141 @@ +import React, { createContext, useState, useEffect } from 'react' +import type { FC } from 'react' +import { useQuery, useMutation } from 'react-apollo' +import type { ApolloError } from 'apollo-client' +import type { + ExportReturnRequestsInput, + ExportReportData, + MutationExportReturnRequestsArgs, +} from 'vtex.return-app' + +import EXPORT_RETURN_REQUESTS from '../graphql/exportReturnRequests.gql' +import GET_EXPORT_STATUS from '../graphql/getExportStatus.gql' + +interface ExportContextInterface { + exportStatus?: ExportReportData | null + loadingStatus: boolean + submitInProgress: boolean + tagStatus: ExportStatusValues + error?: ApolloError + _handleExport: (exportData: ExportReturnRequestsInput) => void +} + +export const ExportContext = createContext( + {} as ExportContextInterface +) + +const EXPORT_POLLING_MS = 1000 + +export const TAG_STATUSES = { + READY: 'status-ready', + INPROGRESS: 'status-in-progress', + ERROR: 'status-error', +} as const + +type ExportStatusKeys = keyof typeof TAG_STATUSES +type ExportStatusValues = typeof TAG_STATUSES[ExportStatusKeys] + +export const ExportProvider: FC = ({ children }) => { + /** + * Sadly the notify network feature for Apollo only announces when + * a polling is IN PROGRESS, and not when we are in a 'polling' state + */ + const [isPolling, setPollingStatus] = useState(false) + const [tagStatus, setTagStatus] = useState( + TAG_STATUSES.READY + ) + + const { + data, + loading: loadingStatus, + error, + startPolling, + stopPolling, + updateQuery, + } = useQuery<{ + exportStatus: ExportReportData | null + }>(GET_EXPORT_STATUS, { fetchPolicy: 'network-only' }) + + const { exportStatus } = data ?? {} + const { id, inProgress, lastErrorMessage } = exportStatus ?? {} + + const [exportReturnRequests, { loading: submitInProgress }] = useMutation< + { + exportReturnRequests: ExportReportData + }, + MutationExportReturnRequestsArgs + >(EXPORT_RETURN_REQUESTS) + + useEffect(() => { + if (error || lastErrorMessage) { + setTagStatus(TAG_STATUSES.ERROR) + + return + } + + if ((!id || !inProgress) && !error && !lastErrorMessage) { + setTagStatus(TAG_STATUSES.READY) + + return + } + + if (inProgress) { + setTagStatus(TAG_STATUSES.INPROGRESS) + } + }, [error, id, inProgress, lastErrorMessage]) + + useEffect(() => { + if ( + (isPolling && inProgress === false) || + (isPolling && lastErrorMessage) + ) { + stopPolling() + } + + if (!isPolling && inProgress && !lastErrorMessage) { + startPolling(EXPORT_POLLING_MS) + } + }, [isPolling, stopPolling, lastErrorMessage, startPolling, inProgress]) + + const _handleExport = async (exportData: ExportReturnRequestsInput) => { + try { + const { errors, data: mutationData } = await exportReturnRequests({ + variables: { + exportData, + }, + }) + + if (!mutationData || errors) { + throw new Error( + `An unexpected error ocurred: ${ + errors ? errors.toString() : 'No mutation data received' + }` + ) + } + + updateQuery(() => ({ + exportStatus: { ...mutationData.exportReturnRequests }, + })) + setPollingStatus(true) + startPolling(EXPORT_POLLING_MS) + } catch (e) { + console.error(e) + setTagStatus(TAG_STATUSES.ERROR) + } + } + + return ( + + {children} + + ) +} diff --git a/react/admin/ReturnList/ReturnListContainer.tsx b/react/admin/ReturnList/ReturnListContainer.tsx index d83695ec1..fde61d3dc 100644 --- a/react/admin/ReturnList/ReturnListContainer.tsx +++ b/react/admin/ReturnList/ReturnListContainer.tsx @@ -3,6 +3,7 @@ import { Layout, PageHeader, PageBlock } from 'vtex.styleguide' import { FormattedMessage } from 'react-intl' import ListTable from '../../common/components/returnList/ListTable' +import ExportReturnRequestsModal from '../ExportReturnRequestsModal' export const AdminReturnList = () => { return ( @@ -16,7 +17,9 @@ export const AdminReturnList = () => { subtitle={ } - /> + > + + } > diff --git a/react/common/constants/returnsRequest.ts b/react/common/constants/returnsRequest.ts index 09fe0d9af..34da7b3b6 100644 --- a/react/common/constants/returnsRequest.ts +++ b/react/common/constants/returnsRequest.ts @@ -120,3 +120,9 @@ export function getReasonOptions( }, ] } + +/** + * The report api supports either + * XLSX / JSON / CSV + */ +export const SUPPORTED_REPORT_FORMATS = ['XLSX'] as const diff --git a/react/typings/vtex.return-app.d.ts b/react/typings/vtex.return-app.d.ts index 1431eb23b..1be90cd12 100644 --- a/react/typings/vtex.return-app.d.ts +++ b/react/typings/vtex.return-app.d.ts @@ -12,3 +12,12 @@ interface ItemToReturn { type MaybeGlobal = T | null type GeoCoordinates = Array> + +interface ExportDateFilters { + from: string + to: string +} + +type ExportFiltersType = 'dates' + +type ExportFiltersData = ExportDateFilters diff --git a/react/utils/createExportFilters.ts b/react/utils/createExportFilters.ts new file mode 100644 index 000000000..ebbae12e2 --- /dev/null +++ b/react/utils/createExportFilters.ts @@ -0,0 +1,18 @@ +/** + * The report API accepts a string that feeds the masterdata's _WHERE field directly. + * This function should resolve any desireable filter into it's SQL counterpart. + * For the moment we only suppport filtering by dates. + */ +export function createExportFilters( + exportType: ExportFiltersType, + exportFilters: ExportFiltersData +) { + if (exportType === 'dates') { + return `dateSubmitted between ${exportFilters.from.substring( + 0, + 10 + )} AND ${exportFilters.to.substring(0, 10)}` + } + + return null +} From f512b295bf29259cde429ad8fd928fac2ce420ea Mon Sep 17 00:00:00 2001 From: Santiago Sanz Date: Fri, 2 Sep 2022 17:08:19 +0200 Subject: [PATCH 2/5] feat: intl messages --- messages/context.json | 23 +++++++++- messages/en.json | 23 +++++++++- node/services/exportStatusService.ts | 6 +++ .../ExportModule.tsx | 9 ++-- .../components/Content.tsx | 33 +++++++------ .../components/ReportForm.tsx | 46 ++++++++++++------- .../components/ReportStatus.tsx | 45 +++++++++++++----- .../components/TagStatus.tsx | 17 +++++-- .../provider/ExportProvider.tsx | 8 ++-- 9 files changed, 158 insertions(+), 52 deletions(-) diff --git a/messages/context.json b/messages/context.json index 428dda41d..40dd052eb 100644 --- a/messages/context.json +++ b/messages/context.json @@ -329,5 +329,26 @@ "return-app.return-request-details.cancellation.modal.adminAllow": "Text message for allowing a cancellation for admins", "return-app.return-request-details.cancellation.modal.adminRefuse": "Text message for refusing a cancellation for admins", "return-app.return-request-details.cancellation.modal.storeAllow": "Text message for allowing a cancellation for store users", - "return-app.return-request-details.cancellation.modal.storeRefuse": "Text message for refusing a cancellation for store users" + "return-app.return-request-details.cancellation.modal.storeRefuse": "Text message for refusing a cancellation for store users", + "admin/return-app.export-module.modal-open": "Open export modal CTA", + "admin/return-app.export-module.modal-title": "Export modal title", + "admin/return-app.export-module.modal-close": "Export modal close CTA", + "admin/return-app.export-module.content.first-paragraph": "Export explanation content, first paragraph", + "admin/return-app.export-module.content.second-paragraph": "Export explanation content, second paragraph", + "admin/return-app.export-module.content.warning-paragraph": "Export explanation content, warning paragraph", + "admin/return-app.export-module.content.format-disclaimer": "Export file format disclaimer", + "admin/return-app.export-module.content.skip-disclaimer": "Export skip record disclaimer", + "admin/return-app.export-module.report.status-title": "Report status section title", + "admin/return-app.export-module.report.download-cta": "Export file download CTA", + "admin/return-app.export-module.report.status-requestedBy": "Export status, requested-by field", + "admin/return-app.export-module.report.status-completedDate": "Export status, completed-date field", + "admin/return-app.export-module.report.status-filterRange": "Export status, filter-range field", + "admin/return-app.export-module.report.status-tag.error": "Export status error tag", + "admin/return-app.export-module.report.status-tag.inProgress": "Export status in progress tag", + "admin/return-app.export-module.report.status-tag.ready": "Export status ready tag", + "admin/return-app.export-module.report.form-title": "Report form section title", + "admin/return-app.export-module.report.form-allRequests-toggle": "Export form toggle label for filters", + "admin/return-app.export-module.report.form-email-toggle": "Export form toggle label for email", + "admin/return-app.export-module.report.form-email-placeholder": "Export form input placeholder for email", + "admin/return-app.export-module.report.form-cta": "Export form CTA" } diff --git a/messages/en.json b/messages/en.json index 03bbc0c2f..1efc4fe19 100644 --- a/messages/en.json +++ b/messages/en.json @@ -329,5 +329,26 @@ "return-app.return-request-details.cancellation.modal.adminAllow": "

Attention: This request's status will be set to CANCELLED.

This will notify the user via email and allow them to create a new return request with the cancelled request's items.

If that is not your intention, set the request as DENIED.

", "return-app.return-request-details.cancellation.modal.adminRefuse": "

Sorry, it's not possible to cancel this request due to its current status.

", "return-app.return-request-details.cancellation.modal.storeAllow": "

Cancelling this request will allow the current items to be used in a new return request.

This action is irreversible.

", - "return-app.return-request-details.cancellation.modal.storeRefuse": "

Sorry, you need to contact the support team to cancel this request due to its current status.

" + "return-app.return-request-details.cancellation.modal.storeRefuse": "

Sorry, you need to contact the support team to cancel this request due to its current status.

", + "admin/return-app.export-module.modal-open": "EXPORT RETURNS", + "admin/return-app.export-module.modal-title": "Export module", + "admin/return-app.export-module.modal-close": "CLOSE", + "admin/return-app.export-module.content.first-paragraph": "The Export module is the system responsible for merging all return requests into a single file called report, this can be then sent to an email or downloaded directly from this module", + "admin/return-app.export-module.content.second-paragraph": "Keep in mind that download links are not permanent, they expire after 6 hours following their availability", + "admin/return-app.export-module.content.warning-paragraph": "It is important to remember that depending on the quantity of return requests, the process can take a long time to finish. If you chose to generate a download link, you can leave the module open and it will update when its available for download", + "admin/return-app.export-module.content.format-disclaimer": "Supported formats:", + "admin/return-app.export-module.content.skip-disclaimer": "Documents containing errors will be skipped", + "admin/return-app.export-module.report.status-title": "Last report", + "admin/return-app.export-module.report.download-cta": "Download link", + "admin/return-app.export-module.report.status-requestedBy": "Requested by:", + "admin/return-app.export-module.report.status-completedDate": "Completed date:", + "admin/return-app.export-module.report.status-filterRange": "Filter range:", + "admin/return-app.export-module.report.status-tag.error": "Error, please try again", + "admin/return-app.export-module.report.status-tag.inProgress": "In Progress", + "admin/return-app.export-module.report.status-tag.ready": "Ready", + "admin/return-app.export-module.report.form-title": "New report", + "admin/return-app.export-module.report.form-allRequests-toggle": "All requests", + "admin/return-app.export-module.report.form-email-toggle": "Send file to email", + "admin/return-app.export-module.report.form-email-placeholder": "Recipient email", + "admin/return-app.export-module.report.form-cta": "GENERATE" } diff --git a/node/services/exportStatusService.ts b/node/services/exportStatusService.ts index f702b6924..668894ccd 100644 --- a/node/services/exportStatusService.ts +++ b/node/services/exportStatusService.ts @@ -49,6 +49,12 @@ export const exportStatusService = async ( lastErrorMessage, } } catch (error) { + if (error.response.status === 404) { + await exportReport.save(EXPORT_DATA_PATH, {}) + + return null + } + throw new ResolverError( `An unexpected error ocurred while requesting export status: ${error.message}` ) diff --git a/react/admin/ExportReturnRequestsModal/ExportModule.tsx b/react/admin/ExportReturnRequestsModal/ExportModule.tsx index 1d7305f1c..7616e0b48 100644 --- a/react/admin/ExportReturnRequestsModal/ExportModule.tsx +++ b/react/admin/ExportReturnRequestsModal/ExportModule.tsx @@ -6,6 +6,7 @@ import { utils, IconDownload, } from 'vtex.styleguide' +import { FormattedMessage } from 'react-intl' import { ExportProvider } from './provider/ExportProvider' import Content from './components/Content' @@ -22,18 +23,20 @@ const ExportModule = () => { variation="primary" onClick={onOpen} > - EXPORT RETURNS + + } bottomBar={
} diff --git a/react/admin/ExportReturnRequestsModal/components/Content.tsx b/react/admin/ExportReturnRequestsModal/components/Content.tsx index a1b676905..43b40d5ee 100644 --- a/react/admin/ExportReturnRequestsModal/components/Content.tsx +++ b/react/admin/ExportReturnRequestsModal/components/Content.tsx @@ -1,4 +1,6 @@ +import type { ReactElement } from 'react' import React from 'react' +import { FormattedMessage } from 'react-intl' import { SUPPORTED_REPORT_FORMATS } from '../../../common/constants/returnsRequest' @@ -6,15 +8,22 @@ const Content = () => { return ( <>

- The Export module is the system responsible for merging all return - requests into a single file called{' '} - report, this can be then sent to - an email or downloaded directly from this module + {chunks}, + }} + />

- Keep in mind that download links are not permanent, they expire after{' '} - 6 hours following their - availability + {chunks}, + }} + />

{ padding: '12px 16px', }} > - It is important to remember that depending on the quantity of return - requests, the process can take a long time to finish. If you chose to - generate a download link, you can leave the module open and it will - update when its available for download +

- Supported formats:  + +   {SUPPORTED_REPORT_FORMATS.map((format) => `.${format} `)}

- Documents containing errors will be skipped +

) diff --git a/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx b/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx index e368dfe2d..3e74e7888 100644 --- a/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx +++ b/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx @@ -88,7 +88,9 @@ const ReportForm = () => { return ( <> -

New report

+

+ +

{ />
+ } checked={exportAll} onChange={() => setExportPreference(!exportAll)} /> @@ -136,21 +140,29 @@ const ReportForm = () => { )}
- setEmailPreference(!sendEmail)} - /> + + {(formattedMessage) => ( + setEmailPreference(!sendEmail)} + /> + )} +
- } - disabled={!sendEmail} - onChange={(e: FormEvent) => - handleOnChange('email', e.currentTarget.value) - } - /> + + {(formattedMessage) => ( + } + disabled={!sendEmail} + onChange={(e: FormEvent) => + handleOnChange('email', e.currentTarget.value) + } + /> + )} +
diff --git a/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx index b14d162b6..d0d8f5a46 100644 --- a/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx +++ b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx @@ -1,5 +1,6 @@ import React from 'react' import { ButtonPlain, IconExternalLink } from 'vtex.styleguide' +import { FormattedMessage, FormattedDate } from 'react-intl' import { useExportModule } from '../hooks/useExportModule' import TagStatus from './TagStatus' @@ -18,7 +19,9 @@ const ReportStatus = () => { return ( <> -

Last report

+

+ +

{ disabled={inProgress || !downloadLink || staleLink} target="_blank" > - Download link  + +  

-

+ -

+

- Requested by: - {requestedBy ?? 'N/A'} + + +   + + {requestedBy ?? 'N/A'}

- Start date: - - {completedDate ? new Date(completedDate).toLocaleString() : 'N/A'} + + +   + {completedDate ? ( + + ) : ( + 'N/A' + )}

- Filter range: - {selectedFilters ?? 'N/A'} + + +   + + {selectedFilters ?? 'N/A'}

) diff --git a/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx b/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx index bc4c2025d..588bc9540 100644 --- a/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx +++ b/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Tag, Spinner } from 'vtex.styleguide' +import { FormattedMessage } from 'react-intl' import { useExportModule } from '../hooks/useExportModule' import { TAG_STATUSES } from '../provider/ExportProvider' @@ -19,14 +20,20 @@ const TagStatus = () => { switch (tagStatus) { case TAG_STATUSES.ERROR: - return Error, please try again + return ( + + + + ) case TAG_STATUSES.INPROGRESS: return ( -  In Progress  +   + +   {maxPercievedPercentage ? ~~maxPercievedPercentage : 0}% @@ -34,7 +41,11 @@ const TagStatus = () => { default: case TAG_STATUSES.READY: - return Ready + return ( + + + + ) } } diff --git a/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx b/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx index a6a39c965..a19a237bb 100644 --- a/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx +++ b/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx @@ -24,7 +24,7 @@ export const ExportContext = createContext( {} as ExportContextInterface ) -const EXPORT_POLLING_MS = 1000 +const STATUS_POLLING_MS = 2000 export const TAG_STATUSES = { READY: 'status-ready', @@ -89,11 +89,13 @@ export const ExportProvider: FC = ({ children }) => { (isPolling && inProgress === false) || (isPolling && lastErrorMessage) ) { + setPollingStatus(false) stopPolling() } if (!isPolling && inProgress && !lastErrorMessage) { - startPolling(EXPORT_POLLING_MS) + setPollingStatus(true) + startPolling(STATUS_POLLING_MS) } }, [isPolling, stopPolling, lastErrorMessage, startPolling, inProgress]) @@ -117,7 +119,7 @@ export const ExportProvider: FC = ({ children }) => { exportStatus: { ...mutationData.exportReturnRequests }, })) setPollingStatus(true) - startPolling(EXPORT_POLLING_MS) + startPolling(STATUS_POLLING_MS) } catch (e) { console.error(e) setTagStatus(TAG_STATUSES.ERROR) From eebd139e9d61c053c0e254763ac4f39f806a624a Mon Sep 17 00:00:00 2001 From: Santiago Sanz Date: Fri, 2 Sep 2022 17:22:25 +0200 Subject: [PATCH 3/5] Updated README and CHANGELOG --- CHANGELOG.md | 4 ++++ docs/README.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f1168e5..0ee0c4a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- A new feature called Export module; allowing to report all records into a single file. + ## [3.5.5] - 2022-11-11 ### Fixed - Allow creation of a return for orders placed with a ` PICKUP_POINT` as customer address. diff --git a/docs/README.md b/docs/README.md index f8b1412e1..f687f98d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ Here a customer will be able to visualize the history, status and details of the ### Admin: Return Request List -In this section of the merchant's admin, merchants are capable of visualizing and managing all the return requests created by their customers. +In this section of the merchant's admin, merchants are capable of visualizing and managing all the return requests created by their customers, while also being able to export all or some records to a single file. ### Admin: Return Settings From a83343eb6b1cd4af4ee429b4750ba81aed0935ae Mon Sep 17 00:00:00 2001 From: Santiago Sanz Date: Fri, 9 Sep 2022 11:52:48 +0200 Subject: [PATCH 4/5] feat: minor changes --- docs/README.md | 15 ++++++- messages/en.json | 2 +- node/report/json_return-request-map.ts | 40 ++++++++++--------- .../components/ReportStatus.tsx | 6 ++- 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/docs/README.md b/docs/README.md index f687f98d0..f0a102898 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,13 @@ Return app v3 is still in BETA. v2 will no longer be supported so please do not install it. Docs are WIP. + - Table of contents + - [Features](#features) + - [API](#api) + - [Customization](#customization) + - [Known issues](#known-issues) + - [Development](#development) + ## Description The **Return App** gives merchants the option to allow customers to request a return for their items and it gives them the ability to manage the Return Request Process on their store. @@ -303,11 +310,17 @@ In order to apply CSS customizations in this and other blocks, follow the instru |'termsAndConditionsLink'| |'userCommentDetailsContainer'| -## Knowing issues +## Known issues - When a store has a process to create return invoices ([invoice type input](https://developers.vtex.com/vtex-rest-api/reference/invoicenotification)) outside the return app, the app will consider those items and they will not be able to be returned via the app. However when an item is already committed in a return request and an invoice is created considering that item with a invoice number different than the return request id, there will be more processed items to return then invoices items - It can be seen using the query `orderToReturnSummary` on GraphQL. - When installing the app in a workspace - or creating a new one - the app will not behavior as expected. This is due to the masterdata builder not creating a schema for that workspace automatically. To fix that, one can just link the app in the workspace using the toolbelt. Doing so, there will be a new masterdata schema related to that workspace and the app should work fine. +## Development + +### Export feature (Report) + +This app leverages the Report API to export all return documents into a single file. For this to work, we maintain a matrix transformation MAP inside **/node/report**. If any key or value of the object is changed, next time any account that opens the module will have their map created/updated. The ID is the identifier we use to compare them, there's no need of changing it every time we modify the object. + --- Documentation for v2 [here](https://github.com/vtex-apps/return-app/tree/v2). diff --git a/messages/en.json b/messages/en.json index 1efc4fe19..61b4e54cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -335,7 +335,7 @@ "admin/return-app.export-module.modal-close": "CLOSE", "admin/return-app.export-module.content.first-paragraph": "The Export module is the system responsible for merging all return requests into a single file called report, this can be then sent to an email or downloaded directly from this module", "admin/return-app.export-module.content.second-paragraph": "Keep in mind that download links are not permanent, they expire after 6 hours following their availability", - "admin/return-app.export-module.content.warning-paragraph": "It is important to remember that depending on the quantity of return requests, the process can take a long time to finish. If you chose to generate a download link, you can leave the module open and it will update when its available for download", + "admin/return-app.export-module.content.warning-paragraph": "As the export process runs in the backround, If you chose to only generate a download link, you can close this page and check back later.", "admin/return-app.export-module.content.format-disclaimer": "Supported formats:", "admin/return-app.export-module.content.skip-disclaimer": "Documents containing errors will be skipped", "admin/return-app.export-module.report.status-title": "Last report", diff --git a/node/report/json_return-request-map.ts b/node/report/json_return-request-map.ts index dff381a7e..0bc6af1d8 100644 --- a/node/report/json_return-request-map.ts +++ b/node/report/json_return-request-map.ts @@ -1,3 +1,7 @@ +/** + * @todo + * Test translationPrefix for the status column by creating new messages + */ export const localMap: ReportMap = { id: '7a42a323-1faa-11ed-835d-16acdede38c5', name: 'Return Requests', @@ -63,105 +67,105 @@ export const localMap: ReportMap = { defaultLanguage: null, }, { - header: 'customer_profile_data_id', + header: 'customer_user_id', query: 'customerProfileData.userId', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'customer_profile_data_name', + header: 'customer_name', query: 'customerProfileData.name', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'customer_profile_data_email', + header: 'customer_email', query: 'customerProfileData.email', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'customer_profile_data_phone', + header: 'customer_phone_number', query: 'customerProfileData.phoneNumber', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_id', + header: 'pickup_address_id', query: 'pickupReturnData.addressId', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_address', + header: 'pickup_address', query: 'pickupReturnData.address', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_city', + header: 'pickup_city', query: 'pickupReturnData.city', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_state', + header: 'pickup_state', query: 'pickupReturnData.state', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_country', + header: 'pickup_country', query: 'pickupReturnData.country', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_zip_code', + header: 'pickup_zip_code', query: 'pickupReturnData.zipCode', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_type', + header: 'pickup_address_type', query: 'pickupReturnData.addressType', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'pickup_return_data_return_label', + header: 'pickup_return_label', query: "pickupReturnData.( returnLabel ? returnLabel : 'NA' )", usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'refund_payment_data_payment_method', + header: 'refund_payment_method', query: 'refundPaymentData.refundPaymentMethod', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'refund_payment_data_payment_iban', + header: 'refund_payment_iban', query: "refundPaymentData.( iban ? iban : 'NA' )", usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'refund_payment_data_payment_account_holder_name', + header: 'refund_payment_account_holder_name', query: "refundPaymentData.( accountHolderName ? accountHolderName : 'NA' )", usePath: false, @@ -169,7 +173,7 @@ export const localMap: ReportMap = { defaultLanguage: null, }, { - header: 'refund_payment_data_payment_automatic_refund', + header: 'refund_payment_automatic_refund', query: "refundPaymentData.( automaticallyRefundPaymentMethod ? automaticallyRefundPaymentMethod : 'NA' )", usePath: true, @@ -177,14 +181,14 @@ export const localMap: ReportMap = { defaultLanguage: null, }, { - header: 'culture_info_data_currency_code', + header: 'currency_code', query: 'cultureInfoData.currencyCode', usePath: false, translationPrefix: null, defaultLanguage: null, }, { - header: 'culture_info_data_currency_locale', + header: 'locale', query: 'cultureInfoData.locale', usePath: false, translationPrefix: null, diff --git a/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx index d0d8f5a46..39e219442 100644 --- a/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx +++ b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx @@ -17,6 +17,8 @@ const ReportStatus = () => { staleLink, } = exportStatus ?? {} + const disableDownload = inProgress || !downloadLink || staleLink + return ( <>

@@ -26,8 +28,8 @@ const ReportStatus = () => {

false : downloadLink} + disabled={disableDownload} target="_blank" > From 82f284231ab3cab7cc6ce526f1b1bf0a07b39280 Mon Sep 17 00:00:00 2001 From: Santiago Sanz Date: Fri, 9 Sep 2022 11:56:49 +0200 Subject: [PATCH 5/5] fix: minor message change --- messages/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en.json b/messages/en.json index 61b4e54cf..4e28b7d33 100644 --- a/messages/en.json +++ b/messages/en.json @@ -335,7 +335,7 @@ "admin/return-app.export-module.modal-close": "CLOSE", "admin/return-app.export-module.content.first-paragraph": "The Export module is the system responsible for merging all return requests into a single file called report, this can be then sent to an email or downloaded directly from this module", "admin/return-app.export-module.content.second-paragraph": "Keep in mind that download links are not permanent, they expire after 6 hours following their availability", - "admin/return-app.export-module.content.warning-paragraph": "As the export process runs in the backround, If you chose to only generate a download link, you can close this page and check back later.", + "admin/return-app.export-module.content.warning-paragraph": "As the export process runs in the backround, if you only chose to generate a download link, you can close this page and check back later; if not, you'll be alerted via email", "admin/return-app.export-module.content.format-disclaimer": "Supported formats:", "admin/return-app.export-module.content.skip-disclaimer": "Documents containing errors will be skipped", "admin/return-app.export-module.report.status-title": "Last report",