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:
co-authored by
Cursor
parent
a52f963f52
commit
e2fb08cccd
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user