@create-lft-app/nextjs 1.0.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -3
- package/template/CLAUDE.md +279 -0
- package/template/drizzle.config.ts +12 -0
- package/template/package.json +31 -6
- package/template/proxy.ts +12 -0
- package/template/src/app/(auth)/dashboard/dashboard-content.tsx +124 -0
- package/template/src/app/(auth)/dashboard/page.tsx +9 -0
- package/template/src/app/(auth)/layout.tsx +7 -0
- package/template/src/app/(auth)/users/page.tsx +9 -0
- package/template/src/app/(auth)/users/users-content.tsx +26 -0
- package/template/src/app/(public)/layout.tsx +7 -0
- package/template/src/app/(public)/login/page.tsx +17 -0
- package/template/src/app/api/webhooks/route.ts +20 -0
- package/template/src/app/layout.tsx +13 -12
- package/template/src/app/providers.tsx +27 -0
- package/template/src/components/layout/{midday-sidebar.tsx → sidebar.tsx} +2 -7
- package/template/src/components/tables/data-table-column-header.tsx +68 -0
- package/template/src/components/tables/data-table-pagination.tsx +99 -0
- package/template/src/components/tables/data-table-toolbar.tsx +50 -0
- package/template/src/components/tables/data-table-view-options.tsx +59 -0
- package/template/src/components/tables/data-table.tsx +128 -0
- package/template/src/components/tables/index.ts +5 -0
- package/template/src/components/ui/animations/index.ts +44 -0
- package/template/src/components/ui/button.tsx +50 -21
- package/template/src/components/ui/card.tsx +27 -3
- package/template/src/components/ui/dialog.tsx +38 -35
- package/template/src/components/ui/motion.tsx +197 -0
- package/template/src/components/ui/page-transition.tsx +166 -0
- package/template/src/components/ui/sheet.tsx +65 -41
- package/template/src/config/navigation.ts +69 -0
- package/template/src/config/site.ts +12 -0
- package/template/src/db/index.ts +12 -0
- package/template/src/db/schema/index.ts +1 -0
- package/template/src/db/schema/users.ts +16 -0
- package/template/src/db/seed.ts +39 -0
- package/template/src/hooks/index.ts +3 -0
- package/template/src/hooks/useDataTable.ts +82 -0
- package/template/src/hooks/useDebounce.ts +49 -0
- package/template/src/hooks/useMediaQuery.ts +36 -0
- package/template/src/lib/date/config.ts +34 -0
- package/template/src/lib/date/formatters.ts +120 -0
- package/template/src/lib/date/index.ts +19 -0
- package/template/src/lib/excel/exporter.ts +89 -0
- package/template/src/lib/excel/index.ts +14 -0
- package/template/src/lib/excel/parser.ts +96 -0
- package/template/src/lib/query-client.ts +35 -0
- package/template/src/lib/supabase/client.ts +5 -2
- package/template/src/lib/supabase/proxy.ts +67 -0
- package/template/src/lib/supabase/server.ts +6 -4
- package/template/src/lib/supabase/types.ts +53 -0
- package/template/src/lib/validations/common.ts +75 -0
- package/template/src/lib/validations/index.ts +20 -0
- package/template/src/modules/auth/actions/auth-actions.ts +51 -4
- package/template/src/modules/auth/components/login-form.tsx +68 -0
- package/template/src/modules/auth/hooks/useAuth.ts +38 -0
- package/template/src/modules/auth/hooks/useAuthMutations.ts +43 -0
- package/template/src/modules/auth/hooks/useAuthQueries.ts +43 -0
- package/template/src/modules/auth/index.ts +12 -0
- package/template/src/modules/auth/schemas/auth.schema.ts +32 -0
- package/template/src/modules/auth/stores/useAuthStore.ts +37 -0
- package/template/src/modules/users/actions/users-actions.ts +94 -0
- package/template/src/modules/users/columns.tsx +86 -0
- package/template/src/modules/users/components/users-list.tsx +22 -0
- package/template/src/modules/users/hooks/useUsers.ts +39 -0
- package/template/src/modules/users/hooks/useUsersMutations.ts +55 -0
- package/template/src/modules/users/hooks/useUsersQueries.ts +35 -0
- package/template/src/modules/users/index.ts +12 -0
- package/template/src/modules/users/schemas/users.schema.ts +23 -0
- package/template/src/modules/users/stores/useUsersStore.ts +60 -0
- package/template/src/stores/index.ts +1 -0
- package/template/src/stores/useUiStore.ts +55 -0
- package/template/src/types/api.ts +28 -0
- package/template/src/types/index.ts +2 -0
- package/template/src/types/table.ts +34 -0
- package/template/supabase/config.toml +94 -0
- package/template/tsconfig.json +2 -1
- package/template/tsconfig.tsbuildinfo +1 -0
- package/template/next-env.d.ts +0 -6
- package/template/package-lock.json +0 -8454
- package/template/src/app/dashboard/page.tsx +0 -111
- package/template/src/components/dashboard/widget.tsx +0 -113
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
2
|
+
import postgres from 'postgres'
|
|
3
|
+
import * as schema from './schema'
|
|
4
|
+
|
|
5
|
+
const connectionString = process.env.DATABASE_URL!
|
|
6
|
+
|
|
7
|
+
// Disable prefetch as it is not supported for "Transaction" pool mode
|
|
8
|
+
const client = postgres(connectionString, { prepare: false })
|
|
9
|
+
|
|
10
|
+
export const db = drizzle(client, { schema })
|
|
11
|
+
|
|
12
|
+
export type Database = typeof db
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './users'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { pgTable, uuid, varchar, text, timestamp, pgEnum } from 'drizzle-orm/pg-core'
|
|
2
|
+
|
|
3
|
+
export const userRoleEnum = pgEnum('user_role', ['admin', 'user', 'viewer'])
|
|
4
|
+
|
|
5
|
+
export const users = pgTable('users', {
|
|
6
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
7
|
+
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
8
|
+
name: varchar('name', { length: 255 }).notNull(),
|
|
9
|
+
role: userRoleEnum('role').default('user').notNull(),
|
|
10
|
+
avatarUrl: text('avatar_url'),
|
|
11
|
+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
12
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type User = typeof users.$inferSelect
|
|
16
|
+
export type NewUser = typeof users.$inferInsert
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { db } from './index'
|
|
2
|
+
import { users } from './schema'
|
|
3
|
+
|
|
4
|
+
async function seed() {
|
|
5
|
+
console.log('🌱 Seeding database...')
|
|
6
|
+
|
|
7
|
+
// Clear existing data
|
|
8
|
+
await db.delete(users)
|
|
9
|
+
|
|
10
|
+
// Insert seed data
|
|
11
|
+
await db.insert(users).values([
|
|
12
|
+
{
|
|
13
|
+
email: 'admin@example.com',
|
|
14
|
+
name: 'Admin User',
|
|
15
|
+
role: 'admin',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
email: 'user@example.com',
|
|
19
|
+
name: 'Regular User',
|
|
20
|
+
role: 'user',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
email: 'viewer@example.com',
|
|
24
|
+
name: 'Viewer User',
|
|
25
|
+
role: 'viewer',
|
|
26
|
+
},
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
console.log('✅ Database seeded successfully!')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
seed()
|
|
33
|
+
.catch((error) => {
|
|
34
|
+
console.error('❌ Seed failed:', error)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
})
|
|
37
|
+
.finally(() => {
|
|
38
|
+
process.exit(0)
|
|
39
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ColumnDef,
|
|
6
|
+
ColumnFiltersState,
|
|
7
|
+
SortingState,
|
|
8
|
+
VisibilityState,
|
|
9
|
+
getCoreRowModel,
|
|
10
|
+
getFilteredRowModel,
|
|
11
|
+
getPaginationRowModel,
|
|
12
|
+
getSortedRowModel,
|
|
13
|
+
useReactTable,
|
|
14
|
+
TableOptions,
|
|
15
|
+
} from '@tanstack/react-table'
|
|
16
|
+
|
|
17
|
+
interface UseDataTableOptions<TData> {
|
|
18
|
+
data: TData[]
|
|
19
|
+
columns: ColumnDef<TData, unknown>[]
|
|
20
|
+
initialPageSize?: number
|
|
21
|
+
enableRowSelection?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useDataTable<TData>({
|
|
25
|
+
data,
|
|
26
|
+
columns,
|
|
27
|
+
initialPageSize = 10,
|
|
28
|
+
enableRowSelection = false,
|
|
29
|
+
}: UseDataTableOptions<TData>) {
|
|
30
|
+
const [sorting, setSorting] = useState<SortingState>([])
|
|
31
|
+
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
32
|
+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
|
33
|
+
const [rowSelection, setRowSelection] = useState({})
|
|
34
|
+
|
|
35
|
+
const tableOptions: TableOptions<TData> = useMemo(
|
|
36
|
+
() => ({
|
|
37
|
+
data,
|
|
38
|
+
columns,
|
|
39
|
+
getCoreRowModel: getCoreRowModel(),
|
|
40
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
41
|
+
onSortingChange: setSorting,
|
|
42
|
+
getSortedRowModel: getSortedRowModel(),
|
|
43
|
+
onColumnFiltersChange: setColumnFilters,
|
|
44
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
45
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
46
|
+
onRowSelectionChange: setRowSelection,
|
|
47
|
+
enableRowSelection,
|
|
48
|
+
state: {
|
|
49
|
+
sorting,
|
|
50
|
+
columnFilters,
|
|
51
|
+
columnVisibility,
|
|
52
|
+
rowSelection,
|
|
53
|
+
},
|
|
54
|
+
initialState: {
|
|
55
|
+
pagination: {
|
|
56
|
+
pageSize: initialPageSize,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
[data, columns, sorting, columnFilters, columnVisibility, rowSelection, enableRowSelection, initialPageSize]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const table = useReactTable(tableOptions)
|
|
64
|
+
|
|
65
|
+
const selectedRows = useMemo(
|
|
66
|
+
() => table.getFilteredSelectedRowModel().rows.map((row) => row.original),
|
|
67
|
+
[table]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
table,
|
|
72
|
+
sorting,
|
|
73
|
+
columnFilters,
|
|
74
|
+
columnVisibility,
|
|
75
|
+
rowSelection,
|
|
76
|
+
selectedRows,
|
|
77
|
+
setSorting,
|
|
78
|
+
setColumnFilters,
|
|
79
|
+
setColumnVisibility,
|
|
80
|
+
setRowSelection,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useDebounce<T>(value: T, delay: number): T {
|
|
6
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const timer = setTimeout(() => {
|
|
10
|
+
setDebouncedValue(value)
|
|
11
|
+
}, delay)
|
|
12
|
+
|
|
13
|
+
return () => {
|
|
14
|
+
clearTimeout(timer)
|
|
15
|
+
}
|
|
16
|
+
}, [value, delay])
|
|
17
|
+
|
|
18
|
+
return debouncedValue
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useDebouncedCallback<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
|
22
|
+
callback: T,
|
|
23
|
+
delay: number
|
|
24
|
+
): T {
|
|
25
|
+
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
26
|
+
|
|
27
|
+
const debouncedCallback = useCallback(
|
|
28
|
+
(...args: Parameters<T>) => {
|
|
29
|
+
if (timeoutRef.current) {
|
|
30
|
+
clearTimeout(timeoutRef.current)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
timeoutRef.current = setTimeout(() => {
|
|
34
|
+
callback(...args)
|
|
35
|
+
}, delay)
|
|
36
|
+
},
|
|
37
|
+
[callback, delay]
|
|
38
|
+
) as T
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
return () => {
|
|
42
|
+
if (timeoutRef.current) {
|
|
43
|
+
clearTimeout(timeoutRef.current)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
return debouncedCallback
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useMediaQuery(query: string): boolean {
|
|
6
|
+
const [matches, setMatches] = useState(false)
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const media = window.matchMedia(query)
|
|
10
|
+
|
|
11
|
+
if (media.matches !== matches) {
|
|
12
|
+
setMatches(media.matches)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const listener = (event: MediaQueryListEvent) => {
|
|
16
|
+
setMatches(event.matches)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
media.addEventListener('change', listener)
|
|
20
|
+
return () => media.removeEventListener('change', listener)
|
|
21
|
+
}, [matches, query])
|
|
22
|
+
|
|
23
|
+
return matches
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useIsMobile(): boolean {
|
|
27
|
+
return useMediaQuery('(max-width: 768px)')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useIsTablet(): boolean {
|
|
31
|
+
return useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useIsDesktop(): boolean {
|
|
35
|
+
return useMediaQuery('(min-width: 1025px)')
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import dayjs from 'dayjs'
|
|
2
|
+
import utc from 'dayjs/plugin/utc'
|
|
3
|
+
import timezone from 'dayjs/plugin/timezone'
|
|
4
|
+
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
5
|
+
import localizedFormat from 'dayjs/plugin/localizedFormat'
|
|
6
|
+
import isBetween from 'dayjs/plugin/isBetween'
|
|
7
|
+
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
|
8
|
+
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
|
9
|
+
import 'dayjs/locale/es'
|
|
10
|
+
|
|
11
|
+
// Extend dayjs with plugins
|
|
12
|
+
dayjs.extend(utc)
|
|
13
|
+
dayjs.extend(timezone)
|
|
14
|
+
dayjs.extend(relativeTime)
|
|
15
|
+
dayjs.extend(localizedFormat)
|
|
16
|
+
dayjs.extend(isBetween)
|
|
17
|
+
dayjs.extend(isSameOrAfter)
|
|
18
|
+
dayjs.extend(isSameOrBefore)
|
|
19
|
+
|
|
20
|
+
// Set default locale to Spanish
|
|
21
|
+
dayjs.locale('es')
|
|
22
|
+
|
|
23
|
+
// Set default timezone (can be overridden)
|
|
24
|
+
const DEFAULT_TIMEZONE = 'America/Mexico_City'
|
|
25
|
+
|
|
26
|
+
export function setDefaultTimezone(tz: string) {
|
|
27
|
+
dayjs.tz.setDefault(tz)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Initialize with default timezone
|
|
31
|
+
setDefaultTimezone(DEFAULT_TIMEZONE)
|
|
32
|
+
|
|
33
|
+
export { dayjs }
|
|
34
|
+
export default dayjs
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { dayjs } from './config'
|
|
2
|
+
import type { Dayjs, ConfigType } from 'dayjs'
|
|
3
|
+
|
|
4
|
+
type DateInput = ConfigType
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format date to localized short format (e.g., "15/01/2024")
|
|
8
|
+
*/
|
|
9
|
+
export function formatDate(date: DateInput): string {
|
|
10
|
+
return dayjs(date).format('DD/MM/YYYY')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format date to localized long format (e.g., "15 de enero de 2024")
|
|
15
|
+
*/
|
|
16
|
+
export function formatDateLong(date: DateInput): string {
|
|
17
|
+
return dayjs(date).format('D [de] MMMM [de] YYYY')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Format time (e.g., "14:30")
|
|
22
|
+
*/
|
|
23
|
+
export function formatTime(date: DateInput): string {
|
|
24
|
+
return dayjs(date).format('HH:mm')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format time with seconds (e.g., "14:30:45")
|
|
29
|
+
*/
|
|
30
|
+
export function formatTimeWithSeconds(date: DateInput): string {
|
|
31
|
+
return dayjs(date).format('HH:mm:ss')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format date and time (e.g., "15/01/2024 14:30")
|
|
36
|
+
*/
|
|
37
|
+
export function formatDateTime(date: DateInput): string {
|
|
38
|
+
return dayjs(date).format('DD/MM/YYYY HH:mm')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format date and time long (e.g., "15 de enero de 2024 a las 14:30")
|
|
43
|
+
*/
|
|
44
|
+
export function formatDateTimeLong(date: DateInput): string {
|
|
45
|
+
return dayjs(date).format('D [de] MMMM [de] YYYY [a las] HH:mm')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format relative time (e.g., "hace 2 horas")
|
|
50
|
+
*/
|
|
51
|
+
export function formatRelative(date: DateInput): string {
|
|
52
|
+
return dayjs(date).fromNow()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format ISO string
|
|
57
|
+
*/
|
|
58
|
+
export function formatISO(date: DateInput): string {
|
|
59
|
+
return dayjs(date).toISOString()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Format for API requests (ISO format)
|
|
64
|
+
*/
|
|
65
|
+
export function formatForApi(date: DateInput): string {
|
|
66
|
+
return dayjs(date).utc().format()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a date string and return a Dayjs object
|
|
71
|
+
*/
|
|
72
|
+
export function parseDate(date: DateInput): Dayjs {
|
|
73
|
+
return dayjs(date)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get start of day
|
|
78
|
+
*/
|
|
79
|
+
export function startOfDay(date: DateInput): Dayjs {
|
|
80
|
+
return dayjs(date).startOf('day')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get end of day
|
|
85
|
+
*/
|
|
86
|
+
export function endOfDay(date: DateInput): Dayjs {
|
|
87
|
+
return dayjs(date).endOf('day')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if a date is today
|
|
92
|
+
*/
|
|
93
|
+
export function isToday(date: DateInput): boolean {
|
|
94
|
+
return dayjs(date).isSame(dayjs(), 'day')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if a date is in the past
|
|
99
|
+
*/
|
|
100
|
+
export function isPast(date: DateInput): boolean {
|
|
101
|
+
return dayjs(date).isBefore(dayjs())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a date is in the future
|
|
106
|
+
*/
|
|
107
|
+
export function isFuture(date: DateInput): boolean {
|
|
108
|
+
return dayjs(date).isAfter(dayjs())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get difference between two dates in specified unit
|
|
113
|
+
*/
|
|
114
|
+
export function diff(
|
|
115
|
+
date1: DateInput,
|
|
116
|
+
date2: DateInput,
|
|
117
|
+
unit: 'day' | 'week' | 'month' | 'year' | 'hour' | 'minute' = 'day'
|
|
118
|
+
): number {
|
|
119
|
+
return dayjs(date1).diff(dayjs(date2), unit)
|
|
120
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { dayjs, setDefaultTimezone } from './config'
|
|
2
|
+
export {
|
|
3
|
+
formatDate,
|
|
4
|
+
formatDateLong,
|
|
5
|
+
formatTime,
|
|
6
|
+
formatTimeWithSeconds,
|
|
7
|
+
formatDateTime,
|
|
8
|
+
formatDateTimeLong,
|
|
9
|
+
formatRelative,
|
|
10
|
+
formatISO,
|
|
11
|
+
formatForApi,
|
|
12
|
+
parseDate,
|
|
13
|
+
startOfDay,
|
|
14
|
+
endOfDay,
|
|
15
|
+
isToday,
|
|
16
|
+
isPast,
|
|
17
|
+
isFuture,
|
|
18
|
+
diff,
|
|
19
|
+
} from './formatters'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx'
|
|
2
|
+
|
|
3
|
+
export interface ExportOptions {
|
|
4
|
+
filename?: string
|
|
5
|
+
sheetName?: string
|
|
6
|
+
headers?: string[]
|
|
7
|
+
columnWidths?: number[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Export data to Excel file and trigger download
|
|
12
|
+
*/
|
|
13
|
+
export function exportToExcel<T extends Record<string, unknown>>(
|
|
14
|
+
data: T[],
|
|
15
|
+
options: ExportOptions = {}
|
|
16
|
+
): void {
|
|
17
|
+
const {
|
|
18
|
+
filename = 'export',
|
|
19
|
+
sheetName = 'Sheet1',
|
|
20
|
+
headers,
|
|
21
|
+
columnWidths,
|
|
22
|
+
} = options
|
|
23
|
+
|
|
24
|
+
// Create worksheet from data
|
|
25
|
+
const worksheet = XLSX.utils.json_to_sheet(data, {
|
|
26
|
+
header: headers,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Set column widths if provided
|
|
30
|
+
if (columnWidths) {
|
|
31
|
+
worksheet['!cols'] = columnWidths.map((width) => ({ wch: width }))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Create workbook
|
|
35
|
+
const workbook = XLSX.utils.book_new()
|
|
36
|
+
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
|
37
|
+
|
|
38
|
+
// Generate filename with extension
|
|
39
|
+
const fullFilename = filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`
|
|
40
|
+
|
|
41
|
+
// Trigger download
|
|
42
|
+
XLSX.writeFile(workbook, fullFilename)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Export data to Excel buffer (for server-side usage)
|
|
47
|
+
*/
|
|
48
|
+
export function exportToExcelBuffer<T extends Record<string, unknown>>(
|
|
49
|
+
data: T[],
|
|
50
|
+
options: Omit<ExportOptions, 'filename'> = {}
|
|
51
|
+
): Buffer {
|
|
52
|
+
const { sheetName = 'Sheet1', headers, columnWidths } = options
|
|
53
|
+
|
|
54
|
+
const worksheet = XLSX.utils.json_to_sheet(data, {
|
|
55
|
+
header: headers,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (columnWidths) {
|
|
59
|
+
worksheet['!cols'] = columnWidths.map((width) => ({ wch: width }))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const workbook = XLSX.utils.book_new()
|
|
63
|
+
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
|
64
|
+
|
|
65
|
+
return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create an Excel template with headers only
|
|
70
|
+
*/
|
|
71
|
+
export function createTemplate(
|
|
72
|
+
headers: string[],
|
|
73
|
+
options: Omit<ExportOptions, 'headers'> = {}
|
|
74
|
+
): void {
|
|
75
|
+
const { filename = 'template', sheetName = 'Sheet1' } = options
|
|
76
|
+
|
|
77
|
+
const worksheet = XLSX.utils.aoa_to_sheet([headers])
|
|
78
|
+
|
|
79
|
+
// Auto-fit column widths based on header length
|
|
80
|
+
worksheet['!cols'] = headers.map((header) => ({
|
|
81
|
+
wch: Math.max(header.length + 2, 12),
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
const workbook = XLSX.utils.book_new()
|
|
85
|
+
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
|
86
|
+
|
|
87
|
+
const fullFilename = filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`
|
|
88
|
+
XLSX.writeFile(workbook, fullFilename)
|
|
89
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx'
|
|
2
|
+
|
|
3
|
+
export interface ParseOptions {
|
|
4
|
+
sheet?: number | string
|
|
5
|
+
header?: number
|
|
6
|
+
range?: string
|
|
7
|
+
raw?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParseResult<T = Record<string, unknown>> {
|
|
11
|
+
data: T[]
|
|
12
|
+
headers: string[]
|
|
13
|
+
sheetNames: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse an Excel file buffer to JSON
|
|
18
|
+
*/
|
|
19
|
+
export function parseExcelBuffer<T = Record<string, unknown>>(
|
|
20
|
+
buffer: ArrayBuffer,
|
|
21
|
+
options: ParseOptions = {}
|
|
22
|
+
): ParseResult<T> {
|
|
23
|
+
const workbook = XLSX.read(buffer, { type: 'array' })
|
|
24
|
+
const sheetNames = workbook.SheetNames
|
|
25
|
+
|
|
26
|
+
const sheetName = typeof options.sheet === 'number'
|
|
27
|
+
? sheetNames[options.sheet]
|
|
28
|
+
: options.sheet ?? sheetNames[0]
|
|
29
|
+
|
|
30
|
+
const worksheet = workbook.Sheets[sheetName]
|
|
31
|
+
|
|
32
|
+
if (!worksheet) {
|
|
33
|
+
throw new Error(`Sheet "${sheetName}" not found`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const jsonOptions: XLSX.Sheet2JSONOpts = {
|
|
37
|
+
header: options.header,
|
|
38
|
+
range: options.range,
|
|
39
|
+
raw: options.raw ?? false,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const data = XLSX.utils.sheet_to_json<T>(worksheet, jsonOptions)
|
|
43
|
+
|
|
44
|
+
// Get headers from the first row
|
|
45
|
+
const range = XLSX.utils.decode_range(worksheet['!ref'] ?? 'A1')
|
|
46
|
+
const headers: string[] = []
|
|
47
|
+
|
|
48
|
+
for (let col = range.s.c; col <= range.e.c; col++) {
|
|
49
|
+
const cell = worksheet[XLSX.utils.encode_cell({ r: 0, c: col })]
|
|
50
|
+
headers.push(cell?.v?.toString() ?? `Column${col + 1}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
data,
|
|
55
|
+
headers,
|
|
56
|
+
sheetNames,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse an Excel file from a File object
|
|
62
|
+
*/
|
|
63
|
+
export async function parseExcelFile<T = Record<string, unknown>>(
|
|
64
|
+
file: File,
|
|
65
|
+
options: ParseOptions = {}
|
|
66
|
+
): Promise<ParseResult<T>> {
|
|
67
|
+
const buffer = await file.arrayBuffer()
|
|
68
|
+
return parseExcelBuffer<T>(buffer, options)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate Excel data against a schema
|
|
73
|
+
*/
|
|
74
|
+
export function validateExcelData<T>(
|
|
75
|
+
data: unknown[],
|
|
76
|
+
validator: (row: unknown, index: number) => T | null
|
|
77
|
+
): { valid: T[]; errors: { row: number; error: string }[] } {
|
|
78
|
+
const valid: T[] = []
|
|
79
|
+
const errors: { row: number; error: string }[] = []
|
|
80
|
+
|
|
81
|
+
data.forEach((row, index) => {
|
|
82
|
+
try {
|
|
83
|
+
const result = validator(row, index)
|
|
84
|
+
if (result !== null) {
|
|
85
|
+
valid.push(result)
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
errors.push({
|
|
89
|
+
row: index + 2, // +2 because Excel rows are 1-indexed and header is row 1
|
|
90
|
+
error: error instanceof Error ? error.message : 'Validation error',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return { valid, errors }
|
|
96
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
function makeQueryClient() {
|
|
4
|
+
return new QueryClient({
|
|
5
|
+
defaultOptions: {
|
|
6
|
+
queries: {
|
|
7
|
+
staleTime: 60 * 1000, // 1 minute
|
|
8
|
+
gcTime: 5 * 60 * 1000, // 5 minutes
|
|
9
|
+
retry: 1,
|
|
10
|
+
refetchOnWindowFocus: false,
|
|
11
|
+
},
|
|
12
|
+
dehydrate: {
|
|
13
|
+
shouldDehydrateQuery: (query) =>
|
|
14
|
+
defaultShouldDehydrateQuery(query) ||
|
|
15
|
+
query.state.status === 'pending',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let browserQueryClient: QueryClient | undefined = undefined
|
|
22
|
+
|
|
23
|
+
export function getQueryClient() {
|
|
24
|
+
if (isServer) {
|
|
25
|
+
// Server: always make a new query client
|
|
26
|
+
return makeQueryClient()
|
|
27
|
+
} else {
|
|
28
|
+
// Browser: make a new query client if we don't already have one
|
|
29
|
+
// This is very important, so we don't re-make a new client if React
|
|
30
|
+
// suspends during the initial render. This may not be needed if we
|
|
31
|
+
// have a suspense boundary BELOW the creation of the query client
|
|
32
|
+
if (!browserQueryClient) browserQueryClient = makeQueryClient()
|
|
33
|
+
return browserQueryClient
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { createBrowserClient } from '@supabase/ssr'
|
|
2
2
|
|
|
3
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
4
|
+
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY
|
|
5
|
+
|
|
3
6
|
export function createClient() {
|
|
4
7
|
return createBrowserClient(
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
supabaseUrl!,
|
|
9
|
+
supabaseKey!
|
|
7
10
|
)
|
|
8
11
|
}
|