feat(07.1-05): user-tz on admin data-browser DataTable columns
- Add useUserTimezone() to 6 admin/data-browser pages
- Thread { timeZone: tz } into 8 DataTable column render() calls
- Delete orphaned app/admin/data-browser/time-entries/page.tsx.backup
(per audit footnote — never imported, contained 1 leak at line 166)
Files: contracts, projects, tasks, ticket-notes, tickets, time-entries
Migrates 8 of 81 audit leak callsites.
This commit is contained in:
parent
82958c5ec6
commit
b417988ee6
7 changed files with 19 additions and 400 deletions
|
|
@ -8,8 +8,10 @@ import { Button } from '@/components/ui/button';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft, Table2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||||
|
||||
export default function ContractsBrowserPage() {
|
||||
const tz = useUserTimezone();
|
||||
const [contracts, setContracts] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
|
@ -85,7 +87,7 @@ export default function ContractsBrowserPage() {
|
|||
label: 'Start Date',
|
||||
sortable: true,
|
||||
render: (value: string) => (
|
||||
value ? new Date(value).toLocaleDateString() : '-'
|
||||
value ? new Date(value).toLocaleDateString(undefined, { timeZone: tz }) : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -93,7 +95,7 @@ export default function ContractsBrowserPage() {
|
|||
label: 'End Date',
|
||||
sortable: true,
|
||||
render: (value: string) => (
|
||||
value ? new Date(value).toLocaleDateString() : '-'
|
||||
value ? new Date(value).toLocaleDateString(undefined, { timeZone: tz }) : '-'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import { Button } from '@/components/ui/button';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft, FolderKanban } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||||
|
||||
export default function ProjectsBrowserPage() {
|
||||
const tz = useUserTimezone();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
|
@ -85,7 +87,7 @@ export default function ProjectsBrowserPage() {
|
|||
label: 'Start Date',
|
||||
sortable: true,
|
||||
render: (value: string) => (
|
||||
value ? new Date(value).toLocaleDateString() : '-'
|
||||
value ? new Date(value).toLocaleDateString(undefined, { timeZone: tz }) : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import { Button } from '@/components/ui/button';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft, CheckSquare } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||||
|
||||
export default function TasksBrowserPage() {
|
||||
const tz = useUserTimezone();
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
|
@ -97,7 +99,7 @@ export default function TasksBrowserPage() {
|
|||
label: 'Created',
|
||||
sortable: true,
|
||||
render: (value: string) => (
|
||||
value ? new Date(value).toLocaleDateString() : '-'
|
||||
value ? new Date(value).toLocaleDateString(undefined, { timeZone: tz }) : '-'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import DataTable from '@/components/admin/DataTable';
|
|||
import DetailModal from '@/components/admin/DetailModal';
|
||||
import { MessageSquare, ArrowLeft, RefreshCw, Filter, Calendar, User } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||||
|
||||
const NOTE_TYPE: Record<number, string> = {
|
||||
1: 'Task Detail', 2: 'Time Entry Note', 3: 'Ticket Detail',
|
||||
|
|
@ -23,6 +24,7 @@ const PUBLISH_MAP: Record<number, { label: string; cls: string }> = {
|
|||
};
|
||||
|
||||
export default function TicketNotesPage() {
|
||||
const tz = useUserTimezone();
|
||||
const [notes, setNotes] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -83,7 +85,7 @@ export default function TicketNotesPage() {
|
|||
label: 'Date',
|
||||
sortable: true,
|
||||
render: (v: string) => v
|
||||
? <span className="inline-flex items-center gap-1 text-xs"><Calendar className="w-3 h-3" />{new Date(v).toLocaleDateString()}</span>
|
||||
? <span className="inline-flex items-center gap-1 text-xs"><Calendar className="w-3 h-3" />{new Date(v).toLocaleDateString(undefined, { timeZone: tz })}</span>
|
||||
: <span className="text-muted-foreground">—</span>,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import { Button } from '@/components/ui/button';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft, Ticket } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||||
|
||||
export default function TicketsBrowserPage() {
|
||||
const router = useRouter();
|
||||
const tz = useUserTimezone();
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
|
|
@ -100,7 +102,7 @@ export default function TicketsBrowserPage() {
|
|||
key: 'create_date',
|
||||
label: 'Created',
|
||||
sortable: true,
|
||||
render: (value: string) => value ? new Date(value).toLocaleDateString() : '-',
|
||||
render: (value: string) => value ? new Date(value).toLocaleDateString(undefined, { timeZone: tz }) : '-',
|
||||
},
|
||||
{
|
||||
key: 'is_deleted',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
TicketX
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||||
import { TimeEntry } from '@/lib/types/database';
|
||||
|
||||
function cn(...classes: string[]) {
|
||||
|
|
@ -30,6 +31,7 @@ function cn(...classes: string[]) {
|
|||
}
|
||||
|
||||
export default function TimeEntriesPage() {
|
||||
const tz = useUserTimezone();
|
||||
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -293,7 +295,7 @@ export default function TimeEntriesPage() {
|
|||
render: (value: string) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(value).toLocaleDateString()}
|
||||
{new Date(value).toLocaleDateString(undefined, { timeZone: tz })}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,393 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import DataTable from '@/components/admin/DataTable';
|
||||
import DetailModal from '@/components/admin/DetailModal';
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
Ticket,
|
||||
Calendar,
|
||||
ArrowLeft,
|
||||
Home,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { TimeEntry } from '@/lib/types/database';
|
||||
|
||||
export default function TimeEntriesPage() {
|
||||
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<TimeEntry | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
|
||||
// Pagination states
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
|
||||
// Filter states
|
||||
const [search, setSearch] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [billable, setBillable] = useState<string>('all');
|
||||
const [approved, setApproved] = useState<string>('all');
|
||||
const [limit, setLimit] = useState('100');
|
||||
|
||||
const fetchTimeEntries = async (page: number = 1) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (search) params.append('search', search);
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
if (billable !== 'all') params.append('billable', billable);
|
||||
if (approved !== 'all') params.append('approved', approved);
|
||||
params.append('limit', pageSize.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
const response = await fetch(`/api/data/time-entries?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch time entries: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTimeEntries(data.timeEntries || []);
|
||||
setTotalCount(data.pagination?.total || 0);
|
||||
setCurrentPage(page);
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTimeEntries(1);
|
||||
}, [pageSize]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchTimeEntries(currentPage);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchTimeEntries(newPage);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ format: 'csv' });
|
||||
|
||||
if (search) params.append('search', search);
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
if (billable !== 'all') params.append('billable', billable);
|
||||
if (approved !== 'all') params.append('approved', approved);
|
||||
|
||||
const response = await fetch(`/api/data/time-entries/export?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to export data: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Download file
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `time-entries-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (entry: TimeEntry) => {
|
||||
setSelectedEntry(entry);
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'resource_id',
|
||||
label: 'Resource',
|
||||
sortable: true,
|
||||
render: (value: number) => (
|
||||
<Badge variant="outline">
|
||||
<Users className="w-3 h-3 mr-1" />
|
||||
{value}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ticket_id',
|
||||
label: 'Ticket',
|
||||
sortable: true,
|
||||
render: (value: number) => (
|
||||
<Badge variant="outline">
|
||||
<Ticket className="w-3 h-3 mr-1" />
|
||||
{value}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'entry_date',
|
||||
label: 'Date',
|
||||
sortable: true,
|
||||
render: (value: string) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(value).toLocaleDateString()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hours_worked',
|
||||
label: 'Hours',
|
||||
sortable: true,
|
||||
render: (value: number | string) => {
|
||||
const hours = typeof value === 'string' ? parseFloat(value) : value;
|
||||
return (
|
||||
<Badge variant={hours > 4 ? 'destructive' : 'secondary'}>
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{hours.toFixed(1)}h
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
sortable: true,
|
||||
render: (value: string) => (
|
||||
<div className="max-w-48 truncate" title={value}>
|
||||
{value || 'No title'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'billable',
|
||||
label: 'Billable',
|
||||
sortable: true,
|
||||
render: (value: boolean) => (
|
||||
<Badge variant={value ? 'default' : 'secondary'}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'approved',
|
||||
label: 'Approved',
|
||||
sortable: true,
|
||||
render: (value: boolean) => (
|
||||
<Badge variant={value ? 'default' : 'destructive'}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/data-browser">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Clock className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Time Entries</h1>
|
||||
<p className="text-muted-foreground">Browse and analyze time tracking data</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/admin/analytics/time-entries">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Analytics
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshCw className={cn("w-4 h-4 mr-2", loading ? "animate-spin" : "")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Search</Label>
|
||||
<Input
|
||||
placeholder="Search notes or title..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Page Size</Label>
|
||||
<Select value={pageSize.toString()} onValueChange={(val) => setPageSize(parseInt(val))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="250">250</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Billable</Label>
|
||||
<Select value={billable} onValueChange={setBillable}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non-billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Approved</Label>
|
||||
<Select value={approved} onValueChange={setApproved}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="true">Approved</SelectItem>
|
||||
<SelectItem value="false">Not approved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setBillable('all');
|
||||
setApproved('all');
|
||||
setPageSize(100);
|
||||
setCurrentPage(1);
|
||||
}}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshCw className={cn("w-4 h-4 mr-2", loading ? "animate-spin" : "")} />
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Time Entries ({timeEntries.length})</CardTitle>
|
||||
<CardDescription>
|
||||
Click on any row to view detailed information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-4 mb-4">
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={timeEntries}
|
||||
columns={columns}
|
||||
isLoading={loading}
|
||||
onRowClick={handleRowClick}
|
||||
totalCount={totalCount}
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<DetailModal
|
||||
open={showDetailModal}
|
||||
onOpenChange={(open) => setShowDetailModal(open)}
|
||||
title={`Time Entry #${selectedEntry?.id}`}
|
||||
data={selectedEntry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cn(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue