From b50e4fd812f92199e2d2b28429d59c0d6643a06c Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 26 Aug 2026 10:58:47 +0330 Subject: [PATCH] Add step date picker and show full renewal dates. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace native date inputs with year→month→day glass picker and display exact next renewal dates in the businesses list. Co-authored-by: Cursor --- .../src/components/StepDatePicker.module.css | 223 +++++++++++++ .../src/components/StepDatePicker.tsx | 299 ++++++++++++++++++ .../src/pages/BusinessesPage.module.css | 2 +- apps/super-admin/src/pages/BusinessesPage.tsx | 27 +- 4 files changed, 539 insertions(+), 12 deletions(-) create mode 100644 apps/super-admin/src/components/StepDatePicker.module.css create mode 100644 apps/super-admin/src/components/StepDatePicker.tsx diff --git a/apps/super-admin/src/components/StepDatePicker.module.css b/apps/super-admin/src/components/StepDatePicker.module.css new file mode 100644 index 0000000..49c3c84 --- /dev/null +++ b/apps/super-admin/src/components/StepDatePicker.module.css @@ -0,0 +1,223 @@ +.root { + position: relative; + width: 100%; +} + +.trigger { + width: 100%; + min-height: var(--field-height); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: var(--field-padding-y) var(--field-padding-x); + font-size: var(--field-font-size); + font-family: var(--font-ui); + color: var(--text-primary); + text-align: left; + background: rgba(255, 255, 255, 0.65); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); + transition: border-color 0.2s, box-shadow 0.2s; + cursor: pointer; +} + +.trigger:hover:not(:disabled) { + border-color: rgba(var(--primary-rgb) / 0.35); +} + +.triggerOpen, +.trigger:focus-visible { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); + outline: none; +} + +.trigger:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.triggerPlaceholder .triggerText { + color: var(--text-muted); +} + +.triggerText { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.icon { + flex-shrink: 0; + color: var(--text-muted); +} + +.panel { + position: static; + margin-top: 6px; + padding: 12px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + box-shadow: var(--glass-shadow); +} + +.panelHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.backBtn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + color: var(--text-secondary); + transition: background 0.15s, color 0.15s; +} + +.backBtn:hover { + background: rgba(var(--primary-rgb) / 0.1); + color: var(--primary); +} + +.backSpacer { + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.steps { + flex: 1; + display: flex; + gap: 6px; + justify-content: center; +} + +.stepChip { + min-height: 28px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + background: rgba(148, 163, 184, 0.12); + transition: background 0.15s, color 0.15s; +} + +.stepChip:hover:not(.stepStatic) { + background: rgba(var(--primary-rgb) / 0.12); + color: var(--primary); +} + +.stepActive { + background: rgba(var(--primary-rgb) / 0.16); + color: var(--primary); +} + +.stepStatic { + cursor: default; +} + +.yearList { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + max-height: 220px; + overflow-y: auto; +} + +.monthGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.optionBtn { + min-height: 36px; + padding: 8px 10px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.55); + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.optionBtn:hover { + background: rgba(var(--primary-rgb) / 0.1); + border-color: rgba(var(--primary-rgb) / 0.2); +} + +.optionSelected { + background: rgba(var(--primary-rgb) / 0.14); + border-color: rgba(var(--primary-rgb) / 0.35); + color: var(--primary); +} + +.optionToday:not(.optionSelected) { + box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb) / 0.35); +} + +.dayPanel { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weekdayRow, +.dayGrid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; +} + +.weekday { + text-align: center; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + padding: 4px 0; +} + +.dayEmpty { + min-height: 34px; +} + +.dayBtn { + min-height: 34px; + border-radius: 50%; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} + +.dayBtn:hover { + background: rgba(var(--primary-rgb) / 0.12); +} + +.daySelected { + background: var(--primary) !important; + color: #fff !important; + box-shadow: 0 2px 8px rgba(var(--primary-rgb) / 0.35); +} + +.dayToday:not(.daySelected) { + background: rgba(var(--primary-rgb) / 0.14); + color: var(--primary); +} + +.dayPrefill:not(.daySelected):not(.dayToday) { + box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb) / 0.35); +} diff --git a/apps/super-admin/src/components/StepDatePicker.tsx b/apps/super-admin/src/components/StepDatePicker.tsx new file mode 100644 index 0000000..f33a24c --- /dev/null +++ b/apps/super-admin/src/components/StepDatePicker.tsx @@ -0,0 +1,299 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { CalendarDays, ChevronLeft } from 'lucide-react' +import styles from './StepDatePicker.module.css' + +type Step = 'year' | 'month' | 'day' + +interface StepDatePickerProps { + id?: string + value: string + onChange: (isoDate: string) => void + disabled?: boolean + placeholder?: string +} + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const + +function parseIsoDate(value: string | null | undefined): { + year: number + month: number + day: number +} | null { + if (!value?.trim()) return null + const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})/) + if (!match) return null + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + if (!year || month < 1 || month > 12 || day < 1 || day > 31) return null + const probe = new Date(year, month - 1, day) + if ( + probe.getFullYear() !== year || + probe.getMonth() !== month - 1 || + probe.getDate() !== day + ) { + return null + } + return { year, month, day } +} + +function toIsoDate(year: number, month: number, day: number): string { + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` +} + +function formatDisplayDate(iso: string): string { + const parsed = parseIsoDate(iso) + if (!parsed) return '' + return new Date(parsed.year, parsed.month - 1, parsed.day).toLocaleDateString( + 'en-US', + { month: 'short', day: 'numeric', year: 'numeric' }, + ) +} + +function daysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate() +} + +function startWeekday(year: number, month: number): number { + return new Date(year, month - 1, 1).getDay() +} + +export function StepDatePicker({ + id, + value, + onChange, + disabled = false, + placeholder = 'Select date', +}: StepDatePickerProps) { + const autoId = useId() + const fieldId = id ?? autoId + const rootRef = useRef(null) + const [open, setOpen] = useState(false) + const [step, setStep] = useState('year') + + const today = useMemo(() => { + const now = new Date() + return { + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + } + }, []) + + const selected = parseIsoDate(value) + const [draftYear, setDraftYear] = useState(selected?.year ?? today.year) + const [draftMonth, setDraftMonth] = useState(selected?.month ?? today.month) + + const years = useMemo(() => { + const start = today.year - 8 + const end = today.year + 12 + return Array.from({ length: end - start + 1 }, (_, i) => start + i) + }, [today.year]) + + useEffect(() => { + if (!open) return + const onPointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false) + } + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + function openPicker() { + if (disabled) return + const initial = selected ?? today + setDraftYear(initial.year) + setDraftMonth(initial.month) + setStep('year') + setOpen(true) + } + + function pickYear(year: number) { + setDraftYear(year) + setStep('month') + } + + function pickMonth(month: number) { + setDraftMonth(month) + setStep('day') + } + + function pickDay(day: number) { + onChange(toIsoDate(draftYear, draftMonth, day)) + setOpen(false) + } + + const display = value ? formatDisplayDate(value) : '' + const dayCount = daysInMonth(draftYear, draftMonth) + const padStart = startWeekday(draftYear, draftMonth) + const highlightDay = + selected && selected.year === draftYear && selected.month === draftMonth + ? selected.day + : draftYear === today.year && draftMonth === today.month + ? today.day + : null + + return ( +
+ + + {open ? ( +
+
+ {step !== 'year' ? ( + + ) : ( + + )} +
+ + + + Day + +
+
+ + {step === 'year' ? ( +
+ {years.map((year) => ( + + ))} +
+ ) : null} + + {step === 'month' ? ( +
+ {MONTHS.map((label, index) => { + const month = index + 1 + return ( + + ) + })} +
+ ) : null} + + {step === 'day' ? ( +
+
+ {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => ( + + {d} + + ))} +
+
+ {Array.from({ length: padStart }, (_, i) => ( + + ))} + {Array.from({ length: dayCount }, (_, i) => { + const day = i + 1 + const isSelected = + selected?.year === draftYear && + selected?.month === draftMonth && + selected?.day === day + const isToday = + draftYear === today.year && + draftMonth === today.month && + day === today.day + return ( + + ) + })} +
+
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/apps/super-admin/src/pages/BusinessesPage.module.css b/apps/super-admin/src/pages/BusinessesPage.module.css index c76f3ad..bc1f701 100644 --- a/apps/super-admin/src/pages/BusinessesPage.module.css +++ b/apps/super-admin/src/pages/BusinessesPage.module.css @@ -563,7 +563,7 @@ gap: 2px; } -.renewalYear { +.renewalDate { font-size: 13px; font-weight: 600; color: var(--text-primary); diff --git a/apps/super-admin/src/pages/BusinessesPage.tsx b/apps/super-admin/src/pages/BusinessesPage.tsx index 3789783..f6af15e 100644 --- a/apps/super-admin/src/pages/BusinessesPage.tsx +++ b/apps/super-admin/src/pages/BusinessesPage.tsx @@ -14,6 +14,8 @@ import { } from 'lucide-react' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { BusinessActionsOverflowMenu } from '../components/BusinessActionsOverflowMenu' +import { MultiSelectDropdown } from '../components/MultiSelectDropdown' +import { StepDatePicker } from '../components/StepDatePicker' import { ToggleSwitch } from '../components/ToggleSwitch' import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber' import type { @@ -45,7 +47,6 @@ import { 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' import { useToast } from '../context/ToastContext' import { @@ -148,11 +149,11 @@ function formatRenewalCell( ) { const contractAt = annualRenewalAt || createdAt || null if (!contractAt) { - return { yearLabel: '—', daysLabel: null as string | null, tone: null as 'ok' | 'warn' | 'danger' | null } + return { dateLabel: '—', daysLabel: null as string | null, tone: null as 'ok' | 'warn' | 'danger' | null } } const next = nextAnnualRenewal(contractAt) if (!next) { - return { yearLabel: '—', daysLabel: null, tone: null } + return { dateLabel: '—', daysLabel: null, tone: null } } const daysLabel = next.daysRemaining === 0 @@ -161,7 +162,11 @@ function formatRenewalCell( ? '1 day left' : `${next.daysRemaining} days left` return { - yearLabel: String(next.year), + dateLabel: next.next.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }), daysLabel, tone: renewalTone(next.daysRemaining), } @@ -1294,7 +1299,7 @@ export function BusinessesPage() { {b.name}
- {renewal.yearLabel} + {renewal.dateLabel} {renewal.daysLabel ? (
- setEditAnnualRenewalAt(e.target.value)} + onChange={setEditAnnualRenewalAt} disabled={editSubmitting} + placeholder="Select renewal date" />
@@ -2077,12 +2082,12 @@ export function BusinessesPage() {
- setCreateAnnualRenewalAt(e.target.value)} + onChange={setCreateAnnualRenewalAt} disabled={createSubmitting} + placeholder="Select renewal date" />