Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/features/attendance/pages/AttendancePage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/shared/hooks')>()
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(<AttendancePage />, {
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')
})
})
74 changes: 74 additions & 0 deletions src/features/attendance/pages/AttendancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand All @@ -117,6 +188,9 @@ export function AttendancePage() {
{ value: '3', label: t('attendance.monthTwoAgo') },
]}
/>
<Button size="sm" onClick={handleDownloadCsv} disabled={isLoading || students.length === 0}>
{t('common.downloadCsv')}
</Button>
<Button size="sm" onClick={() => navigate('/')}>
← {t('attendance.backToDashboard')}
</Button>
Expand Down
79 changes: 79 additions & 0 deletions src/features/payments/pages/PaymentsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PaymentsPage />, {
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()
})
})
36 changes: 35 additions & 1 deletion src/features/payments/pages/PaymentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -99,6 +126,13 @@ export function PaymentsPage() {
<IconButton label={t('common.back')} onClick={() => navigate('/')}>
<BackIcon />
</IconButton>
<Button
size="sm"
onClick={handleDownloadCsv}
disabled={list.isLoading || list.invoices.length === 0}
>
{t('common.downloadCsv')}
</Button>
<Button variant="primary" size="sm" onClick={() => setIsModalOpen(true)}>
{t('invoice.new')}
</Button>
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/en/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const common: Record<CommonKeys, string> = {
'common.close': 'Close',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.downloadCsv': 'Download',
'common.signOut': 'Sign out',
'common.loading': 'Loading…',
'common.empty': '—',
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/ru/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const common: Record<CommonKeys, string> = {
'common.close': 'Закрыть',
'common.edit': 'Изменить',
'common.delete': 'Удалить',
'common.downloadCsv': 'Скачать',
'common.signOut': 'Выйти',
'common.loading': 'Загрузка…',
'common.empty': '—',
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/uz/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '—',
Expand Down
65 changes: 65 additions & 0 deletions src/shared/lib/csv.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading