diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx
index 50422ba..58db37b 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 ──────────────────────────────────────────────────────────────────
@@ -57,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 {
@@ -94,6 +112,29 @@ 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})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
+ if (!m) return null;
+ 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 */
+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 +205,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 +223,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 +256,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 +270,114 @@ 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) => {
+ // Surface parse errors as a warning; Papa may still return partial data
+ 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 + CSV_ROW_NUMBER_OFFSET : '?'}: ${e.message}`)
+ .join('; ')}${parseErrors.length > 3 ? ` … and ${parseErrors.length - 3} more` : ''}`
+ : null;
+
+ const rows = results.data as Record[];
+ if (!rows.length) {
+ 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(
+ 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();
+ 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)
+ : Number.POSITIVE_INFINITY;
+ 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
+ if (estFileInputRef.current) estFileInputRef.current.value = '';
+ },
+ error: (err: any) => {
+ setEstUploadError(`Parse error: ${err.message}`);
+ if (estFileInputRef.current) estFileInputRef.current.value = '';
+ },
+ });
+ };
+
+ // 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 +401,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 +435,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 +483,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 +541,34 @@ export const RenewalCalendar: React.FC = () => {
}}>
Export
+
+ {/* EST Upload */}
+
+ {estUploadRecords.length > 0 && (
+
+ )}
+ {estUploadError && (
+
+ {estUploadError}
+
+ )}
{/* Term legend */}
@@ -468,7 +624,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 +633,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 +655,6 @@ export const RenewalCalendar: React.FC = () => {
+{dayRenewals.length - 6}
)}
- {estCount > 0 && (
-
- EST{estCount > 1 ? ` ×${estCount}` : ''}
-
- )}
);
})}
@@ -541,47 +690,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) : '—'}
+
+
+ {!isFinite(r.daysUntilEST) ? '—' : 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 &&
}
+
+ This subscription is configured to enter Extended Service Term when its annual term expires
@@ -663,20 +818,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) : '—'} · {!isFinite(r.daysUntilEST) ? '—' : r.daysUntilEST < 0 ? 'overdue' : r.daysUntilEST === 0 ? 'today' : `${r.daysUntilEST}d`}
+
+
+ );
+ })}
)}