@goplusvn/core 0.1.5 → 0.1.7
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/CHANGELOG.md +61 -0
- package/PLATFORM.md +111 -0
- package/package.json +17 -2
- package/src/auth/index.ts +10 -6
- package/src/crud/lib/import-export-service.ts +4 -9
- package/src/errors/app-error.ts +236 -0
- package/src/errors/error-handler.ts +89 -0
- package/src/errors/index.ts +17 -0
- package/src/errors/server-error.ts +316 -0
- package/src/export/download-file.ts +46 -0
- package/src/export/index.ts +7 -0
- package/src/export/to-csv.ts +35 -0
- package/src/export/use-export.ts +68 -0
- package/src/print/direct-print-provider.tsx +333 -0
- package/src/print/index.ts +11 -0
- package/src/print/print-styles.tsx +234 -0
- package/src/repository/cas-update.ts +54 -0
- package/src/repository/index.ts +2 -0
- package/src/styles/base.css +585 -0
- package/src/styles/radix-themes.css +160 -0
- package/src/utils/cccd-parser.ts +62 -0
- package/src/utils/date-utils.ts +175 -0
- package/src/utils/fetcher.ts +46 -0
- package/src/utils/serialize.ts +47 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type { NextResponse } from "next/server"
|
|
2
|
+
import { createHash } from "crypto"
|
|
3
|
+
|
|
4
|
+
import { AppError, toAppError } from "./app-error"
|
|
5
|
+
import { createErrorResponse } from "./error-handler"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Server-side error system for the `error_logs` table.
|
|
9
|
+
*
|
|
10
|
+
* Core ships the generic machinery; the consuming app binds it to its own
|
|
11
|
+
* Prisma client + `ErrorLog` model via `createServerError({ db })` (or just the
|
|
12
|
+
* logger via `createErrorLogger(db)`). This keeps core database-agnostic while
|
|
13
|
+
* every app gets the same stack-preserving, deduped, severity-bucketed logging.
|
|
14
|
+
*
|
|
15
|
+
* // app: src/lib/errors/server-error.ts
|
|
16
|
+
* import { createServerError } from "@goerp/core/errors"
|
|
17
|
+
* import { db } from "@/lib/prisma"
|
|
18
|
+
* export const serverError = createServerError({ db })
|
|
19
|
+
*
|
|
20
|
+
* The app's `db.errorLog` delegate must satisfy {@link ErrorLogDelegate} (the
|
|
21
|
+
* columns: errorId, fingerprint, code, message, detail, context, module,
|
|
22
|
+
* userId, url, userAgent, severity, occurrenceCount, lastSeenAt, resolved,
|
|
23
|
+
* createdAt).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface ServerErrorContext {
|
|
27
|
+
/** The request URL (used for module derivation + storage) */
|
|
28
|
+
url?: string | URL
|
|
29
|
+
/** HTTP method, prepended to module for better grouping */
|
|
30
|
+
method?: string
|
|
31
|
+
/** Authenticated user id, if known */
|
|
32
|
+
userId?: string
|
|
33
|
+
/** User agent string from request headers */
|
|
34
|
+
userAgent?: string
|
|
35
|
+
/** Free-form extra context — JSON-stringified into `context` column */
|
|
36
|
+
extra?: Record<string, unknown>
|
|
37
|
+
/** Override the module name (defaults to derived from url) */
|
|
38
|
+
module?: string
|
|
39
|
+
/** "error" | "warn" | "fatal" — defaults to "error" */
|
|
40
|
+
severity?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Minimal shape of the Prisma `errorLog` delegate the app must provide. */
|
|
44
|
+
export interface ErrorLogDelegate {
|
|
45
|
+
findFirst(args: unknown): Promise<{ id: string } | null>
|
|
46
|
+
update(args: unknown): Promise<unknown>
|
|
47
|
+
create(args: unknown): Promise<unknown>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ErrorLoggerDb {
|
|
51
|
+
errorLog: ErrorLogDelegate
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Same fingerprint formula the client POST /api/error-logs uses, so a server
|
|
56
|
+
* error and its client-bubbled toast collapse onto the same row.
|
|
57
|
+
*/
|
|
58
|
+
function buildFingerprint(
|
|
59
|
+
message: string,
|
|
60
|
+
code: string | null,
|
|
61
|
+
moduleTag: string | null,
|
|
62
|
+
url: string | null
|
|
63
|
+
): string {
|
|
64
|
+
let path = url || ""
|
|
65
|
+
try {
|
|
66
|
+
path = new URL(url || "", "http://x").pathname
|
|
67
|
+
} catch {
|
|
68
|
+
/* keep raw */
|
|
69
|
+
}
|
|
70
|
+
const raw = [message.slice(0, 200), code || "", moduleTag || "", path].join("|")
|
|
71
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Derive a "module" tag from a URL pathname so admin UI can group errors.
|
|
76
|
+
* /api/sales/orders/cml4jn3sl0 → "sales-orders"
|
|
77
|
+
* /api/customers/[id] → "customers"
|
|
78
|
+
* / → "root"
|
|
79
|
+
*/
|
|
80
|
+
function deriveModule(url?: string | URL): string | null {
|
|
81
|
+
if (!url) return null
|
|
82
|
+
let pathname: string
|
|
83
|
+
try {
|
|
84
|
+
pathname = typeof url === "string" ? new URL(url, "http://x").pathname : url.pathname
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
const segs = pathname.split("/").filter(Boolean)
|
|
89
|
+
// Strip locale + leading "api"
|
|
90
|
+
const filtered = segs.filter(
|
|
91
|
+
(s, i) => !(i === 0 && (s === "api" || /^[a-z]{2}$/.test(s)))
|
|
92
|
+
)
|
|
93
|
+
if (filtered.length === 0) return "root"
|
|
94
|
+
const idLike = /^([a-z0-9]{20,}|\d+|[0-9a-f-]{20,})$/i
|
|
95
|
+
const out: string[] = []
|
|
96
|
+
for (const s of filtered) {
|
|
97
|
+
if (idLike.test(s)) break
|
|
98
|
+
out.push(s)
|
|
99
|
+
if (out.length === 2) break
|
|
100
|
+
}
|
|
101
|
+
return out.join("-") || filtered[0] || null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pull a Prisma error code (P2002, P2025, …) out of an error if present.
|
|
106
|
+
* Prisma errors live under different shapes depending on adapter wrapping.
|
|
107
|
+
*/
|
|
108
|
+
function extractErrorCode(err: unknown): string | null {
|
|
109
|
+
if (!err || typeof err !== "object") return null
|
|
110
|
+
const e = err as any
|
|
111
|
+
if (typeof e.code === "string") return e.code
|
|
112
|
+
if (e.cause && typeof e.cause === "object") {
|
|
113
|
+
if (typeof e.cause.code === "string") return e.cause.code
|
|
114
|
+
if (typeof e.cause.originalCode === "string") return e.cause.originalCode
|
|
115
|
+
}
|
|
116
|
+
if (e.meta?.driverAdapterError?.cause?.originalCode) {
|
|
117
|
+
return String(e.meta.driverAdapterError.cause.originalCode)
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildDetail(err: unknown, ctx: ServerErrorContext | undefined): string {
|
|
123
|
+
const lines: string[] = []
|
|
124
|
+
if (err instanceof Error) {
|
|
125
|
+
lines.push(`Message: ${err.message}`)
|
|
126
|
+
if (err.stack) lines.push(`\nStack Trace:\n${err.stack}`)
|
|
127
|
+
if ((err as any).cause) {
|
|
128
|
+
try {
|
|
129
|
+
lines.push(`\nCause: ${JSON.stringify((err as any).cause, null, 2)}`)
|
|
130
|
+
} catch {
|
|
131
|
+
lines.push(`\nCause: ${String((err as any).cause)}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if ((err as any).meta) {
|
|
135
|
+
try {
|
|
136
|
+
lines.push(`\nMeta: ${JSON.stringify((err as any).meta, null, 2)}`)
|
|
137
|
+
} catch {
|
|
138
|
+
/* skip */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
lines.push(`Raw: ${String(err)}`)
|
|
143
|
+
try {
|
|
144
|
+
lines.push(`JSON: ${JSON.stringify(err)}`)
|
|
145
|
+
} catch {
|
|
146
|
+
/* skip */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (ctx?.method || ctx?.url) {
|
|
150
|
+
lines.push(`\nRequest: ${ctx.method || "?"} ${ctx.url || ""}`)
|
|
151
|
+
}
|
|
152
|
+
if (ctx?.extra) {
|
|
153
|
+
try {
|
|
154
|
+
lines.push(`\nContext: ${JSON.stringify(ctx.extra, null, 2)}`)
|
|
155
|
+
} catch {
|
|
156
|
+
/* skip */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return lines.join("\n")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function extractMessage(err: unknown): string {
|
|
163
|
+
if (err instanceof Error) return err.message || err.name || "Error"
|
|
164
|
+
if (typeof err === "string") return err
|
|
165
|
+
try {
|
|
166
|
+
return JSON.stringify(err)
|
|
167
|
+
} catch {
|
|
168
|
+
return String(err)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function safeStringify(v: unknown): string {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.stringify(v)
|
|
175
|
+
} catch {
|
|
176
|
+
return String(v)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Bind the error logger to an app's Prisma client. Returns `logServerError`,
|
|
182
|
+
* which is fire-and-forget (never throws, never awaits in the caller's hot
|
|
183
|
+
* path) and dedupes onto an existing unresolved row by fingerprint.
|
|
184
|
+
*/
|
|
185
|
+
export function createErrorLogger(db: ErrorLoggerDb) {
|
|
186
|
+
async function persistServerError(
|
|
187
|
+
err: unknown,
|
|
188
|
+
ctx?: ServerErrorContext
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
try {
|
|
191
|
+
const message = extractMessage(err)
|
|
192
|
+
if (!message) return
|
|
193
|
+
|
|
194
|
+
const code = extractErrorCode(err)
|
|
195
|
+
const urlStr =
|
|
196
|
+
typeof ctx?.url === "string"
|
|
197
|
+
? ctx.url
|
|
198
|
+
: ctx?.url instanceof URL
|
|
199
|
+
? ctx.url.toString()
|
|
200
|
+
: null
|
|
201
|
+
const moduleTag = ctx?.module ?? deriveModule(ctx?.url)
|
|
202
|
+
const fingerprint = buildFingerprint(message, code, moduleTag, urlStr)
|
|
203
|
+
const detail = buildDetail(err, ctx)
|
|
204
|
+
|
|
205
|
+
const existing = await db.errorLog.findFirst({
|
|
206
|
+
where: { fingerprint, resolved: false },
|
|
207
|
+
orderBy: { createdAt: "desc" },
|
|
208
|
+
select: { id: true },
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
if (existing) {
|
|
212
|
+
await db.errorLog.update({
|
|
213
|
+
where: { id: existing.id },
|
|
214
|
+
data: {
|
|
215
|
+
occurrenceCount: { increment: 1 },
|
|
216
|
+
lastSeenAt: new Date(),
|
|
217
|
+
detail,
|
|
218
|
+
url: urlStr || undefined,
|
|
219
|
+
userId: ctx?.userId || undefined,
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await db.errorLog.create({
|
|
226
|
+
data: {
|
|
227
|
+
errorId: code || "SERVER",
|
|
228
|
+
fingerprint,
|
|
229
|
+
code: code || null,
|
|
230
|
+
message: message.slice(0, 1000),
|
|
231
|
+
detail,
|
|
232
|
+
context: ctx?.extra ? safeStringify(ctx.extra).slice(0, 5000) : null,
|
|
233
|
+
module: moduleTag || null,
|
|
234
|
+
userId: ctx?.userId || null,
|
|
235
|
+
url: urlStr || null,
|
|
236
|
+
userAgent: ctx?.userAgent || null,
|
|
237
|
+
severity: ctx?.severity || "error",
|
|
238
|
+
occurrenceCount: 1,
|
|
239
|
+
lastSeenAt: new Date(),
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
} catch (writeErr) {
|
|
243
|
+
console.error("[logServerError] DB write failed:", writeErr)
|
|
244
|
+
console.error("[logServerError] original error was:", err)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function logServerError(err: unknown, ctx?: ServerErrorContext): void {
|
|
249
|
+
setTimeout(() => {
|
|
250
|
+
void persistServerError(err, ctx).catch((inner) => {
|
|
251
|
+
console.error("[logServerError] failed to persist:", inner)
|
|
252
|
+
})
|
|
253
|
+
}, 0)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { logServerError }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type LogServerError = (err: unknown, ctx?: ServerErrorContext) => void
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The canonical "catch block" helper for API routes, built around a given
|
|
263
|
+
* `logServerError`. Maps Prisma codes to friendly messages + stable errorId,
|
|
264
|
+
* PERSISTS the real stack/Prisma meta (severity = error for 5xx, warn for 4xx),
|
|
265
|
+
* and returns the structured JSON with an `X-Error-Id` header.
|
|
266
|
+
*
|
|
267
|
+
* } catch (error) {
|
|
268
|
+
* return serverError(error, request, { message: "Lỗi xử lý thao tác" })
|
|
269
|
+
* }
|
|
270
|
+
*
|
|
271
|
+
* Takes the logger as a dependency (rather than a db) so an app can keep its
|
|
272
|
+
* own `logServerError` export — used directly by api-handler/cron — as the
|
|
273
|
+
* single seam, and so it stays unit-testable by mocking that one function.
|
|
274
|
+
*/
|
|
275
|
+
export function buildServerError(opts: {
|
|
276
|
+
logServerError: LogServerError
|
|
277
|
+
isProd?: boolean
|
|
278
|
+
}) {
|
|
279
|
+
const { logServerError } = opts
|
|
280
|
+
const isProd = opts.isProd ?? process.env.NODE_ENV === "production"
|
|
281
|
+
|
|
282
|
+
return function serverError(
|
|
283
|
+
err: unknown,
|
|
284
|
+
request?: { url?: string; method?: string; headers?: Headers },
|
|
285
|
+
o?: { message?: string; userId?: string }
|
|
286
|
+
): NextResponse {
|
|
287
|
+
const appError = err instanceof AppError ? err : toAppError(err, o?.message)
|
|
288
|
+
|
|
289
|
+
if (!isProd) {
|
|
290
|
+
console.error("[serverError]", err)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
logServerError(err, {
|
|
294
|
+
url: request?.url,
|
|
295
|
+
method: request?.method,
|
|
296
|
+
userId: o?.userId,
|
|
297
|
+
userAgent: request?.headers?.get?.("user-agent") ?? undefined,
|
|
298
|
+
severity: appError.statusCode >= 500 ? "error" : "warn",
|
|
299
|
+
extra: { errorId: appError.errorId, code: appError.code },
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
return createErrorResponse(appError)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Convenience: bind both the logger and the catch-block helper to an app's db
|
|
308
|
+
* in one call (for apps that don't need a standalone `logServerError` export).
|
|
309
|
+
*/
|
|
310
|
+
export function createServerError(opts: {
|
|
311
|
+
db: ErrorLoggerDb
|
|
312
|
+
isProd?: boolean
|
|
313
|
+
}) {
|
|
314
|
+
const { logServerError } = createErrorLogger(opts.db)
|
|
315
|
+
return buildServerError({ logServerError, isProd: opts.isProd })
|
|
316
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared download helpers for the unified export flow. Pure (no React) so they
|
|
3
|
+
* can be unit-tested and reused outside components.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Trigger a browser download for an in-memory Blob via a transient anchor.
|
|
8
|
+
* Revokes the object URL afterwards so it isn't leaked.
|
|
9
|
+
*/
|
|
10
|
+
export function triggerBlobDownload(blob: Blob, filename: string): void {
|
|
11
|
+
const url = window.URL.createObjectURL(blob)
|
|
12
|
+
const a = document.createElement("a")
|
|
13
|
+
a.href = url
|
|
14
|
+
a.download = filename
|
|
15
|
+
document.body.appendChild(a)
|
|
16
|
+
a.click()
|
|
17
|
+
a.remove()
|
|
18
|
+
window.URL.revokeObjectURL(url)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse the download filename a server sent in `Content-Disposition`, falling
|
|
23
|
+
* back to `fallback` when the header is missing/unparseable. Handles both the
|
|
24
|
+
* RFC 5987 `filename*=UTF-8''...` form and the plain `filename="..."` form so
|
|
25
|
+
* the client doesn't have to hardcode names that the server already knows.
|
|
26
|
+
*/
|
|
27
|
+
export function filenameFromContentDisposition(
|
|
28
|
+
header: string | null,
|
|
29
|
+
fallback: string
|
|
30
|
+
): string {
|
|
31
|
+
if (!header) return fallback
|
|
32
|
+
|
|
33
|
+
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)
|
|
34
|
+
if (encoded?.[1]) {
|
|
35
|
+
try {
|
|
36
|
+
return decodeURIComponent(encoded[1])
|
|
37
|
+
} catch {
|
|
38
|
+
// fall through to the plain form / fallback
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const plain = /filename="?([^";]+)"?/i.exec(header)
|
|
43
|
+
if (plain?.[1]) return plain[1].trim()
|
|
44
|
+
|
|
45
|
+
return fallback
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV building with RFC 4180 escaping.
|
|
3
|
+
*
|
|
4
|
+
* The crud `exportToCSV` historically joined fields with a bare comma and
|
|
5
|
+
* `String(v || "")`, so any value containing a comma / double-quote / newline
|
|
6
|
+
* (addresses, notes) silently broke column alignment, and falsy values like
|
|
7
|
+
* `0`/`false` came out empty. This is the corrected, shared implementation —
|
|
8
|
+
* crud's `exportToCSV` now delegates here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Escape a single CSV field per RFC 4180 (quote if it holds comma/quote/newline). */
|
|
12
|
+
export function escapeCsvField(value: unknown): string {
|
|
13
|
+
const str = value === null || value === undefined ? "" : String(value)
|
|
14
|
+
if (/[",\n\r]/.test(str)) {
|
|
15
|
+
return `"${str.replace(/"/g, '""')}"`
|
|
16
|
+
}
|
|
17
|
+
return str
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param data rows as plain objects
|
|
22
|
+
* @param fields optional explicit column order (defaults to keys of the first row)
|
|
23
|
+
*/
|
|
24
|
+
export function toCsv(
|
|
25
|
+
data: Record<string, unknown>[],
|
|
26
|
+
fields?: string[]
|
|
27
|
+
): string {
|
|
28
|
+
if (data.length === 0) return ""
|
|
29
|
+
const keys = fields ?? Object.keys(data[0])
|
|
30
|
+
const header = keys.map(escapeCsvField).join(",")
|
|
31
|
+
const rows = data.map((row) =>
|
|
32
|
+
keys.map((k) => escapeCsvField(row[k])).join(",")
|
|
33
|
+
)
|
|
34
|
+
return [header, ...rows].join("\n")
|
|
35
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react"
|
|
4
|
+
import { toast } from "sonner"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
filenameFromContentDisposition,
|
|
8
|
+
triggerBlobDownload,
|
|
9
|
+
} from "./download-file"
|
|
10
|
+
|
|
11
|
+
export interface ExportOptions {
|
|
12
|
+
/** Fallback filename if the server doesn't send a Content-Disposition. */
|
|
13
|
+
filename?: string
|
|
14
|
+
loadingMessage?: string
|
|
15
|
+
successMessage?: string
|
|
16
|
+
errorMessage?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The single client-side export primitive. Every "Xuất Excel" button funnels
|
|
21
|
+
* through here: GET the server export URL, stream it to a Blob, name the file
|
|
22
|
+
* from the server's Content-Disposition (or a fallback), trigger the download,
|
|
23
|
+
* and surface consistent loading/success/error toasts.
|
|
24
|
+
*
|
|
25
|
+
* Callers build their own filtered URL (`/api/.../export?filters`) — the part
|
|
26
|
+
* that genuinely differs per page — and hand it to `exportFile`. This replaces
|
|
27
|
+
* the previous mix of `window.open` (new tab, no error handling) and bespoke
|
|
28
|
+
* `fetch → blob → click` copies scattered across ~20 components.
|
|
29
|
+
*/
|
|
30
|
+
export function useExport() {
|
|
31
|
+
const [isExporting, setIsExporting] = useState(false)
|
|
32
|
+
|
|
33
|
+
const exportFile = useCallback(
|
|
34
|
+
async (url: string, options: ExportOptions = {}) => {
|
|
35
|
+
setIsExporting(true)
|
|
36
|
+
const toastId = toast.loading(
|
|
37
|
+
options.loadingMessage ?? "Đang xuất dữ liệu..."
|
|
38
|
+
)
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(url)
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw new Error(`Export failed with status ${res.status}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const blob = await res.blob()
|
|
46
|
+
const filename = filenameFromContentDisposition(
|
|
47
|
+
res.headers.get("Content-Disposition"),
|
|
48
|
+
options.filename ?? "export.xlsx"
|
|
49
|
+
)
|
|
50
|
+
triggerBlobDownload(blob, filename)
|
|
51
|
+
|
|
52
|
+
toast.success(options.successMessage ?? "Đã xuất file thành công", {
|
|
53
|
+
id: toastId,
|
|
54
|
+
})
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error("[useExport]", error)
|
|
57
|
+
toast.error(options.errorMessage ?? "Có lỗi xảy ra khi xuất dữ liệu", {
|
|
58
|
+
id: toastId,
|
|
59
|
+
})
|
|
60
|
+
} finally {
|
|
61
|
+
setIsExporting(false)
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return { exportFile, isExporting }
|
|
68
|
+
}
|