From 4abd8aeef3f6a270a7fb50abf4e2037ed481d0f9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:36:27 +0000 Subject: [PATCH 1/2] refactor: split mockApi and shared types into modular files - Split src/demo/mockApi.ts into src/demo/mockApi/ modules - Split src/shared/types/index.ts into domain type modules - Re-export all types and mock API methods to preserve existing imports - Ensure all files adhere to the ~250 lines limit (CLAUDE.md Rule 3) Co-authored-by: Diyor-Khasanov-dev <317548684+Diyor-Khasanov-dev@users.noreply.github.com> --- src/demo/mockApi.ts | 673 ------------------------------ src/demo/mockApi/analytics.ts | 44 ++ src/demo/mockApi/attendance.ts | 59 +++ src/demo/mockApi/auth.ts | 15 + src/demo/mockApi/enrollments.ts | 34 ++ src/demo/mockApi/generic.ts | 73 ++++ src/demo/mockApi/groupLevels.ts | 59 +++ src/demo/mockApi/index.ts | 81 ++++ src/demo/mockApi/invoices.ts | 64 +++ src/demo/mockApi/leads.ts | 79 ++++ src/demo/mockApi/organizations.ts | 61 +++ src/demo/mockApi/state.ts | 162 +++++++ src/demo/mockApi/students.ts | 68 +++ src/demo/mockApi/teacher.ts | 11 + src/shared/types/analytics.ts | 39 ++ src/shared/types/attendance.ts | 67 +++ src/shared/types/common.ts | 56 +++ src/shared/types/group.ts | 97 +++++ src/shared/types/index.ts | 461 +------------------- src/shared/types/invoice.ts | 31 ++ src/shared/types/lead.ts | 60 +++ src/shared/types/organization.ts | 36 ++ src/shared/types/student.ts | 21 + src/shared/types/teacher.ts | 6 + src/shared/types/user.ts | 28 ++ 25 files changed, 1265 insertions(+), 1120 deletions(-) delete mode 100644 src/demo/mockApi.ts create mode 100644 src/demo/mockApi/analytics.ts create mode 100644 src/demo/mockApi/attendance.ts create mode 100644 src/demo/mockApi/auth.ts create mode 100644 src/demo/mockApi/enrollments.ts create mode 100644 src/demo/mockApi/generic.ts create mode 100644 src/demo/mockApi/groupLevels.ts create mode 100644 src/demo/mockApi/index.ts create mode 100644 src/demo/mockApi/invoices.ts create mode 100644 src/demo/mockApi/leads.ts create mode 100644 src/demo/mockApi/organizations.ts create mode 100644 src/demo/mockApi/state.ts create mode 100644 src/demo/mockApi/students.ts create mode 100644 src/demo/mockApi/teacher.ts create mode 100644 src/shared/types/analytics.ts create mode 100644 src/shared/types/attendance.ts create mode 100644 src/shared/types/common.ts create mode 100644 src/shared/types/group.ts create mode 100644 src/shared/types/invoice.ts create mode 100644 src/shared/types/lead.ts create mode 100644 src/shared/types/organization.ts create mode 100644 src/shared/types/student.ts create mode 100644 src/shared/types/teacher.ts create mode 100644 src/shared/types/user.ts diff --git a/src/demo/mockApi.ts b/src/demo/mockApi.ts deleted file mode 100644 index 25509fb..0000000 --- a/src/demo/mockApi.ts +++ /dev/null @@ -1,673 +0,0 @@ -import { - attendance, - branches, - fullGroup, - groupLevels, - groupRoster, - groups, - invoices, - leads, - lessons, - organizations, - students, - teachers, -} from './mockData' -import { ADMIN_PERMISSIONS } from '@/shared/types' -import type { - AttendanceDto, - BranchDto, - GroupDto, - GroupLevelDto, - InvoiceDto, - InvoiceStatus, - LeadDto, - LeadStatus, - LessonDto, - OrganizationDto, - StudentDto, - TeacherDto, -} from '@/shared/types' - -/** - * Demo uchun soxta backend. - * - * `window.fetch` ni butunlay almashtiradi, ya'ni ilova kodiga umuman - * tegilmaydi — u o'zini haqiqiy serverga ulanganday tutadi. Ma'lumot - * xotirada, sahifa yangilansa boshlang'ich holatga qaytadi. - */ - -type Row = Record & { id: string } - -/** `GET /auth/me` javobi — demo foydalanuvchisi. */ -const demoUser = { - id: 'u-demo', - // Sozlamalardagi markaz bloki shu filialni yuklaydi. - branchId: 'b1', - fullName: 'Demo Foydalanuvchi', - phone: '+998 93 100 10 01', - birthDate: '1995-06-15', - imageUrl: undefined, - role: 'ADMINISTRATOR', -} - -const db = { - students: [...students] as StudentDto[], - teachers: [...teachers] as TeacherDto[], - groups: [...groups] as GroupDto[], - lessons: [...lessons] as LessonDto[], - attendance: [...attendance] as AttendanceDto[], - invoices: [...invoices] as InvoiceDto[], - organizations: [...organizations] as OrganizationDto[], - branches: [...branches] as BranchDto[], - groupLevels: [...groupLevels] as GroupLevelDto[], - leads: [...leads] as LeadDto[], -} - -/** - * Imzosiz, lekin to'g'ri tuzilgan JWT (ilova faqat payload'ni o'qiydi). - * - * `ADMINISTRATOR` uchun BARCHA ruxsatlar beriladi — demo cheklangan - * administratorni emas, ilovaning to'liq imkoniyatini ko'rsatishi kerak. - */ -function makeToken(role: string): string { - const encode = (value: object) => { - const bytes = new TextEncoder().encode(JSON.stringify(value)) - return btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - } - const permissions = role === 'ADMINISTRATOR' ? ADMIN_PERMISSIONS : undefined - return `${encode({ alg: 'none' })}.${encode({ role, permissions, sub: 'demo', name: 'Demo user' })}.demo` -} - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) -} - -/** - * Tanasiz javob (`DELETE` uchun). - * - * `new Response(body, { status: 204 })` — brauzer buni rad etadi: - * "Response with null body status cannot have body". Backend ham - * `noContent()` qaytaradi, ya'ni shakl ham to'g'ri bo'ladi. - */ -function noContent(): Response { - return new Response(null, { status: 204 }) -} - -/** Spring Data `Page` ko'rinishida qaytaradi. */ -function page(rows: T[], url: URL) { - const size = Number(url.searchParams.get('size') ?? 10) - const index = Number(url.searchParams.get('page') ?? 0) - const search = (url.searchParams.get('search') ?? '').toLowerCase() - - const filtered = search - ? rows.filter((row) => JSON.stringify(row).toLowerCase().includes(search)) - : rows - - return json({ - content: filtered.slice(index * size, index * size + size), - totalPages: Math.max(1, Math.ceil(filtered.length / size)), - totalElements: filtered.length, - }) -} - -function nextId(prefix: string) { - return `${prefix}${Math.random().toString(36).slice(2, 8)}` -} - -let installed = false - -/** Joriy demo roli — `setDemoRole` orqali almashtiriladi. */ -let currentRole = 'ADMINISTRATOR' - -export function setDemoRole(role: string) { - currentRole = role -} - -/** Testing helper: allows reinstalling mock in test runners. */ -export function resetMockApiInstalledFlag() { - installed = false -} - -/** `fetch` ni bir marta almashtiradi (qayta chaqirilsa hech narsa qilmaydi). */ -export function installMockApi() { - if (installed) return - installed = true - - const fetchImpl = typeof window !== 'undefined' ? window.fetch : globalThis.fetch - const original = fetchImpl.bind(typeof window !== 'undefined' ? window : globalThis) - - const mockFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - if (!raw.includes('/api/v1/')) return original(input as RequestInfo, init) - - const origin = - typeof window !== 'undefined' && window.location && window.location.origin && window.location.origin !== 'null' - ? window.location.origin - : 'http://localhost' - const url = new URL(raw, origin) - const path = url.pathname.replace('/api/v1', '') - const method = (init?.method ?? 'GET').toUpperCase() - const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {} - - // Haqiqiy tarmoqqa o'xshasin — spinner'lar ko'rinib qolsin. - await new Promise((resolve) => setTimeout(resolve, 180)) - - // --- auth --- - if (path === '/auth/refresh-token' || path === '/auth/login') { - return json({ token: makeToken(currentRole), expiry: '2099-01-01T00:00:00Z' }) - } - if (path === '/auth/me') { - return json(demoUser) - } - if (path === '/auth/change-password') { - // Demo'da har doim muvaffaqiyatli — haqiqiy tekshiruv backendda. - return json({ response: 'Password changed successfully' }) - } - - // --- enrollments: o'quvchi ↔ guruh ko'prigi --- - if (path === '/enrollments' && method === 'GET') { - const groupId = url.searchParams.get('groupId') ?? '' - const ids = groupRoster[groupId] ?? [] - return json({ - // Enrollment id si demo'da guruh+o'quvchidan yasaladi — - // haqiqiy backendda u alohida yozuvning id si. - content: ids.map((studentId) => ({ id: `e-${groupId}-${studentId}`, studentId, groupId })), - totalPages: 1, - totalElements: ids.length, - }) - } - if (path === '/enrollments' && method === 'POST') { - const groupId = String(body.groupId) - const studentId = String(body.studentId) - groupRoster[groupId] = [...(groupRoster[groupId] ?? []), studentId] - return json({ id: `e-${groupId}-${studentId}`, studentId, groupId }) - } - if (path.startsWith('/enrollments/') && method === 'DELETE') { - // `e--` ni teskari yechamiz. - const [, groupId, studentId] = path.split('/')[2].split('-') - groupRoster[groupId] = (groupRoster[groupId] ?? []).filter((id) => id !== studentId) - return noContent() - } - - // --- o'quvchi paneli: o'z kartasini telefon bo'yicha topish --- - // Guruh o'quvchilari — davomat jadvalining qatorlari. - if (path.startsWith('/student/') && path.endsWith('/students') && method === 'GET') { - const groupId = path.slice('/student/'.length, -'/students'.length) - const ids = groupRoster[groupId] ?? [] - return json(db.students.filter((student) => ids.includes(student.id))) - } - if (path === '/student/phone') { - const phone = url.searchParams.get('phone') ?? '' - return json(db.students.filter((student) => student.userDto?.phone === phone)) - } - - // Kirgan o'quvchining o'z yozuvi. Demo'da "kirgan o'quvchi" — - // `demoUser` telefoni bilan mos keladigan yozuv. Balans va to'lov - // holati guruhga bog'liq, shuning uchun `groupId` shart. - if (path === '/student/me' && method === 'GET') { - const me = db.students.find((student) => student.userDto?.phone === demoUser.phone) - if (!me) return json({ message: 'Student not found' }, 404) - const groupId = url.searchParams.get('groupId') ?? '' - if (!groupId) return json({ message: 'groupId is required' }, 400) - return json({ ...me, balance: -300000, status: 'PARTIAL' }) - } - - // O'quvchi panelidagi guruh va davomat bloklari — demo'da kirgan - // "o'quvchi" telefon raqami bo'yicha topiladi (haqiqiy backendda esa - // token orqali). - if (path === '/group/my' && method === 'GET') { - const me = db.students.find((student) => student.userDto?.phone === demoUser.phone) - const myGroupIds = Object.entries(groupRoster) - .filter(([, ids]) => (me ? ids.includes(me.id) : false)) - .map(([groupId]) => groupId) - // Backend `lessonsCount` ni bu endpoint uchun doim `null` qaytaradi — - // demo ham shu xatti-harakatni takrorlaydi. - return json( - db.groups - .filter((group) => myGroupIds.includes(group.id)) - .map((group) => ({ ...group, lessonsCount: null })) - ) - } - if (path.startsWith('/attendance/my/') && method === 'GET') { - const me = db.students.find((student) => student.userDto?.phone === demoUser.phone) - const groupId = path.slice('/attendance/my/'.length) - const groupLessons = db.lessons.filter((lesson) => lesson.group?.id === groupId) - const entries = groupLessons - .map((lesson) => { - const record = db.attendance.find((item) => item.lessonId === lesson.id) - const mine = record?.attendanceStudents?.find((entry) => entry.studentId === me?.id) - if (!mine) return null - return { - title: lesson.topic ?? lesson.title ?? '', - date: lesson.lessonDate?.slice(0, 10) ?? '', - status: mine.status, - reason: mine.reason, - } - }) - .filter((entry) => entry !== null) - return json(entries) - } - - // --- o'qituvchi paneli --- - if (path === '/group/groups') { - return json(db.groups.filter((group) => group.status !== 'COMPLETED')) - } - if (path === '/group/groupInfo') { - return json(fullGroup(url.searchParams.get('groupId') ?? 'g1')) - } - - // --- davomat --- - /** - * Guruhning oylik davomati. - * - * Demo'da oy bo'yicha filtrlash yo'q: mock ma'lumot bitta oyga tegishli, - * shuning uchun `previousMonths` qabul qilinadi-yu, natijani - * o'zgartirmaydi — ekranni ko'rsatish uchun shu yetarli. - */ - if (path.startsWith('/attendance/monthly/') && method === 'GET') { - const groupId = path.slice('/attendance/monthly/'.length) - const groupLessons = db.lessons.filter((lesson) => lesson.group?.id === groupId) - return json( - groupLessons.map((lesson) => { - const record = db.attendance.find((item) => item.lessonId === lesson.id) - const attendanceStudentMap: Record = {} - for (const entry of record?.attendanceStudents ?? []) { - if (entry.studentId && entry.status) { - attendanceStudentMap[entry.studentId] = { status: entry.status, reason: entry.reason } - } - } - return { - // `id` — davomat yozuvining o'zi (PUT shu yerga boradi), dars emas. - id: record?.id ?? lesson.id, - lessonTitle: lesson.topic ?? lesson.title ?? '', - date: lesson.lessonDate?.slice(0, 10) ?? '', - attendanceStudentMap, - } - }) - ) - } - if (path === '/attendance' && method === 'GET') return json(db.attendance) - if (path === '/attendance' && method === 'POST') { - const record: AttendanceDto = { - id: nextId('a'), - lessonId: String(body.lessonId), - createdAt: new Date().toISOString(), - attendanceStudents: body.students as AttendanceDto['attendanceStudents'], - } - db.attendance = [...db.attendance, record] - return json(record) - } - if (path.startsWith('/attendance/') && method === 'PUT') { - const id = path.slice('/attendance/'.length) - const students = body.attendanceStudents as AttendanceDto['attendanceStudents'] - db.attendance = db.attendance.map((item) => - item.id === id ? { ...item, attendanceStudents: students } : item - ) - const updated = db.attendance.find((item) => item.id === id) - return updated ? json(updated) : json({ message: 'Attendance not found' }, 404) - } - - // --- leads --- - if (path === '/leads' && method === 'GET') { - const status = url.searchParams.get('status') - const rows = status - ? db.leads.filter((lead) => lead.status === status) - : db.leads - return page(rows as unknown as Row[], url) - } - if (path === '/leads' && method === 'POST') { - const level = db.groupLevels.find((item) => item.id === String(body.preferredCourse)) - const newLead: LeadDto = { - id: nextId('ld'), - fullName: String(body.fullName ?? ''), - phone: String(body.phone ?? ''), - status: 'NEW', - source: body.source as LeadDto['source'], - preferredCourse: level, - createdAt: new Date().toISOString(), - } - db.leads = [newLead, ...db.leads] - return json(newLead) - } - if (path.startsWith('/leads/') && method === 'PUT') { - const id = path.slice('/leads/'.length) - const level = body.preferredCourse - ? db.groupLevels.find((item) => item.id === String(body.preferredCourse)) - : undefined - db.leads = db.leads.map((lead) => { - if (lead.id !== id) return lead - return { - ...lead, - fullName: body.fullName !== undefined ? String(body.fullName) : lead.fullName, - phone: body.phone !== undefined ? String(body.phone) : lead.phone, - status: (body.status as LeadStatus) ?? lead.status, - source: body.source ? (body.source as LeadDto['source']) : lead.source, - preferredCourse: level ?? lead.preferredCourse, - callAt: body.callAt !== undefined ? String(body.callAt) : lead.callAt, - updatedAt: new Date().toISOString(), - } - }) - const updated = db.leads.find((lead) => lead.id === id) - return updated ? json(updated) : json({ message: 'Lead not found' }, 404) - } - if (path.startsWith('/leads/') && path.endsWith('/enroll') && method === 'POST') { - const id = path.slice('/leads/'.length, -'/enroll'.length) - db.leads = db.leads.map((lead) => (lead.id === id ? { ...lead, status: 'ENROLLED' } : lead)) - const updated = db.leads.find((lead) => lead.id === id) - return updated ? json(updated) : json({ message: 'Lead not found' }, 404) - } - if (path.startsWith('/leads/') && path.endsWith('/reject') && method === 'POST') { - const id = path.slice('/leads/'.length, -'/reject'.length) - db.leads = db.leads.map((lead) => (lead.id === id ? { ...lead, status: 'REJECTED' } : lead)) - const updated = db.leads.find((lead) => lead.id === id) - return updated ? json(updated) : json({ message: 'Lead not found' }, 404) - } - if (path.startsWith('/leads/') && path.endsWith('/callLater') && method === 'PATCH') { - const id = path.slice('/leads/'.length, -'/callLater'.length) - const callAtParam = url.searchParams.get('callAt') ?? undefined - db.leads = db.leads.map((lead) => - lead.id === id ? { ...lead, status: 'CALL_LATER', callAt: callAtParam } : lead - ) - const updated = db.leads.find((lead) => lead.id === id) - return updated ? json(updated) : json({ message: 'Lead not found' }, 404) - } - if (path.startsWith('/leads/') && method === 'DELETE') { - const id = path.slice('/leads/'.length) - db.leads = db.leads.filter((lead) => lead.id !== id) - return noContent() - } - - // --- group-level --- - if (path === '/group-level/names' && method === 'GET') { - return json(db.groupLevels.map((gl) => ({ id: gl.id, name: gl.name }))) - } - if (path === '/group-level' && method === 'GET') { - return json(db.groupLevels) - } - if (path === '/group-level' && method === 'POST') { - const level: GroupLevelDto = { - id: nextId('lvl'), - name: String(body.name ?? ''), - lessonCount: Number(body.lessonCount ?? 0), - orderNumber: db.groupLevels.length + 1, - durationInMonths: Number(body.durationInMonths ?? 0), - monthlyFee: Number(body.monthlyFee ?? 0), - } - db.groupLevels = [...db.groupLevels, level] - return json(level) - } - if (path === '/group-level' && method === 'PUT') { - // Tartibni yangilash: { levels: [{ id, orderNumber }] } - const levels = body.levels as Array<{ id: string; orderNumber: number }> | undefined - if (Array.isArray(levels)) { - const orderMap = new Map(levels.map((item) => [item.id, item.orderNumber])) - db.groupLevels = db.groupLevels - .map((gl) => (orderMap.has(gl.id) ? { ...gl, orderNumber: orderMap.get(gl.id)! } : gl)) - .sort((a, b) => a.orderNumber - b.orderNumber) - } - return json(db.groupLevels) - } - if (path.startsWith('/group-level/') && method === 'PUT') { - const id = path.slice('/group-level/'.length) - db.groupLevels = db.groupLevels.map((gl) => { - if (gl.id !== id) return gl - return { - ...gl, - ...(body.name !== undefined ? { name: String(body.name) } : {}), - ...(body.lessonCount !== undefined ? { lessonCount: Number(body.lessonCount) } : {}), - ...(body.durationInMonths !== undefined ? { durationInMonths: Number(body.durationInMonths) } : {}), - ...(body.monthlyFee !== undefined ? { monthlyFee: Number(body.monthlyFee) } : {}), - } - }) - const updated = db.groupLevels.find((gl) => gl.id === id) - return updated ? json(updated) : json({ message: 'Group level not found' }, 404) - } - if (path.startsWith('/group-level/') && method === 'DELETE') { - const id = path.slice('/group-level/'.length) - db.groupLevels = db.groupLevels.filter((gl) => gl.id !== id) - return noContent() - } - - // --- analytics --- - if (path.startsWith('/analytics/') && method === 'GET') { - const category = path.slice('/analytics/'.length) - switch (category) { - case 'student': - return json({ - studentCount: db.students.length, - studentsAddedInMonth: 3, - }) - case 'teacher': - return json({ - teacherCount: db.teachers.length, - teachersAddedInMonth: 1, - }) - case 'lead': - return json({ - leadCount: db.leads.length, - leadCountInAMonth: 5, - }) - case 'invoice': { - const totalAmount = db.invoices.reduce((sum, inv) => sum + (inv.amount ?? 0), 0) - return json({ - invoiceAmount: totalAmount, - invoiceAmountInAMonth: 1050000, - }) - } - case 'enrollment': - return json({ - enrollmentCount: Object.values(groupRoster).reduce((sum, ids) => sum + ids.length, 0), - enrollmentCountInAMonth: 4, - }) - case 'branch': - return json({ - branchCount: db.branches.length, - }) - default: - return json({ message: `Unknown analytics category: ${category}` }, 404) - } - } - - // --- super-admin: tashkilotlar va filiallar --- - if (path === '/organizations' && method === 'GET') { - return page(db.organizations as unknown as Row[], url) - } - if (path === '/organizations' && method === 'POST') { - const org = { id: nextId('o'), ...body } as OrganizationDto - db.organizations = [...db.organizations, org] - return json(org) - } - if (path.startsWith('/organizations/') && method === 'PUT') { - const id = path.split('/')[2] - db.organizations = db.organizations.map((org) => - org.id === id ? { ...org, ...body } : org - ) - return json(db.organizations.find((org) => org.id === id)) - } - - if (path === '/branch' && method === 'GET') { - return page(db.branches as unknown as Row[], url) - } - if (path === '/branch' && method === 'POST') { - // `organizationId` javobda qaytmaydi — backendda ham `BranchDto` - // da tashkilot yo'q (izohga olingan). - const branch = { - id: nextId('b'), - name: body.name, - address: body.address, - googleMapsUrl: body.googleMapsUrl, - latitude: body.latitude, - longitude: body.longitude, - googlePlaceId: body.googlePlaceId, - } as BranchDto - db.branches = [...db.branches, branch] - return json(branch) - } - if (path.startsWith('/branch/') && method === 'GET') { - const id = path.slice('/branch/'.length) - const branch = db.branches.find((item) => item.id === id) - return branch ? json(branch) : json({ message: 'Branch not found' }, 404) - } - if (path.startsWith('/branch/') && method === 'PUT') { - const id = path.split('/')[2] - db.branches = db.branches.map((b) => (b.id === id ? { ...b, ...body } : b)) - return json(db.branches.find((b) => b.id === id)) - } - if (path.startsWith('/branch/') && method === 'DELETE') { - const id = path.split('/')[2] - db.branches = db.branches.filter((b) => b.id !== id) - return noContent() - } - - // --- to'lovlar --- - if (path === '/invoice' && method === 'GET') { - const status = url.searchParams.get('status') - const rows = status - ? db.invoices.filter((invoice) => invoice.status === status) - : db.invoices - return page(rows as unknown as Row[], url) - } - if (path === '/invoice' && method === 'POST') { - const student = db.students.find((item) => item.id === String(body.studentId)) - const invoice: InvoiceDto = { - id: nextId('i'), - invoiceNumber: `INV-${String(db.invoices.length + 1).padStart(3, '0')}`, - student, - amount: Number(body.amount), - issuedAt: new Date().toISOString().slice(0, 19), - // Backend ham shunday qiladi: yangi hisob doim kutilmoqda. - status: 'PENDING', - } - db.invoices = [...db.invoices, invoice] - return json(invoice) - } - if (path === '/invoice/return' && method === 'POST') { - // Haqiqiy backend o'tilgan darslar pulini ushlab qoladi; demo'da - // shunchaki oxirgi to'lovning yarmini qaytargan bo'lamiz. - const studentId = url.searchParams.get('studentId') ?? '' - const paid = db.invoices.find( - (item) => item.student?.id === studentId && item.status === 'PAID' - ) - const refundRecord: InvoiceDto = { - id: nextId('i'), - invoiceNumber: `RET-${String(db.invoices.length + 1).padStart(3, '0')}`, - student: paid?.student, - amount: Math.round((paid?.amount ?? 0) / 2), - issuedAt: new Date().toISOString().slice(0, 19), - status: 'PAID', - type: 'RETURN', - } - db.invoices = [...db.invoices, refundRecord] - return json(refundRecord) - } - if (path.startsWith('/invoice/') && method === 'PUT') { - const id = path.split('/')[2] - db.invoices = db.invoices.map((invoice) => - invoice.id === id ? { ...invoice, status: body.status as InvoiceStatus } : invoice - ) - return json(db.invoices.find((invoice) => invoice.id === id)) - } - if (path.startsWith('/invoice/') && method === 'DELETE') { - const id = path.split('/')[2] - db.invoices = db.invoices.filter((invoice) => invoice.id !== id) - return noContent() - } - - // Profil saqlash `PUT /user/{id}` orqali ketadi — demo'da shunchaki - // yangi qiymatni qaytaramiz. - const [, resource, tail] = path.split('/') - if (resource === 'user' && method === 'PUT') { - Object.assign(demoUser, body) - return json(demoUser) - } - - // --- generik CRUD: /student, /teacher, /group, /lesson --- - const table = { - student: 'students', - teacher: 'teachers', - group: 'groups', - lesson: 'lessons', - }[resource] as 'students' | 'teachers' | 'groups' | 'lessons' | undefined - - if (!table) return json({ message: `No mock for ${path}` }, 404) - - if (tail === 'count') return json(db[table].length) - - if (method === 'GET') { - const rows = db[table] as unknown as Row[] - const status = url.searchParams.get('status') - const filtered = - table === 'groups' && status ? rows.filter((row) => row.status === status) : rows - return page(filtered, url) - } - - if (method === 'POST') { - // Dars boshlash: o'qituvchi paneli LessonDto kutadi. - if (table === 'lessons') { - const group = db.groups.find((item) => item.id === String(body.groupId)) - const lesson: LessonDto = { - id: nextId('l'), - title: String(db.lessons.length + 12), - lessonDate: new Date().toISOString().slice(0, 19), - isComplete: false, - group, - teacherDto: group?.teacher, - } - db.lessons = [...db.lessons, lesson] - return json(lesson) - } - const created = { id: nextId(resource[0]), ...flatten(body) } as Row - ;(db[table] as unknown as Row[]).push(created) - return json(created) - } - - if (method === 'PUT' && tail) { - const rows = db[table] as unknown as Row[] - const index = rows.findIndex((row) => row.id === tail) - if (index >= 0) rows[index] = { ...rows[index], ...flatten(body), id: tail } - return json(rows[index] ?? null) - } - - if (method === 'DELETE' && tail) { - db[table] = (db[table] as unknown as Row[]).filter( - (row) => row.id !== tail - ) as never - return new Response('', { status: 204 }) - } - - return json({ message: `No mock for ${method} ${path}` }, 405) - } - - if (typeof window !== 'undefined') window.fetch = mockFetch - if (typeof globalThis !== 'undefined') globalThis.fetch = mockFetch -} - -/** - * Create/Update DTO'sini o'qish DTO'siga qaytaradi (backend shuni qiladi): - * `{ user: {...} }` → `{ userDto: {...} }`, `teacherId` → to'liq o'qituvchi. - */ -function flatten(body: Record): Record { - const result: Record = { ...body } - - const user = (body.userCreateDto ?? body.user) as Record | undefined - if (user) { - result.userDto = user - delete result.user - delete result.userCreateDto - } - - if (typeof body.teacherId === 'string') { - result.teacher = db.teachers.find((teacher) => teacher.id === body.teacherId) - delete result.teacherId - } - - if (body.timeTable) result.timeTable = body.timeTable - - return result -} diff --git a/src/demo/mockApi/analytics.ts b/src/demo/mockApi/analytics.ts new file mode 100644 index 0000000..38bbfb7 --- /dev/null +++ b/src/demo/mockApi/analytics.ts @@ -0,0 +1,44 @@ +import { groupRoster } from '../mockData' +import { db, json } from './state' + +export function handleAnalytics(path: string, method: string): Response | null { + if (path.startsWith('/analytics/') && method === 'GET') { + const category = path.slice('/analytics/'.length) + switch (category) { + case 'student': + return json({ + studentCount: db.students.length, + studentsAddedInMonth: 3, + }) + case 'teacher': + return json({ + teacherCount: db.teachers.length, + teachersAddedInMonth: 1, + }) + case 'lead': + return json({ + leadCount: db.leads.length, + leadCountInAMonth: 5, + }) + case 'invoice': { + const totalAmount = db.invoices.reduce((sum, inv) => sum + (inv.amount ?? 0), 0) + return json({ + invoiceAmount: totalAmount, + invoiceAmountInAMonth: 1050000, + }) + } + case 'enrollment': + return json({ + enrollmentCount: Object.values(groupRoster).reduce((sum, ids) => sum + ids.length, 0), + enrollmentCountInAMonth: 4, + }) + case 'branch': + return json({ + branchCount: db.branches.length, + }) + default: + return json({ message: `Unknown analytics category: ${category}` }, 404) + } + } + return null +} diff --git a/src/demo/mockApi/attendance.ts b/src/demo/mockApi/attendance.ts new file mode 100644 index 0000000..4b0d052 --- /dev/null +++ b/src/demo/mockApi/attendance.ts @@ -0,0 +1,59 @@ +import type { AttendanceDto } from '@/shared/types' +import { db, json, nextId } from './state' + +export function handleAttendance( + path: string, + method: string, + body: Record +): Response | null { + /** + * Guruhning oylik davomati. + * + * Demo'da oy bo'yicha filtrlash yo'q: mock ma'lumot bitta oyga tegishli, + * shuning uchun `previousMonths` qabul qilinadi-yu, natijani + * o'zgartirmaydi — ekranni ko'rsatish uchun shu yetarli. + */ + if (path.startsWith('/attendance/monthly/') && method === 'GET') { + const groupId = path.slice('/attendance/monthly/'.length) + const groupLessons = db.lessons.filter((lesson) => lesson.group?.id === groupId) + return json( + groupLessons.map((lesson) => { + const record = db.attendance.find((item) => item.lessonId === lesson.id) + const attendanceStudentMap: Record = {} + for (const entry of record?.attendanceStudents ?? []) { + if (entry.studentId && entry.status) { + attendanceStudentMap[entry.studentId] = { status: entry.status, reason: entry.reason } + } + } + return { + // `id` — davomat yozuvining o'zi (PUT shu yerga boradi), dars emas. + id: record?.id ?? lesson.id, + lessonTitle: lesson.topic ?? lesson.title ?? '', + date: lesson.lessonDate?.slice(0, 10) ?? '', + attendanceStudentMap, + } + }) + ) + } + if (path === '/attendance' && method === 'GET') return json(db.attendance) + if (path === '/attendance' && method === 'POST') { + const record: AttendanceDto = { + id: nextId('a'), + lessonId: String(body.lessonId), + createdAt: new Date().toISOString(), + attendanceStudents: body.students as AttendanceDto['attendanceStudents'], + } + db.attendance = [...db.attendance, record] + return json(record) + } + if (path.startsWith('/attendance/') && method === 'PUT') { + const id = path.slice('/attendance/'.length) + const students = body.attendanceStudents as AttendanceDto['attendanceStudents'] + db.attendance = db.attendance.map((item) => + item.id === id ? { ...item, attendanceStudents: students } : item + ) + const updated = db.attendance.find((item) => item.id === id) + return updated ? json(updated) : json({ message: 'Attendance not found' }, 404) + } + return null +} diff --git a/src/demo/mockApi/auth.ts b/src/demo/mockApi/auth.ts new file mode 100644 index 0000000..88a978f --- /dev/null +++ b/src/demo/mockApi/auth.ts @@ -0,0 +1,15 @@ +import { demoUser, getDemoRole, json, makeToken } from './state' + +export function handleAuth(path: string): Response | null { + if (path === '/auth/refresh-token' || path === '/auth/login') { + return json({ token: makeToken(getDemoRole()), expiry: '2099-01-01T00:00:00Z' }) + } + if (path === '/auth/me') { + return json(demoUser) + } + if (path === '/auth/change-password') { + // Demo'da har doim muvaffaqiyatli — haqiqiy tekshiruv backendda. + return json({ response: 'Password changed successfully' }) + } + return null +} diff --git a/src/demo/mockApi/enrollments.ts b/src/demo/mockApi/enrollments.ts new file mode 100644 index 0000000..d30e230 --- /dev/null +++ b/src/demo/mockApi/enrollments.ts @@ -0,0 +1,34 @@ +import { groupRoster } from '../mockData' +import { json, noContent } from './state' + +export function handleEnrollments( + path: string, + method: string, + url: URL, + body: Record +): Response | null { + if (path === '/enrollments' && method === 'GET') { + const groupId = url.searchParams.get('groupId') ?? '' + const ids = groupRoster[groupId] ?? [] + return json({ + // Enrollment id si demo'da guruh+o'quvchidan yasaladi — + // haqiqiy backendda u alohida yozuvning id si. + content: ids.map((studentId) => ({ id: `e-${groupId}-${studentId}`, studentId, groupId })), + totalPages: 1, + totalElements: ids.length, + }) + } + if (path === '/enrollments' && method === 'POST') { + const groupId = String(body.groupId) + const studentId = String(body.studentId) + groupRoster[groupId] = [...(groupRoster[groupId] ?? []), studentId] + return json({ id: `e-${groupId}-${studentId}`, studentId, groupId }) + } + if (path.startsWith('/enrollments/') && method === 'DELETE') { + // `e--` ni teskari yechamiz. + const [, groupId, studentId] = path.split('/')[2].split('-') + groupRoster[groupId] = (groupRoster[groupId] ?? []).filter((id) => id !== studentId) + return noContent() + } + return null +} diff --git a/src/demo/mockApi/generic.ts b/src/demo/mockApi/generic.ts new file mode 100644 index 0000000..9a47aaa --- /dev/null +++ b/src/demo/mockApi/generic.ts @@ -0,0 +1,73 @@ +import type { LessonDto } from '@/shared/types' +import { db, demoUser, flatten, json, nextId, page, type Row } from './state' + +export function handleGeneric( + path: string, + method: string, + url: URL, + body: Record +): Response | null { + // Profil saqlash `PUT /user/{id}` orqali ketadi — demo'da shunchaki + // yangi qiymatni qaytaramiz. + const [, resource, tail] = path.split('/') + if (resource === 'user' && method === 'PUT') { + Object.assign(demoUser, body) + return json(demoUser) + } + + // --- generik CRUD: /student, /teacher, /group, /lesson --- + const table = { + student: 'students', + teacher: 'teachers', + group: 'groups', + lesson: 'lessons', + }[resource] as 'students' | 'teachers' | 'groups' | 'lessons' | undefined + + if (!table) return json({ message: `No mock for ${path}` }, 404) + + if (tail === 'count') return json(db[table].length) + + if (method === 'GET') { + const rows = db[table] as unknown as Row[] + const status = url.searchParams.get('status') + const filtered = + table === 'groups' && status ? rows.filter((row) => row.status === status) : rows + return page(filtered, url) + } + + if (method === 'POST') { + // Dars boshlash: o'qituvchi paneli LessonDto kutadi. + if (table === 'lessons') { + const group = db.groups.find((item) => item.id === String(body.groupId)) + const lesson: LessonDto = { + id: nextId('l'), + title: String(db.lessons.length + 12), + lessonDate: new Date().toISOString().slice(0, 19), + isComplete: false, + group, + teacherDto: group?.teacher, + } + db.lessons = [...db.lessons, lesson] + return json(lesson) + } + const created = { id: nextId(resource[0]), ...flatten(body) } as Row + ;(db[table] as unknown as Row[]).push(created) + return json(created) + } + + if (method === 'PUT' && tail) { + const rows = db[table] as unknown as Row[] + const index = rows.findIndex((row) => row.id === tail) + if (index >= 0) rows[index] = { ...rows[index], ...flatten(body), id: tail } + return json(rows[index] ?? null) + } + + if (method === 'DELETE' && tail) { + db[table] = (db[table] as unknown as Row[]).filter( + (row) => row.id !== tail + ) as never + return new Response('', { status: 204 }) + } + + return json({ message: `No mock for ${method} ${path}` }, 405) +} diff --git a/src/demo/mockApi/groupLevels.ts b/src/demo/mockApi/groupLevels.ts new file mode 100644 index 0000000..91626d2 --- /dev/null +++ b/src/demo/mockApi/groupLevels.ts @@ -0,0 +1,59 @@ +import type { GroupLevelDto } from '@/shared/types' +import { db, json, nextId, noContent } from './state' + +export function handleGroupLevels( + path: string, + method: string, + body: Record +): Response | null { + if (path === '/group-level/names' && method === 'GET') { + return json(db.groupLevels.map((gl) => ({ id: gl.id, name: gl.name }))) + } + if (path === '/group-level' && method === 'GET') { + return json(db.groupLevels) + } + if (path === '/group-level' && method === 'POST') { + const level: GroupLevelDto = { + id: nextId('lvl'), + name: String(body.name ?? ''), + lessonCount: Number(body.lessonCount ?? 0), + orderNumber: db.groupLevels.length + 1, + durationInMonths: Number(body.durationInMonths ?? 0), + monthlyFee: Number(body.monthlyFee ?? 0), + } + db.groupLevels = [...db.groupLevels, level] + return json(level) + } + if (path === '/group-level' && method === 'PUT') { + // Tartibni yangilash: { levels: [{ id, orderNumber }] } + const levels = body.levels as Array<{ id: string; orderNumber: number }> | undefined + if (Array.isArray(levels)) { + const orderMap = new Map(levels.map((item) => [item.id, item.orderNumber])) + db.groupLevels = db.groupLevels + .map((gl) => (orderMap.has(gl.id) ? { ...gl, orderNumber: orderMap.get(gl.id)! } : gl)) + .sort((a, b) => a.orderNumber - b.orderNumber) + } + return json(db.groupLevels) + } + if (path.startsWith('/group-level/') && method === 'PUT') { + const id = path.slice('/group-level/'.length) + db.groupLevels = db.groupLevels.map((gl) => { + if (gl.id !== id) return gl + return { + ...gl, + ...(body.name !== undefined ? { name: String(body.name) } : {}), + ...(body.lessonCount !== undefined ? { lessonCount: Number(body.lessonCount) } : {}), + ...(body.durationInMonths !== undefined ? { durationInMonths: Number(body.durationInMonths) } : {}), + ...(body.monthlyFee !== undefined ? { monthlyFee: Number(body.monthlyFee) } : {}), + } + }) + const updated = db.groupLevels.find((gl) => gl.id === id) + return updated ? json(updated) : json({ message: 'Group level not found' }, 404) + } + if (path.startsWith('/group-level/') && method === 'DELETE') { + const id = path.slice('/group-level/'.length) + db.groupLevels = db.groupLevels.filter((gl) => gl.id !== id) + return noContent() + } + return null +} diff --git a/src/demo/mockApi/index.ts b/src/demo/mockApi/index.ts new file mode 100644 index 0000000..e8d4a70 --- /dev/null +++ b/src/demo/mockApi/index.ts @@ -0,0 +1,81 @@ +import { handleAnalytics } from './analytics' +import { handleAttendance } from './attendance' +import { handleAuth } from './auth' +import { handleEnrollments } from './enrollments' +import { handleGeneric } from './generic' +import { handleGroupLevels } from './groupLevels' +import { handleInvoices } from './invoices' +import { handleLeads } from './leads' +import { handleOrganizations } from './organizations' +import { isInstalled, resetMockApiInstalledFlag, setDemoRole, setInstalled } from './state' +import { handleStudents } from './students' +import { handleTeacher } from './teacher' + +export { setDemoRole, resetMockApiInstalledFlag } + +/** `fetch` ni bir marta almashtiradi (qayta chaqirilsa hech narsa qilmaydi). */ +export function installMockApi() { + if (isInstalled()) return + setInstalled(true) + + const fetchImpl = typeof window !== 'undefined' ? window.fetch : globalThis.fetch + const original = fetchImpl.bind(typeof window !== 'undefined' ? window : globalThis) + + const mockFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (!raw.includes('/api/v1/')) return original(input as RequestInfo, init) + + const origin = + typeof window !== 'undefined' && window.location && window.location.origin && window.location.origin !== 'null' + ? window.location.origin + : 'http://localhost' + const url = new URL(raw, origin) + const path = url.pathname.replace('/api/v1', '') + const method = (init?.method ?? 'GET').toUpperCase() + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {} + + // Haqiqiy tarmoqqa o'xshasin — spinner'lar ko'rinib qolsin. + await new Promise((resolve) => setTimeout(resolve, 180)) + + const authRes = handleAuth(path) + if (authRes) return authRes + + const enrollmentsRes = handleEnrollments(path, method, url, body) + if (enrollmentsRes) return enrollmentsRes + + const studentsRes = handleStudents(path, method, url) + if (studentsRes) return studentsRes + + const teacherRes = handleTeacher(path, url) + if (teacherRes) return teacherRes + + const attendanceRes = handleAttendance(path, method, body) + if (attendanceRes) return attendanceRes + + const leadsRes = handleLeads(path, method, url, body) + if (leadsRes) return leadsRes + + const groupLevelsRes = handleGroupLevels(path, method, body) + if (groupLevelsRes) return groupLevelsRes + + const analyticsRes = handleAnalytics(path, method) + if (analyticsRes) return analyticsRes + + const orgsRes = handleOrganizations(path, method, url, body) + if (orgsRes) return orgsRes + + const invoicesRes = handleInvoices(path, method, url, body) + if (invoicesRes) return invoicesRes + + const genericRes = handleGeneric(path, method, url, body) + if (genericRes) return genericRes + + return new Response(JSON.stringify({ message: `No mock for ${method} ${path}` }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (typeof window !== 'undefined') window.fetch = mockFetch + if (typeof globalThis !== 'undefined') globalThis.fetch = mockFetch +} diff --git a/src/demo/mockApi/invoices.ts b/src/demo/mockApi/invoices.ts new file mode 100644 index 0000000..533bc0f --- /dev/null +++ b/src/demo/mockApi/invoices.ts @@ -0,0 +1,64 @@ +import type { InvoiceDto, InvoiceStatus } from '@/shared/types' +import { db, json, nextId, noContent, page, type Row } from './state' + +export function handleInvoices( + path: string, + method: string, + url: URL, + body: Record +): Response | null { + if (path === '/invoice' && method === 'GET') { + const status = url.searchParams.get('status') + const rows = status + ? db.invoices.filter((invoice) => invoice.status === status) + : db.invoices + return page(rows as unknown as Row[], url) + } + if (path === '/invoice' && method === 'POST') { + const student = db.students.find((item) => item.id === String(body.studentId)) + const invoice: InvoiceDto = { + id: nextId('i'), + invoiceNumber: `INV-${String(db.invoices.length + 1).padStart(3, '0')}`, + student, + amount: Number(body.amount), + issuedAt: new Date().toISOString().slice(0, 19), + // Backend ham shunday qiladi: yangi hisob doim kutilmoqda. + status: 'PENDING', + } + db.invoices = [...db.invoices, invoice] + return json(invoice) + } + if (path === '/invoice/return' && method === 'POST') { + // Haqiqiy backend o'tilgan darslar pulini ushlab qoladi; demo'da + // shunchaki oxirgi to'lovning yarmini qaytargan bo'lamiz. + const studentId = url.searchParams.get('studentId') ?? '' + const paid = db.invoices.find( + (item) => item.student?.id === studentId && item.status === 'PAID' + ) + const refundRecord: InvoiceDto = { + id: nextId('i'), + invoiceNumber: `RET-${String(db.invoices.length + 1).padStart(3, '0')}`, + student: paid?.student, + amount: Math.round((paid?.amount ?? 0) / 2), + issuedAt: new Date().toISOString().slice(0, 19), + status: 'PAID', + type: 'RETURN', + } + db.invoices = [...db.invoices, refundRecord] + return json(refundRecord) + } + if (path.startsWith('/invoice/') && method === 'PUT') { + const id = path.split('/')[2] + db.invoices = db.invoices.map((invoice) => + invoice.id === id ? { ...invoice, status: body.status as InvoiceStatus } : invoice + ) + return json(db.invoices.find((invoice) => invoice.id === id)) + } + if (path.startsWith('/invoice/') && method === 'DELETE') { + const id = path.split('/')[2] + db.invoices = db.invoices.filter((invoice) => invoice.id !== id) + return noContent() + } + + return null +} diff --git a/src/demo/mockApi/leads.ts b/src/demo/mockApi/leads.ts new file mode 100644 index 0000000..44d79f3 --- /dev/null +++ b/src/demo/mockApi/leads.ts @@ -0,0 +1,79 @@ +import type { LeadDto, LeadStatus } from '@/shared/types' +import { db, json, nextId, noContent, page, type Row } from './state' + +export function handleLeads( + path: string, + method: string, + url: URL, + body: Record +): Response | null { + if (path === '/leads' && method === 'GET') { + const status = url.searchParams.get('status') + const rows = status + ? db.leads.filter((lead) => lead.status === status) + : db.leads + return page(rows as unknown as Row[], url) + } + if (path === '/leads' && method === 'POST') { + const level = db.groupLevels.find((item) => item.id === String(body.preferredCourse)) + const newLead: LeadDto = { + id: nextId('ld'), + fullName: String(body.fullName ?? ''), + phone: String(body.phone ?? ''), + status: 'NEW', + source: body.source as LeadDto['source'], + preferredCourse: level, + createdAt: new Date().toISOString(), + } + db.leads = [newLead, ...db.leads] + return json(newLead) + } + if (path.startsWith('/leads/') && method === 'PUT') { + const id = path.slice('/leads/'.length) + const level = body.preferredCourse + ? db.groupLevels.find((item) => item.id === String(body.preferredCourse)) + : undefined + db.leads = db.leads.map((lead) => { + if (lead.id !== id) return lead + return { + ...lead, + fullName: body.fullName !== undefined ? String(body.fullName) : lead.fullName, + phone: body.phone !== undefined ? String(body.phone) : lead.phone, + status: (body.status as LeadStatus) ?? lead.status, + source: body.source ? (body.source as LeadDto['source']) : lead.source, + preferredCourse: level ?? lead.preferredCourse, + callAt: body.callAt !== undefined ? String(body.callAt) : lead.callAt, + updatedAt: new Date().toISOString(), + } + }) + const updated = db.leads.find((lead) => lead.id === id) + return updated ? json(updated) : json({ message: 'Lead not found' }, 404) + } + if (path.startsWith('/leads/') && path.endsWith('/enroll') && method === 'POST') { + const id = path.slice('/leads/'.length, -'/enroll'.length) + db.leads = db.leads.map((lead) => (lead.id === id ? { ...lead, status: 'ENROLLED' } : lead)) + const updated = db.leads.find((lead) => lead.id === id) + return updated ? json(updated) : json({ message: 'Lead not found' }, 404) + } + if (path.startsWith('/leads/') && path.endsWith('/reject') && method === 'POST') { + const id = path.slice('/leads/'.length, -'/reject'.length) + db.leads = db.leads.map((lead) => (lead.id === id ? { ...lead, status: 'REJECTED' } : lead)) + const updated = db.leads.find((lead) => lead.id === id) + return updated ? json(updated) : json({ message: 'Lead not found' }, 404) + } + if (path.startsWith('/leads/') && path.endsWith('/callLater') && method === 'PATCH') { + const id = path.slice('/leads/'.length, -'/callLater'.length) + const callAtParam = url.searchParams.get('callAt') ?? undefined + db.leads = db.leads.map((lead) => + lead.id === id ? { ...lead, status: 'CALL_LATER', callAt: callAtParam } : lead + ) + const updated = db.leads.find((lead) => lead.id === id) + return updated ? json(updated) : json({ message: 'Lead not found' }, 404) + } + if (path.startsWith('/leads/') && method === 'DELETE') { + const id = path.slice('/leads/'.length) + db.leads = db.leads.filter((lead) => lead.id !== id) + return noContent() + } + return null +} diff --git a/src/demo/mockApi/organizations.ts b/src/demo/mockApi/organizations.ts new file mode 100644 index 0000000..996c3c1 --- /dev/null +++ b/src/demo/mockApi/organizations.ts @@ -0,0 +1,61 @@ +import type { BranchDto, OrganizationDto } from '@/shared/types' +import { db, json, nextId, noContent, page, type Row } from './state' + +export function handleOrganizations( + path: string, + method: string, + url: URL, + body: Record +): Response | null { + if (path === '/organizations' && method === 'GET') { + return page(db.organizations as unknown as Row[], url) + } + if (path === '/organizations' && method === 'POST') { + const org = { id: nextId('o'), ...body } as OrganizationDto + db.organizations = [...db.organizations, org] + return json(org) + } + if (path.startsWith('/organizations/') && method === 'PUT') { + const id = path.split('/')[2] + db.organizations = db.organizations.map((org) => + org.id === id ? { ...org, ...body } : org + ) + return json(db.organizations.find((org) => org.id === id)) + } + + if (path === '/branch' && method === 'GET') { + return page(db.branches as unknown as Row[], url) + } + if (path === '/branch' && method === 'POST') { + // `organizationId` javobda qaytmaydi — backendda ham `BranchDto` + // da tashkilot yo'q (izohga olingan). + const branch = { + id: nextId('b'), + name: body.name, + address: body.address, + googleMapsUrl: body.googleMapsUrl, + latitude: body.latitude, + longitude: body.longitude, + googlePlaceId: body.googlePlaceId, + } as BranchDto + db.branches = [...db.branches, branch] + return json(branch) + } + if (path.startsWith('/branch/') && method === 'GET') { + const id = path.slice('/branch/'.length) + const branch = db.branches.find((item) => item.id === id) + return branch ? json(branch) : json({ message: 'Branch not found' }, 404) + } + if (path.startsWith('/branch/') && method === 'PUT') { + const id = path.split('/')[2] + db.branches = db.branches.map((b) => (b.id === id ? { ...b, ...body } : b)) + return json(db.branches.find((b) => b.id === id)) + } + if (path.startsWith('/branch/') && method === 'DELETE') { + const id = path.split('/')[2] + db.branches = db.branches.filter((b) => b.id !== id) + return noContent() + } + + return null +} diff --git a/src/demo/mockApi/state.ts b/src/demo/mockApi/state.ts new file mode 100644 index 0000000..f0519b0 --- /dev/null +++ b/src/demo/mockApi/state.ts @@ -0,0 +1,162 @@ +import { + attendance, + branches, + fullGroup, + groupLevels, + groups, + invoices, + leads, + lessons, + organizations, + students, + teachers, +} from '../mockData' +import { ADMIN_PERMISSIONS } from '@/shared/types' +import type { + AttendanceDto, + BranchDto, + GroupDto, + GroupLevelDto, + InvoiceDto, + LeadDto, + LessonDto, + OrganizationDto, + StudentDto, + TeacherDto, +} from '@/shared/types' + +export type Row = Record & { id: string } + +/** `GET /auth/me` javobi — demo foydalanuvchisi. */ +export const demoUser = { + id: 'u-demo', + // Sozlamalardagi markaz bloki shu filialni yuklaydi. + branchId: 'b1', + fullName: 'Demo Foydalanuvchi', + phone: '+998 93 100 10 01', + birthDate: '1995-06-15', + imageUrl: undefined, + role: 'ADMINISTRATOR', +} + +export const db = { + students: [...students] as StudentDto[], + teachers: [...teachers] as TeacherDto[], + groups: [...groups] as GroupDto[], + lessons: [...lessons] as LessonDto[], + attendance: [...attendance] as AttendanceDto[], + invoices: [...invoices] as InvoiceDto[], + organizations: [...organizations] as OrganizationDto[], + branches: [...branches] as BranchDto[], + groupLevels: [...groupLevels] as GroupLevelDto[], + leads: [...leads] as LeadDto[], +} + +export { fullGroup } + +let installed = false + +/** Joriy demo roli — `setDemoRole` orqali almashtiriladi. */ +let currentRole = 'ADMINISTRATOR' + +export function getDemoRole() { + return currentRole +} + +export function setDemoRole(role: string) { + currentRole = role +} + +export function isInstalled() { + return installed +} + +export function setInstalled(val: boolean) { + installed = val +} + +/** Testing helper: allows reinstalling mock in test runners. */ +export function resetMockApiInstalledFlag() { + installed = false +} + +/** + * Imzosiz, lekin to'g'ri tuzilgan JWT (ilova faqat payload'ni o'qiydi). + * + * `ADMINISTRATOR` uchun BARCHA ruxsatlar beriladi — demo cheklangan + * administratorni emas, ilovaning to'liq imkoniyatini ko'rsatishi kerak. + */ +export function makeToken(role: string): string { + const encode = (value: object) => { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + } + const permissions = role === 'ADMINISTRATOR' ? ADMIN_PERMISSIONS : undefined + return `${encode({ alg: 'none' })}.${encode({ role, permissions, sub: 'demo', name: 'Demo user' })}.demo` +} + +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** + * Tanasiz javob (`DELETE` uchun). + * + * `new Response(body, { status: 204 })` — brauzer buni rad etadi: + * "Response with null body status cannot have body". Backend ham + * `noContent()` qaytaradi, ya'ni shakl ham to'g'ri bo'ladi. + */ +export function noContent(): Response { + return new Response(null, { status: 204 }) +} + +/** Spring Data `Page` ko'rinishida qaytaradi. */ +export function page(rows: T[], url: URL) { + const size = Number(url.searchParams.get('size') ?? 10) + const index = Number(url.searchParams.get('page') ?? 0) + const search = (url.searchParams.get('search') ?? '').toLowerCase() + + const filtered = search + ? rows.filter((row) => JSON.stringify(row).toLowerCase().includes(search)) + : rows + + return json({ + content: filtered.slice(index * size, index * size + size), + totalPages: Math.max(1, Math.ceil(filtered.length / size)), + totalElements: filtered.length, + }) +} + +export function nextId(prefix: string) { + return `${prefix}${Math.random().toString(36).slice(2, 8)}` +} + +/** + * Create/Update DTO'sini o'qish DTO'siga qaytaradi (backend shuni qiladi): + * `{ user: {...} }` → `{ userDto: {...} }`, `teacherId` → to'liq o'qituvchi. + */ +export function flatten(body: Record): Record { + const result: Record = { ...body } + + const user = (body.userCreateDto ?? body.user) as Record | undefined + if (user) { + result.userDto = user + delete result.user + delete result.userCreateDto + } + + if (typeof body.teacherId === 'string') { + result.teacher = db.teachers.find((teacher) => teacher.id === body.teacherId) + delete result.teacherId + } + + if (body.timeTable) result.timeTable = body.timeTable + + return result +} diff --git a/src/demo/mockApi/students.ts b/src/demo/mockApi/students.ts new file mode 100644 index 0000000..872f861 --- /dev/null +++ b/src/demo/mockApi/students.ts @@ -0,0 +1,68 @@ +import { groupRoster } from '../mockData' +import { db, demoUser, json } from './state' + +export function handleStudents( + path: string, + method: string, + url: URL +): Response | null { + // Guruh o'quvchilari — davomat jadvalining qatorlari. + if (path.startsWith('/student/') && path.endsWith('/students') && method === 'GET') { + const groupId = path.slice('/student/'.length, -'/students'.length) + const ids = groupRoster[groupId] ?? [] + return json(db.students.filter((student) => ids.includes(student.id))) + } + if (path === '/student/phone') { + const phone = url.searchParams.get('phone') ?? '' + return json(db.students.filter((student) => student.userDto?.phone === phone)) + } + + // Kirgan o'quvchining o'z yozuvi. Demo'da "kirgan o'quvchi" — + // `demoUser` telefoni bilan mos keladigan yozuv. Balans va to'lov + // holati guruhga bog'liq, shuning uchun `groupId` shart. + if (path === '/student/me' && method === 'GET') { + const me = db.students.find((student) => student.userDto?.phone === demoUser.phone) + if (!me) return json({ message: 'Student not found' }, 404) + const groupId = url.searchParams.get('groupId') ?? '' + if (!groupId) return json({ message: 'groupId is required' }, 400) + return json({ ...me, balance: -300000, status: 'PARTIAL' }) + } + + // O'quvchi panelidagi guruh va davomat bloklari — demo'da kirgan + // "o'quvchi" telefon raqami bo'yicha topiladi (haqiqiy backendda esa + // token orqali). + if (path === '/group/my' && method === 'GET') { + const me = db.students.find((student) => student.userDto?.phone === demoUser.phone) + const myGroupIds = Object.entries(groupRoster) + .filter(([, ids]) => (me ? ids.includes(me.id) : false)) + .map(([groupId]) => groupId) + // Backend `lessonsCount` ni bu endpoint uchun doim `null` qaytaradi — + // demo ham shu xatti-harakatni takrorlaydi. + return json( + db.groups + .filter((group) => myGroupIds.includes(group.id)) + .map((group) => ({ ...group, lessonsCount: null })) + ) + } + if (path.startsWith('/attendance/my/') && method === 'GET') { + const me = db.students.find((student) => student.userDto?.phone === demoUser.phone) + const groupId = path.slice('/attendance/my/'.length) + const groupLessons = db.lessons.filter((lesson) => lesson.group?.id === groupId) + const entries = groupLessons + .map((lesson) => { + const record = db.attendance.find((item) => item.lessonId === lesson.id) + const mine = record?.attendanceStudents?.find((entry) => entry.studentId === me?.id) + if (!mine) return null + return { + title: lesson.topic ?? lesson.title ?? '', + date: lesson.lessonDate?.slice(0, 10) ?? '', + status: mine.status, + reason: mine.reason, + } + }) + .filter((entry) => entry !== null) + return json(entries) + } + + return null +} diff --git a/src/demo/mockApi/teacher.ts b/src/demo/mockApi/teacher.ts new file mode 100644 index 0000000..538dccc --- /dev/null +++ b/src/demo/mockApi/teacher.ts @@ -0,0 +1,11 @@ +import { db, fullGroup, json } from './state' + +export function handleTeacher(path: string, url: URL): Response | null { + if (path === '/group/groups') { + return json(db.groups.filter((group) => group.status !== 'COMPLETED')) + } + if (path === '/group/groupInfo') { + return json(fullGroup(url.searchParams.get('groupId') ?? 'g1')) + } + return null +} diff --git a/src/shared/types/analytics.ts b/src/shared/types/analytics.ts new file mode 100644 index 0000000..32632dc --- /dev/null +++ b/src/shared/types/analytics.ts @@ -0,0 +1,39 @@ +/** Analytics categories. */ +export type AnalyticsCategory = 'student' | 'teacher' | 'lead' | 'invoice' | 'enrollment' | 'branch' + +export interface StudentAnalyticsDto { + studentCount?: number + studentsAddedInMonth?: number +} + +export interface TeacherAnalyticsDto { + teacherCount?: number + teachersAddedInMonth?: number +} + +export interface LeadAnalyticsDto { + leadCount?: number + leadCountInAMonth?: number +} + +export interface InvoiceAnalyticsDto { + invoiceAmount?: number + invoiceAmountInAMonth?: number +} + +export interface EnrollmentAnalyticsDto { + enrollmentCount?: number + enrollmentCountInAMonth?: number +} + +export interface BranchAnalyticsDto { + branchCount?: number +} + +export type AnalyticsStatDto = + | StudentAnalyticsDto + | TeacherAnalyticsDto + | LeadAnalyticsDto + | InvoiceAnalyticsDto + | EnrollmentAnalyticsDto + | BranchAnalyticsDto diff --git a/src/shared/types/attendance.ts b/src/shared/types/attendance.ts new file mode 100644 index 0000000..3eaa52e --- /dev/null +++ b/src/shared/types/attendance.ts @@ -0,0 +1,67 @@ +/** + * Davomat statuslari. + * + * Backenddan `LATE` o'chirilgan — yangi yozuvlarda faqat uch status + * qoladi, chunki kechikish holati endi alohida ma'lumot emas. + */ +export const ATTENDANCE_STATUSES = ['PRESENT', 'ABSENT', 'EXCUSED'] as const +export type AttendanceStatus = (typeof ATTENDANCE_STATUSES)[number] + +/** O'qituvchi yangi davomatda tanlay oladigan statuslar. */ +export const SELECTABLE_ATTENDANCE_STATUSES = ['PRESENT', 'ABSENT', 'EXCUSED'] as const +export type SelectableAttendanceStatus = (typeof SELECTABLE_ATTENDANCE_STATUSES)[number] + +export interface AttendanceStudentDto { + studentId: string + studentFullName?: string + status: AttendanceStatus + /** Faqat EXCUSED uchun mantiqiy — backend boshqa statuslarda ham qabul qiladi. */ + reason?: string +} + +export interface AttendanceDto { + id?: string + lessonId: string + /** `LocalDateTime`; jadvaldagi ustun sanasi shundan olinadi. */ + createdAt?: string + attendanceStudents?: AttendanceStudentDto[] +} + +/** `attendanceStudentMap` dagi bitta yozuv. */ +export interface StatusReasonDto { + status: AttendanceStatus + reason?: string +} + +/** + * `GET /attendance/monthly/{groupId}` javobi — bitta o'tgan darsning + * davomati. + * + * `attendanceStudentMap` massiv emas, xarita: kalit — `studentId`. Xaritada + * yo'q o'quvchi hali belgilanmagan degani, "kelmadi" EMAS — jadval katagi + * shu farqni bo'sh qoldirib ko'rsatishi kerak. + * + * `id` — bu yozuvning o'zi identifikatori (`PUT /attendance/{id}` shu yerga + * yuboriladi), dars identifikatori EMAS. + */ +export interface MonthlyAttendanceDto { + id: string + lessonTitle?: string + /** `LocalDate`/`LocalDateTime` — jadvaldagi ustun sanasi shundan olinadi. */ + date?: string + attendanceStudentMap?: Record +} + +/** + * `GET /attendance/my/{groupId}` javobidagi bitta yozuv. + * + * `MonthlyAttendanceDto` dan farqli o'laroq — bu allaqachon SO'RAGAN + * o'quvchining o'zi uchun tekislangan ro'yxat, xarita emas. + */ +export interface MyAttendanceDto { + title?: string + /** `LocalDate` — "yyyy-MM-dd". */ + date?: string + status: AttendanceStatus + reason?: string +} diff --git a/src/shared/types/common.ts b/src/shared/types/common.ts new file mode 100644 index 0000000..c3d9d81 --- /dev/null +++ b/src/shared/types/common.ts @@ -0,0 +1,56 @@ +/** Spring Data `Page` javobi. */ +export interface Page { + content?: T[] + totalPages?: number + totalElements?: number +} + +/** + * Administrator ruxsatlari — faqat `ADMINISTRATOR` rolida ma'noga ega. + * `SUPER_ADMIN` da bu ro'yxat umuman kelmaydi (unga cheklov yo'q). + */ +export const ADMIN_PERMISSIONS = [ + 'LEAD_MANAGEMENT', + 'TEACHER_MANAGEMENT', + 'STUDENT_MANAGEMENT', + 'INVOICE_MANAGEMENT', +] as const +export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number] + +/** + * JWT ichidagi claim'lar. `role` ataylab oddiy string: backend yangi rol + * qo'shsa build yiqilmasligi, balki App'dagi `default` shoxiga tushishi kerak. + */ +export interface JwtClaims { + role?: string + permissions?: AdminPermission[] + [claim: string]: unknown +} + +/** Tizimga kirgan foydalanuvchi sessiyasi. */ +export interface Session { + token: string + role: string + claims: JwtClaims + /** Faqat `ADMINISTRATOR` uchun ma'noli; boshqa rollarda bo'sh massiv. */ + permissions: AdminPermission[] +} + +/** + * `POST /auth/login` va `/auth/refresh-token` javobi. + * + * Refresh token javob TANASIDA kelmaydi — backend uni httpOnly + * `refresh_token` cookie'siga yozadi (`AuthService.setRefreshCookie`). + */ +export interface AuthResponse { + token: string + expiry?: string +} + +export interface LoginCredentials { + phone: string + password: string + rememberMe: boolean +} + +export type Role = 'SUPER_ADMIN' | 'ADMINISTRATOR' | 'TEACHER' | 'STUDENT' diff --git a/src/shared/types/group.ts b/src/shared/types/group.ts new file mode 100644 index 0000000..57b7689 --- /dev/null +++ b/src/shared/types/group.ts @@ -0,0 +1,97 @@ +import type { StudentDto } from './student' +import type { TeacherDto } from './teacher' + +/** + * Guruh jadvali turi. + * + * Backend kunlar ro'yxatini emas, shu ikki qiymatdan birini saqlaydi: + * toq kunlar (Du/Cho/Ju) yoki juft kunlar (Se/Pay/Sha). + */ +export const DAY_TYPES = ['ODD', 'EVEN'] as const +export type DayType = (typeof DAY_TYPES)[number] + +export interface TimeTableDto { + id?: string + dayType?: DayType + /** `LocalTime` — "HH:mm:ss". */ + startTime?: string + endTime?: string +} + +export const GROUP_STATUSES = ['STARTING', 'ONGOING', 'COMPLETED'] as const +export type GroupStatus = (typeof GROUP_STATUSES)[number] + +export const GROUP_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] as const +export type GroupLevel = (typeof GROUP_LEVELS)[number] + +export interface GroupLevelNameDto { + id: string + name: string +} + +export interface GroupLevelDto { + id: string + name: string + lessonCount: number + orderNumber: number + durationInMonths: number + /** + * Oylik to'lov. Backend hozircha bosh harf bilan `MonthlyFee` qaytaradi — + * bu backend'dagi xato, tan olingan va tuzatiladi. Tuzatilgunga qadar + * ikkalasini ham qabul qilamiz; keyin `MonthlyFee` olib tashlanadi. + */ + monthlyFee?: number + MonthlyFee?: number +} + +export interface GroupDto { + id: string + name?: string + room?: string + teacher?: TeacherDto + timeTable?: TimeTableDto + status?: GroupStatus + level?: GroupLevelDto + /** Kurs boshlanganidan beri nechanchi oy. */ + currentMonth?: number + /** Guruhda o'tilgan darslar soni. */ + lessonsCount?: number +} + +/** + * `GET /group/groups` (o'qituvchining guruhlari) javobi. + * + * Bu TO'LIQ `GroupDto` emas — backend `GroupNameProjection` qaytaradi, + * ya'ni faqat `id` va `name`. `dayType` optional: proyeksiyaga qo'shilsa + * o'qituvchi panelidagi toq/juft filtri o'zi ishlab ketadi. + */ +export interface GroupNameDto { + id: string + name?: string + dayType?: DayType +} + +/** `GET /group/groupInfo` javobi: guruh + uning ro'yxati. */ +export interface FullGroupDto { + groupDto?: GroupDto + studentDto?: StudentDto[] +} + +export interface LessonDto { + id: string + /** + * Dars mavzusi — o'qituvchi/admin kiritadi. Yaratishda va tahrirlashda + * yuboriladigan YAGONA maydon (`LessonCreateDto{groupId, topic}`). + */ + topic?: string + /** + * Tartib raqami ("1.2" kabi) — backend o'zi qo'yadi, biz yubormaymiz. + * Nomi `title` bo'lsa ham, bu sarlavha emas, raqam. + */ + title?: string + /** `LocalDateTime` — "yyyy-MM-ddTHH:mm:ss". */ + lessonDate?: string + isComplete?: boolean + group?: GroupDto + teacherDto?: TeacherDto +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index f186823..4a31d2c 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,450 +1,17 @@ /** * Backend DTO shakllari — bitta joyda. * - * Bu shakllar `goodman113/learning_center` repo'sidagi haqiqiy Java - * record'laridan olingan (2026-08-13 holatiga). Ilgari ular fetch - * chaqiruvlaridan taxmin qilingan edi; farq bo'lgan joylar tuzatildi. - * - * Maydonlar hamon optional: backend `null` qaytarishi mumkin va Java - * record'i buni ko'rsatmaydi. - */ - -/** Spring Data `Page` javobi. */ -export interface Page { - content?: T[] - totalPages?: number - totalElements?: number -} - -/** - * Administrator ruxsatlari — faqat `ADMINISTRATOR` rolida ma'noga ega. - * `SUPER_ADMIN` da bu ro'yxat umuman kelmaydi (unga cheklov yo'q). - */ -export const ADMIN_PERMISSIONS = [ - 'LEAD_MANAGEMENT', - 'TEACHER_MANAGEMENT', - 'STUDENT_MANAGEMENT', - 'INVOICE_MANAGEMENT', -] as const -export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number] - -/** - * JWT ichidagi claim'lar. `role` ataylab oddiy string: backend yangi rol - * qo'shsa build yiqilmasligi, balki App'dagi `default` shoxiga tushishi kerak. - */ -export interface JwtClaims { - role?: string - permissions?: AdminPermission[] - [claim: string]: unknown -} - -/** Tizimga kirgan foydalanuvchi sessiyasi. */ -export interface Session { - token: string - role: string - claims: JwtClaims - /** Faqat `ADMINISTRATOR` uchun ma'noli; boshqa rollarda bo'sh massiv. */ - permissions: AdminPermission[] -} - -/** - * `POST /auth/login` va `/auth/refresh-token` javobi. - * - * Refresh token javob TANASIDA kelmaydi — backend uni httpOnly - * `refresh_token` cookie'siga yozadi (`AuthService.setRefreshCookie`). - */ -export interface AuthResponse { - token: string - expiry?: string -} - -export interface LoginCredentials { - phone: string - password: string - rememberMe: boolean -} - -export type Role = 'SUPER_ADMIN' | 'ADMINISTRATOR' | 'TEACHER' | 'STUDENT' - -/** `UserDto` — diqqat: maydon nomi `imageUrl` (`imgUrl` emas). */ -export interface UserDto { - id?: string - /** Foydalanuvchi biriktirilgan filial — sozlamalardagi markaz bloki shuni yuklaydi. */ - branchId?: string - imageUrl?: string - fullName?: string - phone?: string - /** `LocalDate` — "yyyy-MM-dd". */ - birthDate?: string - role?: Role -} - -/** `PUT /user/{id}` uchun. */ -export interface UserUpdatePayload { - fullName: string - phone: string - birthDate: string -} - -/** `POST /auth/change-password` uchun. */ -export interface ChangePasswordPayload { - oldPassword: string - newPassword: string - confirmPassword: string -} - -/** - * O'quvchining to'lov holati bitta guruh bo'yicha (`Enrollment.status`). - * - * `PARTIAL` — qisman to'langan; ya'ni "to'lamagan" ham, "to'lagan" ham emas. - */ -export type EnrollmentPaymentStatus = 'UNPAID' | 'PARTIAL' | 'PAID' - -export interface StudentDto { - id: string - userDto?: UserDto - parentPhone?: string - /** - * Balans va to'lov holati faqat `GET /student/me?groupId=…` javobida - * keladi va BITTA GURUHGA tegishli — boshqa endpointlarda bo'sh bo'ladi. - * Manfiy son — qarz (`paidAmount - monthlyFee`). - */ - balance?: number - status?: EnrollmentPaymentStatus -} - -export interface TeacherDto { - id: string - userDto?: UserDto -} - -/** - * Guruh jadvali turi. - * - * Backend kunlar ro'yxatini emas, shu ikki qiymatdan birini saqlaydi: - * toq kunlar (Du/Cho/Ju) yoki juft kunlar (Se/Pay/Sha). - */ -export const DAY_TYPES = ['ODD', 'EVEN'] as const -export type DayType = (typeof DAY_TYPES)[number] - -export interface TimeTableDto { - id?: string - dayType?: DayType - /** `LocalTime` — "HH:mm:ss". */ - startTime?: string - endTime?: string -} - -export const GROUP_STATUSES = ['STARTING', 'ONGOING', 'COMPLETED'] as const -export type GroupStatus = (typeof GROUP_STATUSES)[number] - -export const GROUP_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] as const -export type GroupLevel = (typeof GROUP_LEVELS)[number] - -export interface GroupLevelNameDto { - id: string - name: string -} - -export interface GroupLevelDto { - id: string - name: string - lessonCount: number - orderNumber: number - durationInMonths: number - /** - * Oylik to'lov. Backend hozircha bosh harf bilan `MonthlyFee` qaytaradi — - * bu backend'dagi xato, tan olingan va tuzatiladi. Tuzatilgunga qadar - * ikkalasini ham qabul qilamiz; keyin `MonthlyFee` olib tashlanadi. - */ - monthlyFee?: number - MonthlyFee?: number -} - -export interface GroupDto { - id: string - name?: string - room?: string - teacher?: TeacherDto - timeTable?: TimeTableDto - status?: GroupStatus - level?: GroupLevelDto - /** Kurs boshlanganidan beri nechanchi oy. */ - currentMonth?: number - /** Guruhda o'tilgan darslar soni. */ - lessonsCount?: number -} - -/** - * `GET /group/groups` (o'qituvchining guruhlari) javobi. - * - * Bu TO'LIQ `GroupDto` emas — backend `GroupNameProjection` qaytaradi, - * ya'ni faqat `id` va `name`. `dayType` optional: proyeksiyaga qo'shilsa - * o'qituvchi panelidagi toq/juft filtri o'zi ishlab ketadi. - */ -export interface GroupNameDto { - id: string - name?: string - dayType?: DayType -} - -/** `GET /group/groupInfo` javobi: guruh + uning ro'yxati. */ -export interface FullGroupDto { - groupDto?: GroupDto - studentDto?: StudentDto[] -} - -export interface LessonDto { - id: string - /** - * Dars mavzusi — o'qituvchi/admin kiritadi. Yaratishda va tahrirlashda - * yuboriladigan YAGONA maydon (`LessonCreateDto{groupId, topic}`). - */ - topic?: string - /** - * Tartib raqami ("1.2" kabi) — backend o'zi qo'yadi, biz yubormaymiz. - * Nomi `title` bo'lsa ham, bu sarlavha emas, raqam. - */ - title?: string - /** `LocalDateTime` — "yyyy-MM-ddTHH:mm:ss". */ - lessonDate?: string - isComplete?: boolean - group?: GroupDto - teacherDto?: TeacherDto -} - -/** - * Davomat statuslari. - * - * Backenddan `LATE` o'chirilgan — yangi yozuvlarda faqat uch status - * qoladi, chunki kechikish holati endi alohida ma'lumot emas. - */ -export const ATTENDANCE_STATUSES = ['PRESENT', 'ABSENT', 'EXCUSED'] as const -export type AttendanceStatus = (typeof ATTENDANCE_STATUSES)[number] - -/** O'qituvchi yangi davomatda tanlay oladigan statuslar. */ -export const SELECTABLE_ATTENDANCE_STATUSES = ['PRESENT', 'ABSENT', 'EXCUSED'] as const -export type SelectableAttendanceStatus = (typeof SELECTABLE_ATTENDANCE_STATUSES)[number] - -export interface AttendanceStudentDto { - studentId: string - studentFullName?: string - status: AttendanceStatus - /** Faqat EXCUSED uchun mantiqiy — backend boshqa statuslarda ham qabul qiladi. */ - reason?: string -} - -export interface AttendanceDto { - id?: string - lessonId: string - /** `LocalDateTime`; jadvaldagi ustun sanasi shundan olinadi. */ - createdAt?: string - attendanceStudents?: AttendanceStudentDto[] -} - -/** `attendanceStudentMap` dagi bitta yozuv. */ -export interface StatusReasonDto { - status: AttendanceStatus - reason?: string -} - -/** - * `GET /attendance/monthly/{groupId}` javobi — bitta o'tgan darsning - * davomati. - * - * `attendanceStudentMap` massiv emas, xarita: kalit — `studentId`. Xaritada - * yo'q o'quvchi hali belgilanmagan degani, "kelmadi" EMAS — jadval katagi - * shu farqni bo'sh qoldirib ko'rsatishi kerak. - * - * `id` — bu yozuvning o'zi identifikatori (`PUT /attendance/{id}` shu yerga - * yuboriladi), dars identifikatori EMAS. - */ -export interface MonthlyAttendanceDto { - id: string - lessonTitle?: string - /** `LocalDate`/`LocalDateTime` — jadvaldagi ustun sanasi shundan olinadi. */ - date?: string - attendanceStudentMap?: Record -} - -/** - * `GET /attendance/my/{groupId}` javobidagi bitta yozuv. - * - * `MonthlyAttendanceDto` dan farqli o'laroq — bu allaqachon SO'RAGAN - * o'quvchining o'zi uchun tekislangan ro'yxat, xarita emas. - */ -export interface MyAttendanceDto { - title?: string - /** `LocalDate` — "yyyy-MM-dd". */ - date?: string - status: AttendanceStatus - reason?: string -} - -/** `OrganizationDto` — o'quv markazi (tashkilot) darajasi. */ -export interface OrganizationDto { - id: string - name?: string - email?: string - phone?: string - website?: string -} - -/** - * `BranchDto` — filial. - * - * Diqqat: DTO'da `organization` YO'Q (backendda izohga olingan), shuning - * uchun filial qaysi tashkilotga tegishli ekanini ro'yxatdan bilib - * bo'lmaydi. `email`/`phone` ham entity'da bor, lekin DTO'ga chiqmagan. - * Oylik to'lov (`chargeForMonth`) endi bu yerda yo'q — u `Level`ga ko'chdi. - */ -export interface BranchDto { - id: string - name?: string - address?: string - googlePlaceId?: string - latitude?: number - longitude?: number - googleMapsUrl?: string -} - -/** `PUT /branch/{id}` tanasi — `BranchDto` dan `id` va `organization` siz. */ -export interface BranchUpdatePayload { - name?: string - address?: string - googlePlaceId?: string - latitude?: number - longitude?: number - googleMapsUrl?: string -} - -export const INVOICE_STATUSES = ['PAID', 'PENDING', 'OVERDUE'] as const -export type InvoiceStatus = (typeof INVOICE_STATUSES)[number] - -/** - * `InvoiceDto` — to'lov hisobi. - * - * Diqqat: entity'da maydon `paymentStatus`, DTO'da esa `status`. - * `amount` `BigDecimal` — JSON'da son bo'lib keladi, lekin tiyin/so'm - * aniqligini yo'qotmaslik uchun biz uni HISOBLASHDA ishlatmaymiz, faqat - * ko'rsatamiz. - */ -export interface InvoiceDto { - id: string - invoiceNumber?: string - student?: StudentDto - amount?: number - /** `LocalDateTime` — "yyyy-MM-ddTHH:mm:ss". */ - issuedAt?: string - status?: InvoiceStatus - /** - * To'lov turi: o'quvchi to'ladimi yoki markaz qaytardimi. - * - * Ataylab union EMAS, oddiy `string`: `InvoiceType` enum'i backendning - * merge bo'lmagan branchida va qiymatlari bizga aytilmagan. Taxmin - * qilsak, noto'g'ri qiymat kelganda ekran buziladi. Qiymatlar - * ma'lum bo'lgach union qilinadi (`JwtClaims['role']` bilan bir sabab). - */ - type?: string -} - -export const LEAD_STATUSES = ['NEW', 'ENROLLED', 'REJECTED', 'CALL_LATER'] as const -export type LeadStatus = (typeof LEAD_STATUSES)[number] - -export const REJECTION_REASONS = ['PRICE_TOO_HIGH', 'SCHEDULE_CONFLICT', 'LOCATION_FAR', 'CHOSE_COMPETITOR', 'UNRESPONSIVE', 'NOT_INTERESTED', 'OTHER'] as const -export type RejectionReason = (typeof REJECTION_REASONS)[number] - -export const LEAD_SOURCES = ['INSTAGRAM', 'FACEBOOK', 'TELEGRAM'] as const -export type LeadSource = (typeof LEAD_SOURCES)[number] - -/** - * `LeadDto` — potentsial o'quvchi (lid). - * - * Backend `GroupLevel` enum'ini jadvalga aylantirdi. O'quvchining qiziqishi - * ro'yxatdan kelgan obyekt bo'lib, lekin yaratish/yangilash uchun faqat uning - * `id` yuboriladi. - */ -export interface LeadCourse { - id: string - name: string - orderNumber: number - lessonCount: number - durationInMonths: number -} - -export type LeadRejectReason = RejectionReason - -export interface LeadDto { - id: string - fullName?: string - phone?: string - /** `LocalDateTime` — qo'ng'iroq qilish rejalashtirilgan vaqt. */ - callAt?: string - status?: LeadStatus - source?: LeadSource - preferredCourse?: LeadCourse - createdAt?: string - updatedAt?: string -} - -/** `POST /leads` va tasdiqlangan `PUT /leads/{id}` maydonlari. */ -export interface LeadCreateDto { - fullName: string - phone: string - source?: LeadSource - preferredCourse?: string -} - -export interface LeadUpdateDto { - fullName?: string - phone?: string - status: LeadStatus - source?: LeadSource - preferredCourse?: string - callAt?: string -} - -export interface LeadRejectDto { - reason: LeadRejectReason - note?: string -} - -/** Analytics categories. */ -export type AnalyticsCategory = 'student' | 'teacher' | 'lead' | 'invoice' | 'enrollment' | 'branch' - -export interface StudentAnalyticsDto { - studentCount?: number - studentsAddedInMonth?: number -} - -export interface TeacherAnalyticsDto { - teacherCount?: number - teachersAddedInMonth?: number -} - -export interface LeadAnalyticsDto { - leadCount?: number - leadCountInAMonth?: number -} - -export interface InvoiceAnalyticsDto { - invoiceAmount?: number - invoiceAmountInAMonth?: number -} - -export interface EnrollmentAnalyticsDto { - enrollmentCount?: number - enrollmentCountInAMonth?: number -} - -export interface BranchAnalyticsDto { - branchCount?: number -} - -export type AnalyticsStatDto = - | StudentAnalyticsDto - | TeacherAnalyticsDto - | LeadAnalyticsDto - | InvoiceAnalyticsDto - | EnrollmentAnalyticsDto - | BranchAnalyticsDto + * Bu shakllar alohida modullarga ajratilgan va bu yerda re-eksport qilinadi. + * Maqsadi — mavjud importlar buzilmasligi (`import { ... } from '@/shared/types'`). + */ + +export * from './common' +export * from './user' +export * from './student' +export * from './teacher' +export * from './group' +export * from './attendance' +export * from './organization' +export * from './invoice' +export * from './lead' +export * from './analytics' diff --git a/src/shared/types/invoice.ts b/src/shared/types/invoice.ts new file mode 100644 index 0000000..cde47cd --- /dev/null +++ b/src/shared/types/invoice.ts @@ -0,0 +1,31 @@ +import type { StudentDto } from './student' + +export const INVOICE_STATUSES = ['PAID', 'PENDING', 'OVERDUE'] as const +export type InvoiceStatus = (typeof INVOICE_STATUSES)[number] + +/** + * `InvoiceDto` — to'lov hisobi. + * + * Diqqat: entity'da maydon `paymentStatus`, DTO'da esa `status`. + * `amount` `BigDecimal` — JSON'da son bo'lib keladi, lekin tiyin/so'm + * aniqligini yo'qotmaslik uchun biz uni HISOBLASHDA ishlatmaymiz, faqat + * ko'rsatamiz. + */ +export interface InvoiceDto { + id: string + invoiceNumber?: string + student?: StudentDto + amount?: number + /** `LocalDateTime` — "yyyy-MM-ddTHH:mm:ss". */ + issuedAt?: string + status?: InvoiceStatus + /** + * To'lov turi: o'quvchi to'ladimi yoki markaz qaytardimi. + * + * Ataylab union EMAS, oddiy `string`: `InvoiceType` enum'i backendning + * merge bo'lmagan branchida va qiymatlari bizga aytilmagan. Taxmin + * qilsak, noto'g'ri qiymat kelganda ekran buziladi. Qiymatlar + * ma'lum bo'lgach union qilinadi (`JwtClaims['role']` bilan bir sabab). + */ + type?: string +} diff --git a/src/shared/types/lead.ts b/src/shared/types/lead.ts new file mode 100644 index 0000000..322a72a --- /dev/null +++ b/src/shared/types/lead.ts @@ -0,0 +1,60 @@ +export const LEAD_STATUSES = ['NEW', 'ENROLLED', 'REJECTED', 'CALL_LATER'] as const +export type LeadStatus = (typeof LEAD_STATUSES)[number] + +export const REJECTION_REASONS = ['PRICE_TOO_HIGH', 'SCHEDULE_CONFLICT', 'LOCATION_FAR', 'CHOSE_COMPETITOR', 'UNRESPONSIVE', 'NOT_INTERESTED', 'OTHER'] as const +export type RejectionReason = (typeof REJECTION_REASONS)[number] + +export const LEAD_SOURCES = ['INSTAGRAM', 'FACEBOOK', 'TELEGRAM'] as const +export type LeadSource = (typeof LEAD_SOURCES)[number] + +/** + * `LeadDto` — potentsial o'quvchi (lid). + * + * Backend `GroupLevel` enum'ini jadvalga aylantirdi. O'quvchining qiziqishi + * ro'yxatdan kelgan obyekt bo'lib, lekin yaratish/yangilash uchun faqat uning + * `id` yuboriladi. + */ +export interface LeadCourse { + id: string + name: string + orderNumber: number + lessonCount: number + durationInMonths: number +} + +export type LeadRejectReason = RejectionReason + +export interface LeadDto { + id: string + fullName?: string + phone?: string + /** `LocalDateTime` — qo'ng'iroq qilish rejalashtirilgan vaqt. */ + callAt?: string + status?: LeadStatus + source?: LeadSource + preferredCourse?: LeadCourse + createdAt?: string + updatedAt?: string +} + +/** `POST /leads` va tasdiqlangan `PUT /leads/{id}` maydonlari. */ +export interface LeadCreateDto { + fullName: string + phone: string + source?: LeadSource + preferredCourse?: string +} + +export interface LeadUpdateDto { + fullName?: string + phone?: string + status: LeadStatus + source?: LeadSource + preferredCourse?: string + callAt?: string +} + +export interface LeadRejectDto { + reason: LeadRejectReason + note?: string +} diff --git a/src/shared/types/organization.ts b/src/shared/types/organization.ts new file mode 100644 index 0000000..48863dd --- /dev/null +++ b/src/shared/types/organization.ts @@ -0,0 +1,36 @@ +/** `OrganizationDto` — o'quv markazi (tashkilot) darajasi. */ +export interface OrganizationDto { + id: string + name?: string + email?: string + phone?: string + website?: string +} + +/** + * `BranchDto` — filial. + * + * Diqqat: DTO'da `organization` YO'Q (backendda izohga olingan), shuning + * uchun filial qaysi tashkilotga tegishli ekanini ro'yxatdan bilib + * bo'lmaydi. `email`/`phone` ham entity'da bor, lekin DTO'ga chiqmagan. + * Oylik to'lov (`chargeForMonth`) endi bu yerda yo'q — u `Level`ga ko'chdi. + */ +export interface BranchDto { + id: string + name?: string + address?: string + googlePlaceId?: string + latitude?: number + longitude?: number + googleMapsUrl?: string +} + +/** `PUT /branch/{id}` tanasi — `BranchDto` dan `id` va `organization` siz. */ +export interface BranchUpdatePayload { + name?: string + address?: string + googlePlaceId?: string + latitude?: number + longitude?: number + googleMapsUrl?: string +} diff --git a/src/shared/types/student.ts b/src/shared/types/student.ts new file mode 100644 index 0000000..d40b29b --- /dev/null +++ b/src/shared/types/student.ts @@ -0,0 +1,21 @@ +import type { UserDto } from './user' + +/** + * O'quvchining to'lov holati bitta guruh bo'yicha (`Enrollment.status`). + * + * `PARTIAL` — qisman to'langan; ya'ni "to'lamagan" ham, "to'lagan" ham emas. + */ +export type EnrollmentPaymentStatus = 'UNPAID' | 'PARTIAL' | 'PAID' + +export interface StudentDto { + id: string + userDto?: UserDto + parentPhone?: string + /** + * Balans va to'lov holati faqat `GET /student/me?groupId=…` javobida + * keladi va BITTA GURUHGA tegishli — boshqa endpointlarda bo'sh bo'ladi. + * Manfiy son — qarz (`paidAmount - monthlyFee`). + */ + balance?: number + status?: EnrollmentPaymentStatus +} diff --git a/src/shared/types/teacher.ts b/src/shared/types/teacher.ts new file mode 100644 index 0000000..c5c4cf7 --- /dev/null +++ b/src/shared/types/teacher.ts @@ -0,0 +1,6 @@ +import type { UserDto } from './user' + +export interface TeacherDto { + id: string + userDto?: UserDto +} diff --git a/src/shared/types/user.ts b/src/shared/types/user.ts new file mode 100644 index 0000000..7b70f2d --- /dev/null +++ b/src/shared/types/user.ts @@ -0,0 +1,28 @@ +import type { Role } from './common' + +/** `UserDto` — diqqat: maydon nomi `imageUrl` (`imgUrl` emas). */ +export interface UserDto { + id?: string + /** Foydalanuvchi biriktirilgan filial — sozlamalardagi markaz bloki shuni yuklaydi. */ + branchId?: string + imageUrl?: string + fullName?: string + phone?: string + /** `LocalDate` — "yyyy-MM-dd". */ + birthDate?: string + role?: Role +} + +/** `PUT /user/{id}` uchun. */ +export interface UserUpdatePayload { + fullName: string + phone: string + birthDate: string +} + +/** `POST /auth/change-password` uchun. */ +export interface ChangePasswordPayload { + oldPassword: string + newPassword: string + confirmPassword: string +} From 35c86ff4bebac7ab53cb1d171a383ddd1c4b5718 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:04:36 +0000 Subject: [PATCH 2/2] refactor(admin): move top action buttons to sidebar Co-authored-by: Diyor-Khasanov-dev <317548684+Diyor-Khasanov-dev@users.noreply.github.com> --- .../admin/components/AdminSidebar.tsx | 30 ++++++++++++++----- .../admin/pages/AdminDashboardPage.test.tsx | 4 +-- .../admin/pages/AdminDashboardPage.tsx | 9 +++++- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/features/admin/components/AdminSidebar.tsx b/src/features/admin/components/AdminSidebar.tsx index dca64de..7d7adc6 100644 --- a/src/features/admin/components/AdminSidebar.tsx +++ b/src/features/admin/components/AdminSidebar.tsx @@ -3,12 +3,6 @@ import type { TranslationKey } from '@/shared/i18n' import { cn } from '@/shared/lib' import type { EntityConfig, EntityKey } from '../types' -interface AdminNavProps { - entities: EntityConfig[] - activeTab: EntityKey - onTabChange: (tab: EntityKey) => void -} - /** Sidebarning pastki guruhidagi havola — tab emas, marshrutga o'tadi. */ export interface AdminSidebarLink { key: string @@ -16,6 +10,13 @@ export interface AdminSidebarLink { onClick: () => void } +interface AdminNavProps { + entities: EntityConfig[] + activeTab: EntityKey + onTabChange: (tab: EntityKey) => void + links?: AdminSidebarLink[] +} + interface AdminSidebarProps extends AdminNavProps { links: AdminSidebarLink[] } @@ -83,7 +84,7 @@ export function AdminSidebar({ entities, activeTab, onTabChange, links }: AdminS } /** Mobil variant — sarlavha ostidagi tasma. */ -export function AdminTabStrip({ entities, activeTab, onTabChange }: AdminNavProps) { +export function AdminTabStrip({ entities, activeTab, onTabChange, links }: AdminNavProps) { const { t } = useT() return ( @@ -103,6 +104,21 @@ export function AdminTabStrip({ entities, activeTab, onTabChange }: AdminNavProp {t(entity.pluralKey)} ))} + {links && links.length > 0 && ( + <> +
+ {links.map((link) => ( + + ))} + + )}
) } diff --git a/src/features/admin/pages/AdminDashboardPage.test.tsx b/src/features/admin/pages/AdminDashboardPage.test.tsx index 9a7e0bc..1531752 100644 --- a/src/features/admin/pages/AdminDashboardPage.test.tsx +++ b/src/features/admin/pages/AdminDashboardPage.test.tsx @@ -53,7 +53,7 @@ describe('AdminDashboardPage — ruxsatlar', () => { renderWithProviders() expect(screen.queryByRole('button', { name: /lidlar/i })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: /to.lovlar/i })).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: /to.lovlar/i }).length).toBeGreaterThan(0) }) it('LEAD_MANAGEMENT bor administratorga "Lidlar" tugmasi ko‘rinadi', () => { @@ -62,7 +62,7 @@ describe('AdminDashboardPage — ruxsatlar', () => { renderWithProviders() - expect(screen.getByRole('button', { name: /lidlar/i })).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: /lidlar/i }).length).toBeGreaterThan(0) }) it('STUDENT_MANAGEMENT yo‘q administratorga O‘quvchilar tabi ko‘rinmaydi', () => { diff --git a/src/features/admin/pages/AdminDashboardPage.tsx b/src/features/admin/pages/AdminDashboardPage.tsx index d45b5bc..8ac1c2a 100644 --- a/src/features/admin/pages/AdminDashboardPage.tsx +++ b/src/features/admin/pages/AdminDashboardPage.tsx @@ -140,7 +140,14 @@ export function AdminDashboardPage() { token={session.token} theme={theme} toggleTheme={toggleTheme} - secondary={} + secondary={ + + } >