Show contract renewal in businesses and align SSL/view charts.

Add annual renewal column with color-coded days left, owner title and renewal fields on create, SSL expiry display, and updated view chart kinds.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 10:13:06 +03:30
co-authored by Cursor
parent a52f963f52
commit e2fb08cccd
11 changed files with 243 additions and 27 deletions
+124 -14
View File
@@ -43,6 +43,7 @@ import {
type LegacyMigrateLookupResponse,
} from '../services/businessService'
import { listBusinessCategories } from '../services/categoryService'
import { listUserTitles, type UserTitleOption } from '../services/userTitleService'
import { Modal } from '../components/Modal'
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
@@ -100,10 +101,56 @@ function emptyModuleSelection(
) as Record<BusinessModuleId, boolean>
}
function formatDate(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
/** Next anniversary of the contract date (renews every year). */
function nextAnnualRenewal(contractAt: string, now = new Date()) {
const contract = new Date(contractAt)
if (Number.isNaN(contract.getTime())) return null
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
let next = new Date(
startToday.getFullYear(),
contract.getMonth(),
contract.getDate(),
)
if (next.getTime() < startToday.getTime()) {
next = new Date(
startToday.getFullYear() + 1,
contract.getMonth(),
contract.getDate(),
)
}
const daysRemaining = Math.round(
(next.getTime() - startToday.getTime()) / (24 * 60 * 60 * 1000),
)
return { next, year: next.getFullYear(), daysRemaining }
}
function renewalTone(daysRemaining: number): 'ok' | 'warn' | 'danger' {
const months = daysRemaining / 30.437
if (months > 8) return 'ok'
if (months >= 2) return 'warn'
return 'danger'
}
function formatRenewalCell(annualRenewalAt: string | null) {
if (!annualRenewalAt) {
return { yearLabel: '—', daysLabel: null as string | null, tone: null as 'ok' | 'warn' | 'danger' | null }
}
const next = nextAnnualRenewal(annualRenewalAt)
if (!next) {
return { yearLabel: '—', daysLabel: null, tone: null }
}
const daysLabel =
next.daysRemaining === 0
? 'Renews today'
: next.daysRemaining === 1
? '1 day left'
: `${next.daysRemaining} days left`
return {
yearLabel: String(next.year),
daysLabel,
tone: renewalTone(next.daysRemaining),
}
}
const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
@@ -253,6 +300,8 @@ export function BusinessesPage() {
const [createNameFa, setCreateNameFa] = useState('')
const [createAbout, setCreateAbout] = useState('')
const [createCategoryIds, setCreateCategoryIds] = useState<number[]>([])
const [createAnnualRenewalAt, setCreateAnnualRenewalAt] = useState('')
const [createOwnerTitleId, setCreateOwnerTitleId] = useState('')
const [createOwnerFirstName, setCreateOwnerFirstName] = useState('')
const [createOwnerLastName, setCreateOwnerLastName] = useState('')
const [createOwnerFirstNameEn, setCreateOwnerFirstNameEn] = useState('')
@@ -261,6 +310,7 @@ export function BusinessesPage() {
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [userTitles, setUserTitles] = useState<UserTitleOption[]>([])
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
@@ -324,7 +374,7 @@ export function BusinessesPage() {
setLoading(true)
setError('')
try {
const [result, categoryResult] = await Promise.all([
const [result, categoryResult, titlesResult] = await Promise.all([
listBusinesses(
{
page,
@@ -334,10 +384,12 @@ export function BusinessesPage() {
controller.signal,
),
listBusinessCategories(controller.signal),
listUserTitles(controller.signal),
])
if (controller.signal.aborted) return
setData(result)
setCategories(categoryResult.items)
setUserTitles(titlesResult.items)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
@@ -968,6 +1020,8 @@ export function BusinessesPage() {
setCreateNameFa('')
setCreateAbout('')
setCreateCategoryIds([])
setCreateAnnualRenewalAt('')
setCreateOwnerTitleId('')
setCreateOwnerFirstName('')
setCreateOwnerLastName('')
setCreateOwnerFirstNameEn('')
@@ -994,6 +1048,12 @@ export function BusinessesPage() {
nameFa: createNameFa.trim(),
about: createAbout.trim() || undefined,
categoryIds: createCategoryIds,
...(createAnnualRenewalAt
? { annualRenewalAt: new Date(createAnnualRenewalAt).toISOString() }
: {}),
...(createOwnerTitleId
? { ownerTitleId: Number(createOwnerTitleId) }
: {}),
ownerFirstName: createOwnerFirstName.trim(),
ownerLastName: createOwnerLastName.trim(),
ownerFirstNameEn: createOwnerFirstNameEn.trim(),
@@ -1172,7 +1232,7 @@ export function BusinessesPage() {
<thead>
<tr>
<th className={styles.th}>Name</th>
<th className={styles.th}>Date created</th>
<th className={styles.th}>Contract Data / Renewal</th>
<th className={styles.th}>Domain</th>
<th className={styles.th}>Owner</th>
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
@@ -1199,10 +1259,29 @@ export function BusinessesPage() {
)}
{!loading &&
data?.items?.map((b) => (
data?.items?.map((b) => {
const renewal = formatRenewalCell(b.annualRenewalAt ?? null)
return (
<tr key={b.id}>
<td className={styles.td}>{b.name}</td>
<td className={styles.td}>{formatDate(b.createdAt)}</td>
<td className={styles.td}>
<div className={styles.renewalCell}>
<span className={styles.renewalYear}>{renewal.yearLabel}</span>
{renewal.daysLabel ? (
<span
className={`${styles.renewalDays} ${
renewal.tone === 'ok'
? styles.renewalOk
: renewal.tone === 'warn'
? styles.renewalWarn
: styles.renewalDanger
}`}
>
{renewal.daysLabel}
</span>
) : null}
</div>
</td>
<td className={styles.td}>
{b.domain ? (
<div className={styles.domainCell}>
@@ -1355,7 +1434,8 @@ export function BusinessesPage() {
</div>
</td>
</tr>
))}
)
})}
</tbody>
</table>
</div>
@@ -1945,7 +2025,7 @@ export function BusinessesPage() {
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<div className={`${styles.field} ${styles.formCol8}`}>
<label htmlFor="create-categories">Categories</label>
<MultiSelectDropdown
id="create-categories"
@@ -1957,11 +2037,39 @@ export function BusinessesPage() {
/>
</div>
<div className={`${styles.field} ${styles.formCol4}`}>
<label htmlFor="create-annual-renewal">Annual renewal</label>
<input
id="create-annual-renewal"
type="date"
value={createAnnualRenewalAt}
onChange={(e) => setCreateAnnualRenewalAt(e.target.value)}
disabled={createSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<div className={styles.sectionLabel}>Owner</div>
</div>
<div className={styles.field}>
<div className={`${styles.field} ${styles.formCol2}`}>
<label htmlFor="create-owner-title">Title</label>
<select
id="create-owner-title"
value={createOwnerTitleId}
onChange={(e) => setCreateOwnerTitleId(e.target.value)}
disabled={createSubmitting || userTitles.length === 0}
>
<option value="">—</option>
{userTitles.map((title) => (
<option key={title.id} value={title.id}>
{title.nameEn} / {title.nameFa}
</option>
))}
</select>
</div>
<div className={`${styles.field} ${styles.formCol4}`}>
<label htmlFor="create-owner-first-name-en">First name (EN)</label>
<input
id="create-owner-first-name-en"
@@ -1972,7 +2080,7 @@ export function BusinessesPage() {
/>
</div>
<div className={styles.field}>
<div className={`${styles.field} ${styles.formCol4}`}>
<label htmlFor="create-owner-last-name-en">Last name (EN)</label>
<input
id="create-owner-last-name-en"
@@ -1983,7 +2091,9 @@ export function BusinessesPage() {
/>
</div>
<div className={styles.field}>
<div className={`${styles.field} ${styles.formCol2}`} aria-hidden="true" />
<div className={`${styles.field} ${styles.formCol4}`}>
<label htmlFor="create-owner-first-name">First name (FA)</label>
<input
id="create-owner-first-name"
@@ -1997,7 +2107,7 @@ export function BusinessesPage() {
/>
</div>
<div className={styles.field}>
<div className={`${styles.field} ${styles.formCol4}`}>
<label htmlFor="create-owner-last-name">Last name (FA)</label>
<input
id="create-owner-last-name"