From 3e023757c9b89047f06962fd640ef2fc478aeaf9 Mon Sep 17 00:00:00 2001 From: Arjen Furster Date: Fri, 3 Apr 2026 15:48:19 +0200 Subject: [PATCH 1/6] feat: add RenewalCalendar component for tracking and visualizing subscription renewals with CSV/XLSX support --- src/components/RenewalCalendar.tsx | 300 ++++++++++++++++++++--------- 1 file changed, 214 insertions(+), 86 deletions(-) diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx index 50422ba..0c9913a 100644 --- a/src/components/RenewalCalendar.tsx +++ b/src/components/RenewalCalendar.tsx @@ -1,7 +1,8 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; +import Papa from 'papaparse'; import { useBillingStore } from '../store/billingStore'; import type { BillingRecord } from '../types/BillingData'; -import { Calendar, ChevronLeft, ChevronRight, List, Clock, AlertTriangle, TrendingUp, Download, Hourglass } from 'lucide-react'; +import { Calendar, ChevronLeft, ChevronRight, List, Clock, AlertTriangle, TrendingUp, Download, Hourglass, Upload, X } from 'lucide-react'; import * as XLSX from 'xlsx'; // ── Types ────────────────────────────────────────────────────────────────────── @@ -29,16 +30,30 @@ interface RenewalEntry { currency: string; daysUntil: number; isCancellable: boolean; - isInEST: boolean; orderId?: string; } -const EST_COLOR = '#6366F1'; +/** A record from the Partner Center AI Assist EST export CSV */ +interface ESTUploadRecord { + customerTenantId: string; + resellerPartnerId: string; + subscriptionId: string; + subscriptionName: string; + offerId: string; + quantity: number; + termDuration: string; + billingCycle: string; + termEndDate: Date | null; + errorMessage: string; + evaluationTime: Date | null; + /** Customer name resolved from billing data via SubscriptionId */ + resolvedCustomerName?: string; + /** Days until this subscription enters EST (termEndDate − today) */ + daysUntilEST: number; +} -// EST (Extended Service Terms) applies to annual/multi-year subscriptions that -// expire on or after the EST launch date. Subscriptions that expired before this -// date fell under the old post-expiry grace period, not EST. -const EST_LAUNCH_DATE = new Date('2025-05-04T00:00:00'); +/** Indigo for subscriptions scheduled to enter EST (from PC upload) */ +const EST_FUTURE_COLOR = '#818CF8'; // ── Constants ────────────────────────────────────────────────────────────────── @@ -94,6 +109,23 @@ function formatCurrency(v: number, currency: string): string { }); } +/** Parse Partner Center EST export date: MM/DD/YYYY HH:MM:SS */ +function parseESTDate(s: string | undefined): Date | null { + if (!s) return null; + const m = s.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); + if (!m) return null; + return new Date(parseInt(m[3]), parseInt(m[1]) - 1, parseInt(m[2])); +} + +/** Format a P-duration string to a readable label */ +function formatDuration(d: string): string { + if (!d) return d; + if (d.toUpperCase() === 'P1Y') return '1 Year'; + if (d.toUpperCase() === 'P3Y') return '3 Year'; + if (d.toUpperCase() === 'P1M') return '1 Month'; + return d; +} + function isSameDay(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() @@ -164,11 +196,6 @@ const RenewalDetailRow: React.FC<{ renewal: RenewalEntry; isExpanded: boolean; o NCE 7-day cancellation window active )} - {r.isInEST && ( -
- Extended Service Term (EST) — subscription continues month-to-month, cancel anytime -
- )} )} @@ -187,6 +214,9 @@ export const RenewalCalendar: React.FC = () => { const [filterDays, setFilterDays] = useState(90); const [search, setSearch] = useState(''); const [expandedRow, setExpandedRow] = useState(null); + const [estUploadRecords, setEstUploadRecords] = useState([]); + const [estUploadError, setEstUploadError] = useState(null); + const estFileInputRef = useRef(null); const today = useMemo(() => toMidnight(new Date()), []); @@ -217,14 +247,6 @@ export const RenewalCalendar: React.FC = () => { : 999; const termCategory = classifyTerm(row.TermAndBillingCycle); - // EST applies to annual/multi-year subscriptions whose end date is on or after - // the EST launch date (2025-05-04) and that are now past their end date. - // Subscriptions expired before EST launch fall under the old grace period model. - // Monthly (Flex) and Trial are excluded — they auto-renew or expire normally. - const isInEST = daysUntil < 0 - && end >= EST_LAUNCH_DATE - && termCategory !== 'Monthly (Flex)' - && termCategory !== 'Trial'; entries.push({ subscriptionId: row.SubscriptionId, @@ -239,13 +261,90 @@ export const RenewalCalendar: React.FC = () => { currency: row.Currency ?? row.PricingCurrency ?? 'EUR', daysUntil, isCancellable: daysSinceStart >= -1 && daysSinceStart <= 7, - isInEST, orderId: row.OrderId, }); } return entries; }, [data, today]); + // ── Subscription ID → customer name lookup from billing data ────────────── + const subscriptionCustomerMap = useMemo(() => { + const m = new Map(); + for (const row of data) { + if (row.SubscriptionId && row.CustomerName) { + m.set(row.SubscriptionId.toLowerCase(), row.CustomerName); + } + } + return m; + }, [data]); + + // ── Parse Partner Center EST export CSV ─────────────────────────────────── + const handleESTFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setEstUploadError(null); + + Papa.parse(file, { + header: true, + skipEmptyLines: 'greedy', + encoding: 'UTF-8', + complete: (results) => { + const rows = results.data as Record[]; + if (!rows.length) { + setEstUploadError('No data rows found in the uploaded file.'); + return; + } + // Validate that it looks like a PC EST export + const first = rows[0]; + if (!('SubscriptionId' in first) && !('subscriptionId' in first)) { + setEstUploadError('Unrecognised file format. Expected a Partner Center EST export with a SubscriptionId column.'); + return; + } + + const parsed: ESTUploadRecord[] = rows + .map((row) => { + const subId = (row['SubscriptionId'] || row['subscriptionId'] || '').trim(); + if (!subId) return null; + const termEndDate = parseESTDate(row['TermEndDate'] || row['termEndDate']); + const evaluationTime = parseESTDate(row['EvaluationTime'] || row['evaluationTime']); + const daysUntilEST = termEndDate + ? Math.ceil((toMidnight(termEndDate).getTime() - today.getTime()) / 86_400_000) + : 0; + const resolvedCustomerName = subscriptionCustomerMap.get(subId.toLowerCase()); + return { + customerTenantId: (row['CustomerTenantId'] || row['customerTenantId'] || '').trim(), + resellerPartnerId: (row['ResellerPartnerId'] || row['resellerPartnerId'] || '').trim(), + subscriptionId: subId, + subscriptionName: (row['SubscriptionName'] || row['subscriptionName'] || '').trim(), + offerId: (row['OfferId'] || row['offerId'] || '').trim(), + quantity: parseInt(row['Quantity'] || row['quantity'] || '0', 10) || 0, + termDuration: (row['TermDuration'] || row['termDuration'] || '').trim(), + billingCycle: (row['BillingCycle'] || row['billingCycle'] || '').trim(), + termEndDate, + errorMessage: (row['ErrorMessage'] || row['errorMessage'] || '').trim(), + evaluationTime, + resolvedCustomerName, + daysUntilEST, + } satisfies ESTUploadRecord; + }) + .filter((r): r is ESTUploadRecord => r !== null); + + setEstUploadRecords(parsed); + // Reset file input so re-uploading the same file triggers onChange + if (estFileInputRef.current) estFileInputRef.current.value = ''; + }, + error: (err: any) => { + setEstUploadError(`Parse error: ${err.message}`); + }, + }); + }; + + // Sorted by soonest TermEndDate first + const estUploadSorted = useMemo( + () => [...estUploadRecords].sort((a, b) => (a.daysUntilEST) - (b.daysUntilEST)), + [estUploadRecords] + ); + // ── Apply search + term filter ───────────────────────────────────────────── const filteredRenewals = useMemo(() => { const s = search.toLowerCase(); @@ -269,10 +368,9 @@ export const RenewalCalendar: React.FC = () => { const thisMonth = filteredRenewals.filter(r => r.daysUntil >= 0 && r.endDate <= monthEnd); const next30 = filteredRenewals.filter(r => r.daysUntil >= 0 && r.daysUntil <= 30); const cancellable = allRenewals.filter(r => r.isCancellable); - const inEST = filteredRenewals.filter(r => r.isInEST); const windowValue = upcomingRenewals.reduce((s, r) => s + r.value, 0); const currency = filteredRenewals[0]?.currency ?? 'EUR'; - return { thisMonth, next30, cancellable, inEST, windowValue, currency }; + return { thisMonth, next30, cancellable, windowValue, currency }; }, [filteredRenewals, upcomingRenewals, allRenewals, today]); // ── Calendar grid ────────────────────────────────────────────────────────── @@ -304,9 +402,7 @@ export const RenewalCalendar: React.FC = () => { // ── Export ───────────────────────────────────────────────────────────────── const handleExport = () => { - const exportRows = [...stats.inEST, ...upcomingRenewals]; - const rows = exportRows.map(r => ({ - 'Status': r.isInEST ? 'Extended Service Term (EST)' : 'Upcoming Renewal', + const rows = upcomingRenewals.map(r => ({ 'Customer': r.customerName, 'Product': r.productName, 'Term': r.term, @@ -354,10 +450,10 @@ export const RenewalCalendar: React.FC = () => { } color="#FE5000" label="NCE Cancellable Now" value={stats.cancellable.length.toString()} sub="Within 7-day window" alert={stats.cancellable.length > 0} /> - } color={EST_COLOR} label="In Extended Service Term" - value={stats.inEST.length.toString()} - sub={stats.inEST.length > 0 ? `${formatCurrency(stats.inEST.reduce((s, r) => s + r.value, 0), stats.currency)} · cancel anytime` : 'None active'} - alert={stats.inEST.length > 0} /> + } color={EST_FUTURE_COLOR} label="Entering EST (PC Upload)" + value={estUploadRecords.length.toString()} + sub={estUploadRecords.length > 0 ? `${estUploadRecords.filter(r => r.daysUntilEST >= 0 && r.daysUntilEST <= 90).length} within 90 days` : 'Upload PC EST export'} + alert={estUploadRecords.some(r => r.daysUntilEST >= 0 && r.daysUntilEST <= 30)} /> {/* Toolbar */} @@ -412,7 +508,34 @@ export const RenewalCalendar: React.FC = () => { }}> Export + + {/* EST Upload */} + + {estUploadRecords.length > 0 && ( + + )} + {estUploadError && ( +
+ {estUploadError} +
+ )} {/* Term legend */}
@@ -468,7 +591,6 @@ export const RenewalCalendar: React.FC = () => { ); const k = dayKey(day); const dayRenewals = renewalsByDay.get(k) ?? []; - const estCount = dayRenewals.filter(r => r.isInEST).length; const isToday = isSameDay(day, today); const isSelected = selectedDay ? isSameDay(day, selectedDay) : false; const isPast = day < today && !isToday; @@ -478,16 +600,15 @@ export const RenewalCalendar: React.FC = () => { style={{ minHeight: '80px', padding: '0.4rem', borderRight: '1px solid var(--border-color)', borderBottom: '1px solid var(--border-color)', - background: isSelected ? 'rgba(99,102,241,0.1)' : isToday ? 'rgba(16,185,129,0.06)' : estCount > 0 && isPast ? 'rgba(99,102,241,0.04)' : 'transparent', - borderLeft: estCount > 0 && isPast ? `3px solid ${EST_COLOR}55` : undefined, + background: isSelected ? 'rgba(99,102,241,0.1)' : isToday ? 'rgba(16,185,129,0.06)' : 'transparent', cursor: hasItems ? 'pointer' : 'default', - opacity: isPast && !estCount ? 0.45 : 1, + opacity: isPast ? 0.45 : 1, transition: 'background 0.12s', }}>
@@ -501,11 +622,6 @@ export const RenewalCalendar: React.FC = () => { +{dayRenewals.length - 6} )}
- {estCount > 0 && ( -
- EST{estCount > 1 ? ` ×${estCount}` : ''} -
- )}
); })} @@ -541,47 +657,53 @@ export const RenewalCalendar: React.FC = () => { {viewMode === 'list' && (
- {/* EST section */} - {stats.inEST.length > 0 && ( -
-
- Extended Service Term — {stats.inEST.length} subscription{stats.inEST.length > 1 ? 's' : ''} · cancel anytime + {/* Partner Center EST upload section */} + {estUploadSorted.length > 0 && ( +
+
+ Entering EST — {estUploadSorted.length} subscription{estUploadSorted.length > 1 ? 's' : ''} (Partner Center AI Assist)
- {stats.inEST.sort((a, b) => a.daysUntil - b.daysUntil).map((r, i) => { - const isExpanded = expandedRow === r.subscriptionId; + {estUploadSorted.map((r, i) => { + const key = r.subscriptionId; + const isExpanded = expandedRow === `est-pc-${key}`; + const displayName = r.resolvedCustomerName || r.customerTenantId; + const urgencyColor = r.daysUntilEST <= 30 ? '#FE5000' : r.daysUntilEST <= 90 ? '#F59E0B' : 'var(--text-secondary)'; return ( - -
setExpandedRow(isExpanded ? null : r.subscriptionId)} - style={{ - display: 'grid', gridTemplateColumns: '1fr 1fr auto auto auto auto', - gap: '0.5rem', alignItems: 'center', - padding: '0.6rem 0.75rem', cursor: 'pointer', - borderBottom: '1px solid var(--border-color)', - background: i % 2 === 0 ? 'transparent' : 'var(--bg-tertiary)', - fontSize: '0.85rem', - }}> -
{r.customerName}
-
{r.productName}
-
- - {r.termCategory} + +
setExpandedRow(isExpanded ? null : `est-pc-${key}`)} style={{ + display: 'grid', gridTemplateColumns: '1fr 1fr auto auto auto', + gap: '0.5rem', alignItems: 'center', + padding: '0.6rem 0.75rem', cursor: 'pointer', + borderBottom: '1px solid var(--border-color)', + background: i % 2 === 0 ? 'transparent' : 'var(--bg-tertiary)', + fontSize: '0.85rem', + }}> +
{displayName}
+
{r.subscriptionName}
+
+ {formatDuration(r.termDuration)} · {r.billingCycle} +
+
+ {r.termEndDate ? formatDate(r.termEndDate) : '—'} +
+
+ {r.daysUntilEST < 0 ? 'Now' : r.daysUntilEST === 0 ? 'Today' : `${r.daysUntilEST}d`}
-
{formatDate(r.endDate)}
-
EST
-
{formatCurrency(r.value, r.currency)}
{isExpanded && ( -
+
Subscription ID
{r.subscriptionId}
- {r.startDate &&
Start Date
{formatDate(r.startDate)}
} -
Original End Date
{formatDate(r.endDate)}
-
Overdue by
{Math.abs(r.daysUntil)} days
+
Customer Tenant ID
{r.customerTenantId}
+ {r.resolvedCustomerName &&
Customer Name
{r.resolvedCustomerName}
} +
Offer ID
{r.offerId}
Quantity
{r.quantity}
- {r.orderId &&
Order ID
{r.orderId}
} -
Full Term
{r.term}
-
- Extended Service Term active — subscription continues month-to-month until renewed or cancelled +
Term / Billing Cycle
{formatDuration(r.termDuration)} / {r.billingCycle}
+
Term End Date
{r.termEndDate ? formatDate(r.termEndDate) : '—'}
+ {r.resellerPartnerId &&
Reseller Partner ID
{r.resellerPartnerId}
} + {r.errorMessage &&
{r.errorMessage}
} +
+ This subscription is configured to enter Extended Service Term when its annual term expires
@@ -663,20 +785,26 @@ export const RenewalCalendar: React.FC = () => {
)} - {/* EST alert panel */} - {stats.inEST.length > 0 && viewMode === 'calendar' && ( -
-
- - {stats.inEST.length} subscription{stats.inEST.length > 1 ? 's' : ''} in Extended Service Term — past end date, running month-to-month + {/* Partner Center EST panel — calendar view */} + {estUploadSorted.length > 0 && viewMode === 'calendar' && ( +
+
+ + {estUploadSorted.length} subscription{estUploadSorted.length > 1 ? 's' : ''} entering EST — scheduled at term end (Partner Center AI Assist)
- {stats.inEST.map(r => ( -
- {r.customerName} — {r.productName} - expired {formatDate(r.endDate)} · {Math.abs(r.daysUntil)}d ago -
- ))} + {estUploadSorted.map(r => { + const displayName = r.resolvedCustomerName || r.customerTenantId; + const urgencyColor = r.daysUntilEST <= 30 ? '#FE5000' : r.daysUntilEST <= 90 ? '#F59E0B' : 'var(--text-secondary)'; + return ( +
+ {displayName} — {r.subscriptionName} + + {r.termEndDate ? formatDate(r.termEndDate) : '—'} · {r.daysUntilEST < 0 ? 'overdue' : r.daysUntilEST === 0 ? 'today' : `${r.daysUntilEST}d`} + +
+ ); + })}
)} From 2015c5e596001a264402c4b103ae43b1583621ac Mon Sep 17 00:00:00 2001 From: Arjen Furster Date: Fri, 3 Apr 2026 15:54:11 +0200 Subject: [PATCH 2/6] feat: implement RenewalCalendar component for tracking and visualizing subscription renewal dates --- src/components/RenewalCalendar.tsx | 54 +++++++++++++++--------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx index 0c9913a..c7783b8 100644 --- a/src/components/RenewalCalendar.tsx +++ b/src/components/RenewalCalendar.tsx @@ -301,33 +301,33 @@ export const RenewalCalendar: React.FC = () => { return; } - const parsed: ESTUploadRecord[] = rows - .map((row) => { - const subId = (row['SubscriptionId'] || row['subscriptionId'] || '').trim(); - if (!subId) return null; - const termEndDate = parseESTDate(row['TermEndDate'] || row['termEndDate']); - const evaluationTime = parseESTDate(row['EvaluationTime'] || row['evaluationTime']); - const daysUntilEST = termEndDate - ? Math.ceil((toMidnight(termEndDate).getTime() - today.getTime()) / 86_400_000) - : 0; - const resolvedCustomerName = subscriptionCustomerMap.get(subId.toLowerCase()); - return { - customerTenantId: (row['CustomerTenantId'] || row['customerTenantId'] || '').trim(), - resellerPartnerId: (row['ResellerPartnerId'] || row['resellerPartnerId'] || '').trim(), - subscriptionId: subId, - subscriptionName: (row['SubscriptionName'] || row['subscriptionName'] || '').trim(), - offerId: (row['OfferId'] || row['offerId'] || '').trim(), - quantity: parseInt(row['Quantity'] || row['quantity'] || '0', 10) || 0, - termDuration: (row['TermDuration'] || row['termDuration'] || '').trim(), - billingCycle: (row['BillingCycle'] || row['billingCycle'] || '').trim(), - termEndDate, - errorMessage: (row['ErrorMessage'] || row['errorMessage'] || '').trim(), - evaluationTime, - resolvedCustomerName, - daysUntilEST, - } satisfies ESTUploadRecord; - }) - .filter((r): r is ESTUploadRecord => r !== null); + const parsed: ESTUploadRecord[] = []; + for (const row of rows) { + const subId = (row['SubscriptionId'] || row['subscriptionId'] || '').trim(); + if (!subId) continue; + const termEndDate = parseESTDate(row['TermEndDate'] || row['termEndDate']); + const evaluationTime = parseESTDate(row['EvaluationTime'] || row['evaluationTime']); + const daysUntilEST = termEndDate + ? Math.ceil((toMidnight(termEndDate).getTime() - today.getTime()) / 86_400_000) + : 0; + const resolvedCustomerName = subscriptionCustomerMap.get(subId.toLowerCase()); + const record: ESTUploadRecord = { + customerTenantId: (row['CustomerTenantId'] || row['customerTenantId'] || '').trim(), + resellerPartnerId: (row['ResellerPartnerId'] || row['resellerPartnerId'] || '').trim(), + subscriptionId: subId, + subscriptionName: (row['SubscriptionName'] || row['subscriptionName'] || '').trim(), + offerId: (row['OfferId'] || row['offerId'] || '').trim(), + quantity: parseInt(row['Quantity'] || row['quantity'] || '0', 10) || 0, + termDuration: (row['TermDuration'] || row['termDuration'] || '').trim(), + billingCycle: (row['BillingCycle'] || row['billingCycle'] || '').trim(), + termEndDate, + errorMessage: (row['ErrorMessage'] || row['errorMessage'] || '').trim(), + evaluationTime, + resolvedCustomerName, + daysUntilEST, + }; + parsed.push(record); + } setEstUploadRecords(parsed); // Reset file input so re-uploading the same file triggers onChange From 4c309144f82d25e7ea8cf82ca1fe8d2b7d3c2532 Mon Sep 17 00:00:00 2001 From: Arjen <34623288+hardinxcore@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:57:36 +0200 Subject: [PATCH 3/6] Update src/components/RenewalCalendar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/RenewalCalendar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx index c7783b8..ac77b71 100644 --- a/src/components/RenewalCalendar.tsx +++ b/src/components/RenewalCalendar.tsx @@ -112,9 +112,15 @@ function formatCurrency(v: number, currency: string): string { /** Parse Partner Center EST export date: MM/DD/YYYY HH:MM:SS */ function parseESTDate(s: string | undefined): Date | null { if (!s) return null; - const m = s.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); + const m = s.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/); if (!m) return null; - return new Date(parseInt(m[3]), parseInt(m[1]) - 1, parseInt(m[2])); + const month = parseInt(m[1], 10) - 1; + const day = parseInt(m[2], 10); + const year = parseInt(m[3], 10); + const hours = m[4] ? parseInt(m[4], 10) : 0; + const minutes = m[5] ? parseInt(m[5], 10) : 0; + const seconds = m[6] ? parseInt(m[6], 10) : 0; + return new Date(year, month, day, hours, minutes, seconds); } /** Format a P-duration string to a readable label */ From 62ae92d3ed5316fcc6651256f40ae17de94d6afc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:00:29 +0000 Subject: [PATCH 4/6] fix: check PapaParse results.errors, use POSITIVE_INFINITY for unknown dates, reset file input on error Agent-Logs-Url: https://github.com/hardinxcore/CSPInsights.app/sessions/8e2b7784-bbed-4956-beea-3fd717a0ec75 Co-authored-by: hardinxcore <34623288+hardinxcore@users.noreply.github.com> --- src/components/RenewalCalendar.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx index ac77b71..4e61deb 100644 --- a/src/components/RenewalCalendar.tsx +++ b/src/components/RenewalCalendar.tsx @@ -295,15 +295,27 @@ export const RenewalCalendar: React.FC = () => { skipEmptyLines: 'greedy', encoding: 'UTF-8', complete: (results) => { + // Surface parse errors as a warning; Papa may still return partial data + if (results.errors.length) { + const sample = results.errors + .slice(0, 3) + .map(e => `row ${e.row != null ? e.row + 2 : '?'}: ${e.message}`) + .join('; '); + const suffix = results.errors.length > 3 ? ` … and ${results.errors.length - 3} more` : ''; + setEstUploadError(`${results.errors.length} parse issue(s) — some rows may be missing: ${sample}${suffix}`); + } + const rows = results.data as Record[]; if (!rows.length) { setEstUploadError('No data rows found in the uploaded file.'); + if (estFileInputRef.current) estFileInputRef.current.value = ''; return; } // Validate that it looks like a PC EST export const first = rows[0]; if (!('SubscriptionId' in first) && !('subscriptionId' in first)) { setEstUploadError('Unrecognised file format. Expected a Partner Center EST export with a SubscriptionId column.'); + if (estFileInputRef.current) estFileInputRef.current.value = ''; return; } @@ -315,7 +327,7 @@ export const RenewalCalendar: React.FC = () => { const evaluationTime = parseESTDate(row['EvaluationTime'] || row['evaluationTime']); const daysUntilEST = termEndDate ? Math.ceil((toMidnight(termEndDate).getTime() - today.getTime()) / 86_400_000) - : 0; + : Number.POSITIVE_INFINITY; const resolvedCustomerName = subscriptionCustomerMap.get(subId.toLowerCase()); const record: ESTUploadRecord = { customerTenantId: (row['CustomerTenantId'] || row['customerTenantId'] || '').trim(), @@ -341,6 +353,7 @@ export const RenewalCalendar: React.FC = () => { }, error: (err: any) => { setEstUploadError(`Parse error: ${err.message}`); + if (estFileInputRef.current) estFileInputRef.current.value = ''; }, }); }; @@ -693,7 +706,7 @@ export const RenewalCalendar: React.FC = () => { {r.termEndDate ? formatDate(r.termEndDate) : '—'}
- {r.daysUntilEST < 0 ? 'Now' : r.daysUntilEST === 0 ? 'Today' : `${r.daysUntilEST}d`} + {!isFinite(r.daysUntilEST) ? '—' : r.daysUntilEST < 0 ? 'Now' : r.daysUntilEST === 0 ? 'Today' : `${r.daysUntilEST}d`}
{isExpanded && ( @@ -806,7 +819,7 @@ export const RenewalCalendar: React.FC = () => {
{displayName} — {r.subscriptionName} - {r.termEndDate ? formatDate(r.termEndDate) : '—'} · {r.daysUntilEST < 0 ? 'overdue' : r.daysUntilEST === 0 ? 'today' : `${r.daysUntilEST}d`} + {r.termEndDate ? formatDate(r.termEndDate) : '—'} · {!isFinite(r.daysUntilEST) ? '—' : r.daysUntilEST < 0 ? 'overdue' : r.daysUntilEST === 0 ? 'today' : `${r.daysUntilEST}d`}
); From b2636e55c64b21e3c9e671590440120e9fd5a132 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:02:07 +0000 Subject: [PATCH 5/6] fix: preserve parse warnings in validation errors, clarify row offset comment Agent-Logs-Url: https://github.com/hardinxcore/CSPInsights.app/sessions/8e2b7784-bbed-4956-beea-3fd717a0ec75 Co-authored-by: hardinxcore <34623288+hardinxcore@users.noreply.github.com> --- src/components/RenewalCalendar.tsx | 31 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx index 4e61deb..4e5a8e5 100644 --- a/src/components/RenewalCalendar.tsx +++ b/src/components/RenewalCalendar.tsx @@ -296,29 +296,40 @@ export const RenewalCalendar: React.FC = () => { encoding: 'UTF-8', complete: (results) => { // Surface parse errors as a warning; Papa may still return partial data - if (results.errors.length) { - const sample = results.errors - .slice(0, 3) - .map(e => `row ${e.row != null ? e.row + 2 : '?'}: ${e.message}`) - .join('; '); - const suffix = results.errors.length > 3 ? ` … and ${results.errors.length - 3} more` : ''; - setEstUploadError(`${results.errors.length} parse issue(s) — some rows may be missing: ${sample}${suffix}`); - } + const parseErrors = results.errors; + const parseWarning = parseErrors.length + ? `${parseErrors.length} parse issue(s) — some rows may be missing: ${parseErrors + .slice(0, 3) + // +2 adjusts for 1-based row numbers plus the header row + .map(e => `row ${e.row != null ? e.row + 2 : '?'}: ${e.message}`) + .join('; ')}${parseErrors.length > 3 ? ` … and ${parseErrors.length - 3} more` : ''}` + : null; const rows = results.data as Record[]; if (!rows.length) { - setEstUploadError('No data rows found in the uploaded file.'); + setEstUploadError( + parseWarning + ? `No data rows found in the uploaded file. ${parseWarning}` + : 'No data rows found in the uploaded file.', + ); if (estFileInputRef.current) estFileInputRef.current.value = ''; return; } // Validate that it looks like a PC EST export const first = rows[0]; if (!('SubscriptionId' in first) && !('subscriptionId' in first)) { - setEstUploadError('Unrecognised file format. Expected a Partner Center EST export with a SubscriptionId column.'); + setEstUploadError( + parseWarning + ? `Unrecognised file format. Expected a Partner Center EST export with a SubscriptionId column. ${parseWarning}` + : 'Unrecognised file format. Expected a Partner Center EST export with a SubscriptionId column.', + ); if (estFileInputRef.current) estFileInputRef.current.value = ''; return; } + // Set parse warning (if any) before processing records + if (parseWarning) setEstUploadError(parseWarning); + const parsed: ESTUploadRecord[] = []; for (const row of rows) { const subId = (row['SubscriptionId'] || row['subscriptionId'] || '').trim(); From 53eca7f965b4cee0ebfa74839c023945d2088152 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:03:31 +0000 Subject: [PATCH 6/6] refactor: extract CSV_ROW_NUMBER_OFFSET constant for parse error row display Agent-Logs-Url: https://github.com/hardinxcore/CSPInsights.app/sessions/8e2b7784-bbed-4956-beea-3fd717a0ec75 Co-authored-by: hardinxcore <34623288+hardinxcore@users.noreply.github.com> --- src/components/RenewalCalendar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx index 4e5a8e5..58db37b 100644 --- a/src/components/RenewalCalendar.tsx +++ b/src/components/RenewalCalendar.tsx @@ -72,6 +72,9 @@ const TERM_LABELS: TermCategory[] = [ const DAY_HEADERS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +/** Offset to convert PapaParse's 0-based row index to a 1-based spreadsheet row number (accounting for the header row) */ +const CSV_ROW_NUMBER_OFFSET = 2; + // ── Helpers ──────────────────────────────────────────────────────────────────── function classifyTerm(raw: string): TermCategory { @@ -301,7 +304,7 @@ export const RenewalCalendar: React.FC = () => { ? `${parseErrors.length} parse issue(s) — some rows may be missing: ${parseErrors .slice(0, 3) // +2 adjusts for 1-based row numbers plus the header row - .map(e => `row ${e.row != null ? e.row + 2 : '?'}: ${e.message}`) + .map(e => `row ${e.row != null ? e.row + CSV_ROW_NUMBER_OFFSET : '?'}: ${e.message}`) .join('; ')}${parseErrors.length > 3 ? ` … and ${parseErrors.length - 3} more` : ''}` : null;