diff --git a/src/features/attendance/pages/AttendancePage.test.tsx b/src/features/attendance/pages/AttendancePage.test.tsx new file mode 100644 index 0000000..7ac113e --- /dev/null +++ b/src/features/attendance/pages/AttendancePage.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { renderWithProviders } from '@/test/renderWithProviders' +import { AttendancePage } from './AttendancePage' +import * as csvLib from '@/shared/lib/csv' + +vi.mock('@/app/providers/useAuth', () => ({ + useSession: () => ({ + token: 'test-token', + role: 'ADMINISTRATOR', + claims: {}, + permissions: [], + }), + useAuth: () => ({ session: null, signIn: vi.fn(), signOut: vi.fn(), isRestoring: false }), +})) + +vi.mock('../hooks/useGroupStudents', () => ({ + useGroupStudents: () => ({ + isLoading: false, + error: null, + students: [ + { id: 's1', userDto: { fullName: 'Ali Valiyev' } }, + { id: 's2', userDto: { fullName: 'Vali Sodyqov' } }, + ], + }), +})) + +vi.mock('@/shared/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAttendanceRecords: () => ({ + isLoading: false, + error: null, + records: [ + { + id: 'l1', + lessonTitle: '1-dars', + date: '2026-03-01T10:00:00', + attendanceStudentMap: { + s1: { status: 'PRESENT' }, + s2: { status: 'ABSENT' }, + }, + }, + ], + }), + } +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AttendancePage CSV Export', () => { + it('triggers CSV download when Download button is clicked', () => { + const downloadCsvSpy = vi.spyOn(csvLib, 'downloadCsv').mockImplementation(() => {}) + + renderWithProviders(, { + route: '/attendance', + }) + + const downloadButton = screen.getByRole('button', { name: 'Yuklab olish' }) + expect(downloadButton).not.toBeDisabled() + + fireEvent.click(downloadButton) + + expect(downloadCsvSpy).toHaveBeenCalledTimes(1) + const [filename, content] = downloadCsvSpy.mock.calls[0] + expect(filename).toContain('attendance_group_') + expect(filename).toContain('.csv') + expect(content).toContain("O'quvchi") + expect(content).toContain('Ali Valiyev') + expect(content).toContain('Keldi') + expect(content).toContain('Kelmadi') + }) +}) diff --git a/src/features/attendance/pages/AttendancePage.tsx b/src/features/attendance/pages/AttendancePage.tsx index 4d4fe1c..48ed4d5 100644 --- a/src/features/attendance/pages/AttendancePage.tsx +++ b/src/features/attendance/pages/AttendancePage.tsx @@ -5,6 +5,7 @@ import { useTheme } from '@/app/providers/useTheme' import { errorMessage } from '@/shared/api' import { useAttendanceRecords } from '@/shared/hooks' import { useT } from '@/shared/i18n' +import { downloadCsv, formatDate, generateCsvContent, sanitizeFilename } from '@/shared/lib' import { AppShell, AttendanceTable, Button, EmptyState, ErrorBox, SegmentedControl, type PastLessonColumn } from '@/shared/ui' import { DraftBar } from '../components/DraftBar' import { useAttendanceDraft, type AttendanceDraftInitial } from '../hooks/useAttendanceDraft' @@ -95,6 +96,76 @@ export function AttendancePage() { } } + const groupName = activeLesson?.group?.name || (groupId ? `group_${groupId}` : 'group') + const today = new Date().toISOString().slice(0, 10) + + function getStatusLabel(status: AttendanceStatus): string { + if (status === 'PRESENT') return t('attendance.PRESENT') + if (status === 'ABSENT') return t('attendance.ABSENT') + if (status === 'EXCUSED') return t('attendance.EXCUSED') + return status + } + + function handleDownloadCsv() { + if (students.length === 0) return + + const activeDraft = draft.draft + + // 1. Sarlavhalar qatori: "Student", keyin har bir o'tgan dars sanasi va sarlavhasi + const headers: string[] = [t('attendance.student')] + pastColumns.forEach((col) => { + const dateStr = formatDate(col.date) + const titleStr = col.lessonTitle ? ` (${col.lessonTitle})` : '' + headers.push(`${dateStr}${titleStr}`) + }) + + if (activeDraft && activeDraft.lesson) { + const draftDate = formatDate(activeDraft.lesson.lessonDate) + const draftTitle = activeDraft.lesson.title ? ` (${t('attendance.lessonNumber', { number: activeDraft.lesson.title })})` : '' + headers.push(`${draftDate}${draftTitle}`) + } + + // 2. Har bir o'quvchi uchun qatorlar + const editingPastLessonId = + activeDraft && pastColumns.some((column) => column.lessonId === activeDraft.lesson.id) + ? activeDraft.lesson.id + : null + + const rows: (string | null | undefined)[][] = students.map((student) => { + const studentRow: (string | null | undefined)[] = [student.userDto?.fullName || '—'] + + pastColumns.forEach((col) => { + if (col.lessonId === editingPastLessonId && activeDraft) { + const st = activeDraft.statuses[student.id] ?? 'PRESENT' + const re = activeDraft.reasons[student.id] + const label = getStatusLabel(st) + studentRow.push(re ? `${label} (${re})` : label) + } else { + const entry = col.attendanceMap[student.id] + if (!entry) { + studentRow.push('—') + } else { + const label = getStatusLabel(entry.status) + studentRow.push(entry.reason ? `${label} (${entry.reason})` : label) + } + } + }) + + if (activeDraft && !editingPastLessonId) { + const st = activeDraft.statuses[student.id] ?? 'PRESENT' + const re = activeDraft.reasons[student.id] + const label = getStatusLabel(st) + studentRow.push(re ? `${label} (${re})` : label) + } + + return studentRow + }) + + const csvString = generateCsvContent([headers, ...rows]) + const filename = sanitizeFilename(`attendance_${groupName}_${today}.csv`) + downloadCsv(filename, csvString) + } + const isLoading = studentsQuery.isLoading || recordsQuery.isLoading const failure = submit.error ?? studentsQuery.error ?? recordsQuery.error @@ -117,6 +188,9 @@ export function AttendancePage() { { value: '3', label: t('attendance.monthTwoAgo') }, ]} /> + diff --git a/src/features/payments/pages/PaymentsPage.test.tsx b/src/features/payments/pages/PaymentsPage.test.tsx new file mode 100644 index 0000000..03026bb --- /dev/null +++ b/src/features/payments/pages/PaymentsPage.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { renderWithProviders } from '@/test/renderWithProviders' +import { PaymentsPage } from './PaymentsPage' +import * as csvLib from '@/shared/lib/csv' + +vi.mock('@/app/providers/useAuth', () => ({ + useSession: () => ({ + token: 'test-token', + role: 'ADMINISTRATOR', + claims: {}, + permissions: [], + }), + useAuth: () => ({ session: null, signIn: vi.fn(), signOut: vi.fn(), isRestoring: false }), +})) + +vi.mock('../hooks/useInvoices', () => ({ + useInvoices: () => ({ + isLoading: false, + error: null, + invoices: [ + { + id: 'inv-1', + invoiceNumber: 'INV-001', + amount: 500000, + issuedAt: '2026-03-01T12:00:00', + status: 'PAID', + type: 'MONTHLY', + student: { userDto: { fullName: 'Ali Valiyev' } }, + }, + ], + totalPages: 1, + totalElements: 1, + }), +})) + +vi.mock('../hooks/useStudentOptions', () => ({ + useStudentOptions: () => [], +})) + +vi.mock('../hooks/useInvoiceMutations', () => ({ + useInvoiceMutations: () => ({ + create: { isPending: false, error: null, mutate: vi.fn() }, + changeStatus: { isPending: false, error: null, mutate: vi.fn() }, + remove: { isPending: false, error: null, mutate: vi.fn() }, + refund: { isPending: false, isSuccess: false, data: null, error: null, mutate: vi.fn() }, + }), +})) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('PaymentsPage CSV Export', () => { + it('triggers CSV download when Download button is clicked', () => { + const downloadCsvSpy = vi.spyOn(csvLib, 'downloadCsv').mockImplementation(() => {}) + + renderWithProviders(, { + route: '/payments', + }) + + const downloadButton = screen.getByRole('button', { name: 'Yuklab olish' }) + expect(downloadButton).not.toBeDisabled() + + fireEvent.click(downloadButton) + + expect(downloadCsvSpy).toHaveBeenCalledTimes(1) + const [filename, content] = downloadCsvSpy.mock.calls[0] + expect(filename).toContain('payments_') + expect(filename).toContain('.csv') + expect(content).toContain('Hisob raqami') + expect(content).toContain('O‘quvchi') + expect(content).toContain('INV-001') + expect(content).toContain('Ali Valiyev') + expect(content).toContain('To‘langan') + + downloadCsvSpy.mockRestore() + }) +}) diff --git a/src/features/payments/pages/PaymentsPage.tsx b/src/features/payments/pages/PaymentsPage.tsx index 15662d0..1d06323 100644 --- a/src/features/payments/pages/PaymentsPage.tsx +++ b/src/features/payments/pages/PaymentsPage.tsx @@ -4,7 +4,7 @@ import { useAuth, useSession } from '@/app/providers/useAuth' import { useTheme } from '@/app/providers/useTheme' import { errorMessage } from '@/shared/api' import { useT } from '@/shared/i18n' -import { formatAmount } from '@/shared/lib' +import { downloadCsv, formatAmount, formatDate, generateCsvContent, sanitizeFilename } from '@/shared/lib' import { INVOICE_STATUSES } from '@/shared/types' import { AppShell, @@ -85,6 +85,33 @@ export function PaymentsPage() { refund.mutate(refundStudentId, { onSuccess: () => setRefundStudentId('') }) } + function handleDownloadCsv() { + if (list.invoices.length === 0) return + + const headers = [ + t('invoice.number'), + t('invoice.student'), + t('invoice.amount'), + t('invoice.issuedAt'), + t('invoice.type'), + t('field.status'), + ] + + const rows = list.invoices.map((invoice) => [ + invoice.invoiceNumber ?? '—', + invoice.student?.userDto?.fullName ?? '—', + formatAmount(invoice.amount), + formatDate(invoice.issuedAt) || '—', + invoice.type || '—', + invoice.status ? t(`invoice.status.${invoice.status}`) : '—', + ]) + + const csvString = generateCsvContent([headers, ...rows]) + const today = new Date().toISOString().slice(0, 10) + const filename = sanitizeFilename(`payments_${today}.csv`) + downloadCsv(filename, csvString) + } + const mutationError = changeStatus.error ?? remove.error ?? refund.error return ( @@ -99,6 +126,13 @@ export function PaymentsPage() { navigate('/')}> + diff --git a/src/shared/i18n/locales/en/common.ts b/src/shared/i18n/locales/en/common.ts index f3cc968..07f068e 100644 --- a/src/shared/i18n/locales/en/common.ts +++ b/src/shared/i18n/locales/en/common.ts @@ -8,6 +8,7 @@ export const common: Record = { 'common.close': 'Close', 'common.edit': 'Edit', 'common.delete': 'Delete', + 'common.downloadCsv': 'Download', 'common.signOut': 'Sign out', 'common.loading': 'Loading…', 'common.empty': '—', diff --git a/src/shared/i18n/locales/ru/common.ts b/src/shared/i18n/locales/ru/common.ts index 65af8e4..bac585d 100644 --- a/src/shared/i18n/locales/ru/common.ts +++ b/src/shared/i18n/locales/ru/common.ts @@ -8,6 +8,7 @@ export const common: Record = { 'common.close': 'Закрыть', 'common.edit': 'Изменить', 'common.delete': 'Удалить', + 'common.downloadCsv': 'Скачать', 'common.signOut': 'Выйти', 'common.loading': 'Загрузка…', 'common.empty': '—', diff --git a/src/shared/i18n/locales/uz/common.ts b/src/shared/i18n/locales/uz/common.ts index 8e999bf..32d5f13 100644 --- a/src/shared/i18n/locales/uz/common.ts +++ b/src/shared/i18n/locales/uz/common.ts @@ -11,6 +11,7 @@ export const common = { 'common.close': 'Yopish', 'common.edit': 'Tahrirlash', 'common.delete': "O'chirish", + 'common.downloadCsv': 'Yuklab olish', 'common.signOut': 'Chiqish', 'common.loading': 'Yuklanmoqda…', 'common.empty': '—', diff --git a/src/shared/lib/csv.test.ts b/src/shared/lib/csv.test.ts new file mode 100644 index 0000000..e61d3df --- /dev/null +++ b/src/shared/lib/csv.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { downloadCsv, generateCsvContent, sanitizeFilename } from './csv' + +describe('sanitizeFilename', () => { + it('normalizes invalid characters in filenames', () => { + expect(sanitizeFilename('Group/1:A')).toBe('Group_1_A') + expect(sanitizeFilename(' ')).toBe('file') + expect(sanitizeFilename('Group 101')).toBe('Group 101') + }) +}) + +describe('generateCsvContent', () => { + it('generates CSV with UTF-8 BOM and escapes quotes and commas', () => { + const data = [ + ['Name', 'Status', 'Note'], + ['Ali Valiyev', 'PRESENT', 'Good, normal'], + ['Vali "Hero" Sodyqov', 'EXCUSED', 'Kasal / "Doctor" note'], + ] + const csv = generateCsvContent(data) + + // Starts with UTF-8 BOM + expect(csv.startsWith('\uFEFF')).toBe(true) + + // Contains formatted rows + expect(csv).toContain('Name,Status,Note') + expect(csv).toContain('Ali Valiyev,PRESENT,"Good, normal"') + expect(csv).toContain('"Vali ""Hero"" Sodyqov",EXCUSED,"Kasal / ""Doctor"" note"') + }) + + it('handles empty and null values gracefully', () => { + const data = [['Header 1', 'Header 2'], [null, undefined], [123, true]] + const csv = generateCsvContent(data) + expect(csv).toBe('\uFEFFHeader 1,Header 2\n,\n123,true') + }) +}) + +describe('downloadCsv', () => { + it('creates link element and triggers download', () => { + const appendSpy = vi.spyOn(document.body, 'appendChild') + const removeSpy = vi.spyOn(document.body, 'removeChild') + const createObjectURLMock = vi.fn().mockReturnValue('blob:http://localhost/test-url') + const revokeObjectURLMock = vi.fn() + + globalThis.URL.createObjectURL = createObjectURLMock + globalThis.URL.revokeObjectURL = revokeObjectURLMock + + const clickMock = vi.fn() + const createElementOriginal = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const el = createElementOriginal(tagName) + if (tagName === 'a') { + el.click = clickMock + } + return el + }) + + downloadCsv('test.csv', '\uFEFFcol1,col2\nval1,val2') + + expect(createObjectURLMock).toHaveBeenCalled() + expect(appendSpy).toHaveBeenCalled() + expect(clickMock).toHaveBeenCalled() + expect(removeSpy).toHaveBeenCalled() + expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:http://localhost/test-url') + }) +}) diff --git a/src/shared/lib/csv.ts b/src/shared/lib/csv.ts new file mode 100644 index 0000000..a513e05 --- /dev/null +++ b/src/shared/lib/csv.ts @@ -0,0 +1,49 @@ +/** + * CSV fayl yaratish va yuklab olish uchun utility funksiyalar. + * Excel Uzbek/Rus/Ingliz va maxsus belgilarni to'g'ri ochishi uchun UTF-8 BOM qo'shiladi. + */ + +/** + * Fayl nomidagi ruxsat berilmagan va muammo tug'diradigan belgilarni xavfsiz belgilarga almashtiradi. + */ +export function sanitizeFilename(name: string): string { + return name.trim().replace(/[/\\?%*:|"<>]/g, '_') || 'file' +} + +/** + * Matn katagini CSV qoidalariga ko'ra formatlaydi: + * Agar tarkibida vergul, qo'shtirnoq yoki yangi qator bo'lsa, qo'shtirnoqqa olinadi va + * ichki qo'shtirnoqlar double-quote (`""`) qilinadi. + */ +function escapeCsvCell(cell: string | number | boolean | null | undefined): string { + if (cell === null || cell === undefined) return '' + const str = String(cell) + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +/** + * 2D massivni UTF-8 BOM ga ega CSV satriga aylantiradi. + */ +export function generateCsvContent(rows: (string | number | boolean | null | undefined)[][]): string { + const csvRows = rows.map((row) => row.map(escapeCsvCell).join(',')) + const BOM = '\uFEFF' + return BOM + csvRows.join('\n') +} + +/** + * Brauzerda CSV faylini yuklab olishni boshlaydi. + */ +export function downloadCsv(filename: string, content: string): void { + const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', filename) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 4b321a7..60afb22 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -10,3 +10,4 @@ export { singular, titleCase, } from './format' +export { downloadCsv, generateCsvContent, sanitizeFilename } from './csv'