2026-05-20 18:09:11 -04:00
|
|
|
export function formatCurrency(amount: number): string {
|
|
|
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 17:29:37 -04:00
|
|
|
export function formatBidAmount(amount: number): string {
|
|
|
|
|
return amount > 0 ? formatCurrency(amount) : 'Not priced';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 18:09:11 -04:00
|
|
|
export function formatDate(iso: string): string {
|
|
|
|
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatDateTime(iso: string | null): string {
|
|
|
|
|
if (!iso) return '-';
|
|
|
|
|
return new Date(iso).toLocaleString('en-US', {
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
hour: 'numeric',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
});
|
|
|
|
|
}
|
Add UX improvements: clickable KPIs, status tabs, revision grouping, workspace restructure, form sections
Medium-effort improvements:
- Shrink KPI cards and make each clickable (navigates to filtered list)
- Role-specific KPI labels (admin: All Proposals/Pending Review; dispatcher: Total Submitted/In Review)
- Reorder sidebar nav per role (admins see Admin section first)
- Add WO# and Priority columns to Dashboard recent proposals table
- Replace "View All" with "View All Proposals" button with arrow icon
- Add Age column to admin queue with color-coded staleness (>2d orange, >5d red)
Heavy-lift improvements:
- Status tabs on All Proposals page (replace status dropdown with All/In Review/Approved/Sent/Revised tabs)
- Group proposal revisions in tables (expand/collapse, latest shown by default)
- Collapsible left panel in admin workspace (chevron toggle, center panel expands to fill)
- Sticky action bar with total display, item count, vendor cost, compact unsaved-changes chip
- Restructure proposal form into 3 card sections (Job Details, Site & Location, Work Details)
- Disabled-submit helper text showing missing required fields
- Compact status timeline with timestamps under completed steps
- Status explanation below timeline (e.g., "Awaiting admin pricing and approval")
2026-05-22 17:50:14 -04:00
|
|
|
|
|
|
|
|
export function formatAge(iso: string): string {
|
|
|
|
|
const diffMs = Date.now() - new Date(iso).getTime();
|
|
|
|
|
const diffMinutes = Math.floor(diffMs / 60000);
|
|
|
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
|
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
|
|
|
|
|
|
|
|
if (diffHours < 1) return `${Math.max(diffMinutes, 0)}m`;
|
|
|
|
|
if (diffDays < 1) return `${diffHours}h`;
|
|
|
|
|
if (diffDays < 7) return `${diffDays}d`;
|
|
|
|
|
return `${diffWeeks}w`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getAgeColor(iso: string): string | undefined {
|
|
|
|
|
const diffDays = (Date.now() - new Date(iso).getTime()) / 86400000;
|
|
|
|
|
if (diffDays > 5) return '#d32f2f';
|
|
|
|
|
if (diffDays > 2) return '#ed6c02';
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GroupedProposal<T extends { proposalNumber: string }> {
|
|
|
|
|
latest: T;
|
|
|
|
|
revisions: T[];
|
|
|
|
|
hasRevisions: boolean;
|
|
|
|
|
baseNumber: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function groupRevisions<T extends { proposalNumber: string }>(rows: T[]): GroupedProposal<T>[] {
|
|
|
|
|
const groups = new Map<string, T[]>();
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
const base = row.proposalNumber.replace(/-R\d+$/, '');
|
|
|
|
|
const existing = groups.get(base);
|
|
|
|
|
if (existing) {
|
|
|
|
|
existing.push(row);
|
|
|
|
|
} else {
|
|
|
|
|
groups.set(base, [row]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result: GroupedProposal<T>[] = [];
|
|
|
|
|
for (const [baseNumber, items] of groups) {
|
|
|
|
|
items.sort((a, b) => {
|
|
|
|
|
const revA = extractRevision(a.proposalNumber);
|
|
|
|
|
const revB = extractRevision(b.proposalNumber);
|
|
|
|
|
return revB - revA;
|
|
|
|
|
});
|
|
|
|
|
result.push({
|
|
|
|
|
latest: items[0]!,
|
|
|
|
|
revisions: items.slice(1),
|
|
|
|
|
hasRevisions: items.length > 1,
|
|
|
|
|
baseNumber,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractRevision(proposalNumber: string): number {
|
|
|
|
|
const match = proposalNumber.match(/-R(\d+)$/);
|
|
|
|
|
return match ? parseInt(match[1]!, 10) : 0;
|
|
|
|
|
}
|