Complete TypeScript migration
- Convert all shared components to TypeScript (.jsx → .tsx) - Carousel, ChartCard, EmptyState, FilterControls, StatCard, ToggleSwitch - Add proper TypeScript interfaces for all component props - Delete unused dataService.legacy.ts (archived Google Sheets code) - Build passes successfully
This commit is contained in:
@@ -1,4 +1,13 @@
|
|||||||
import React, { useRef, useCallback, useState } from 'react';
|
import React, { useRef, useCallback, useState, ReactNode, KeyboardEvent, TouchEvent } from 'react';
|
||||||
|
|
||||||
|
interface CarouselProps {
|
||||||
|
children: ReactNode;
|
||||||
|
activeIndex: number;
|
||||||
|
setActiveIndex: (index: number) => void;
|
||||||
|
labels?: string[];
|
||||||
|
showLabels?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function Carousel({
|
function Carousel({
|
||||||
children,
|
children,
|
||||||
@@ -7,10 +16,10 @@ function Carousel({
|
|||||||
labels = [],
|
labels = [],
|
||||||
showLabels = true,
|
showLabels = true,
|
||||||
className = ''
|
className = ''
|
||||||
}) {
|
}: CarouselProps) {
|
||||||
const touchStartX = useRef(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
const touchStartY = useRef(null);
|
const touchStartY = useRef<number | null>(null);
|
||||||
const trackRef = useRef(null);
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragOffset, setDragOffset] = useState(0);
|
const [dragOffset, setDragOffset] = useState(0);
|
||||||
const itemCount = React.Children.count(children);
|
const itemCount = React.Children.count(children);
|
||||||
@@ -19,20 +28,20 @@ function Carousel({
|
|||||||
const SWIPE_THRESHOLD = 50;
|
const SWIPE_THRESHOLD = 50;
|
||||||
const VELOCITY_THRESHOLD = 0.3;
|
const VELOCITY_THRESHOLD = 0.3;
|
||||||
|
|
||||||
const handleTouchStart = useCallback((e) => {
|
const handleTouchStart = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
||||||
touchStartX.current = e.touches[0].clientX;
|
touchStartX.current = e.touches[0].clientX;
|
||||||
touchStartY.current = e.touches[0].clientY;
|
touchStartY.current = e.touches[0].clientY;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setDragOffset(0);
|
setDragOffset(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTouchMove = useCallback((e) => {
|
const handleTouchMove = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
||||||
if (!touchStartX.current || !isDragging) return;
|
if (!touchStartX.current || !isDragging) return;
|
||||||
|
|
||||||
const currentX = e.touches[0].clientX;
|
const currentX = e.touches[0].clientX;
|
||||||
const currentY = e.touches[0].clientY;
|
const currentY = e.touches[0].clientY;
|
||||||
const diffX = currentX - touchStartX.current;
|
const diffX = currentX - touchStartX.current;
|
||||||
const diffY = currentY - touchStartY.current;
|
const diffY = currentY - (touchStartY.current || 0);
|
||||||
|
|
||||||
// Only handle horizontal swipes
|
// Only handle horizontal swipes
|
||||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
if (Math.abs(diffX) > Math.abs(diffY)) {
|
||||||
@@ -46,7 +55,7 @@ function Carousel({
|
|||||||
}
|
}
|
||||||
}, [isDragging, activeIndex, itemCount]);
|
}, [isDragging, activeIndex, itemCount]);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback((e) => {
|
const handleTouchEnd = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
||||||
if (!touchStartX.current || !isDragging) return;
|
if (!touchStartX.current || !isDragging) return;
|
||||||
|
|
||||||
const endX = e.changedTouches[0].clientX;
|
const endX = e.changedTouches[0].clientX;
|
||||||
@@ -69,7 +78,7 @@ function Carousel({
|
|||||||
setDragOffset(0);
|
setDragOffset(0);
|
||||||
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
|
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === 'ArrowLeft' && activeIndex > 0) {
|
if (e.key === 'ArrowLeft' && activeIndex > 0) {
|
||||||
setActiveIndex(activeIndex - 1);
|
setActiveIndex(activeIndex - 1);
|
||||||
} else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
|
} else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ChartCardProps {
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
headerRight?: ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
halfWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function ChartCard({
|
function ChartCard({
|
||||||
title,
|
title,
|
||||||
@@ -7,7 +16,7 @@ function ChartCard({
|
|||||||
headerRight = null,
|
headerRight = null,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
halfWidth = false
|
halfWidth = false
|
||||||
}) {
|
}: ChartCardProps) {
|
||||||
const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
|
const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
action?: (() => void) | null;
|
||||||
|
actionLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyState({
|
function EmptyState({
|
||||||
icon = '📊',
|
icon = '📊',
|
||||||
title,
|
title,
|
||||||
@@ -7,7 +16,7 @@ function EmptyState({
|
|||||||
action = null,
|
action = null,
|
||||||
actionLabel = 'Try Again',
|
actionLabel = 'Try Again',
|
||||||
className = ''
|
className = ''
|
||||||
}) {
|
}: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`empty-state ${className}`}>
|
<div className={`empty-state ${className}`}>
|
||||||
<div className="empty-state-icon" role="img" aria-hidden="true">
|
<div className="empty-state-icon" role="img" aria-hidden="true">
|
||||||
@@ -1,13 +1,35 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, ReactNode, KeyboardEvent } from 'react';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
function FilterControls({
|
interface FilterControlsProps {
|
||||||
|
children: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
onReset?: (() => void) | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterGroupProps {
|
||||||
|
label?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterRowProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterControlsComponent extends React.FC<FilterControlsProps> {
|
||||||
|
Group: React.FC<FilterGroupProps>;
|
||||||
|
Row: React.FC<FilterRowProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterControls: FilterControlsComponent = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
defaultExpanded = true,
|
defaultExpanded = true,
|
||||||
onReset = null,
|
onReset = null,
|
||||||
className = ''
|
className = ''
|
||||||
}) {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const displayTitle = title || t('filters.title');
|
const displayTitle = title || t('filters.title');
|
||||||
|
|
||||||
@@ -36,6 +58,13 @@ function FilterControls({
|
|||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleExpanded();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
|
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
|
||||||
<div
|
<div
|
||||||
@@ -44,12 +73,7 @@ function FilterControls({
|
|||||||
role="button"
|
role="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={handleKeyDown}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleExpanded();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<h3>{displayTitle}</h3>
|
<h3>{displayTitle}</h3>
|
||||||
<div className="controls-header-actions">
|
<div className="controls-header-actions">
|
||||||
@@ -85,20 +109,20 @@ function FilterControls({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function FilterGroup({ label, children }) {
|
const FilterGroup: React.FC<FilterGroupProps> = ({ label, children }) => {
|
||||||
return (
|
return (
|
||||||
<div className="control-group">
|
<div className="control-group">
|
||||||
{label && <label>{label}</label>}
|
{label && <label>{label}</label>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function FilterRow({ children }) {
|
const FilterRow: React.FC<FilterRowProps> = ({ children }) => {
|
||||||
return <div className="control-row">{children}</div>;
|
return <div className="control-row">{children}</div>;
|
||||||
}
|
};
|
||||||
|
|
||||||
FilterControls.Group = FilterGroup;
|
FilterControls.Group = FilterGroup;
|
||||||
FilterControls.Row = FilterRow;
|
FilterControls.Row = FilterRow;
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }) {
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
change?: number | null;
|
||||||
|
changeLabel?: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }: StatCardProps) {
|
||||||
const isPositive = change !== null && change >= 0;
|
const isPositive = change !== null && change >= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
function ToggleSwitch({ options, value, onChange, className = '' }) {
|
interface ToggleOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleSwitchProps {
|
||||||
|
options: ToggleOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSwitch({ options, value, onChange, className = '' }: ToggleSwitchProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`toggle-switch ${className}`} role="radiogroup">
|
<div className={`toggle-switch ${className}`} role="radiogroup">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// ============================================
|
|
||||||
// ARCHIVED - Google Sheets Data Fetching
|
|
||||||
// ============================================
|
|
||||||
// Kept for reference only - NOT used in the app
|
|
||||||
// The app now uses NocoDB exclusively with offline caching
|
|
||||||
|
|
||||||
const SPREADSHEET_ID = process.env.REACT_APP_SHEETS_ID || '';
|
|
||||||
const SHEET_NAME = process.env.REACT_APP_SHEETS_NAME || 'Consolidated Data';
|
|
||||||
const SHEET_URL = SPREADSHEET_ID
|
|
||||||
? `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Convert Excel serial date to YYYY-MM-DD
|
|
||||||
function excelDateToYMD(serial) {
|
|
||||||
const num = parseInt(serial);
|
|
||||||
if (isNaN(num) || num < 1) return null;
|
|
||||||
|
|
||||||
const utcDays = Math.floor(num - 25569);
|
|
||||||
const date = new Date(utcDays * 86400 * 1000);
|
|
||||||
|
|
||||||
const y = date.getUTCFullYear();
|
|
||||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
||||||
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCSV(text) {
|
|
||||||
const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
||||||
const lines = normalizedText.trim().split('\n');
|
|
||||||
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
|
|
||||||
|
|
||||||
return lines.slice(1).map(line => {
|
|
||||||
const values = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let char of line) {
|
|
||||||
if (char === '"') {
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
} else if (char === ',' && !inQuotes) {
|
|
||||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
|
||||||
current = '';
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
|
||||||
|
|
||||||
const obj = {};
|
|
||||||
headers.forEach((header, i) => {
|
|
||||||
let val = values[i] || '';
|
|
||||||
if (header === 'date' && /^\d+$/.test(val)) {
|
|
||||||
val = excelDateToYMD(val);
|
|
||||||
}
|
|
||||||
obj[header] = val;
|
|
||||||
});
|
|
||||||
return obj;
|
|
||||||
}).filter(row => row.date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSheetData() {
|
|
||||||
try {
|
|
||||||
console.log('Fetching from Google Sheets...');
|
|
||||||
const response = await fetch(SHEET_URL);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
if (text.includes('<!DOCTYPE') || text.includes('<html')) {
|
|
||||||
throw new Error('Sheet is not public');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = parseCSV(text);
|
|
||||||
console.log(`Loaded ${data.length} rows from Google Sheets`);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Fetch error:', err);
|
|
||||||
throw new Error(`Failed to load data: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user