From 6fef3a9e74f518a400b6119b3f7db44d941cce1c Mon Sep 17 00:00:00 2001 From: Greem4 Date: Fri, 21 Aug 2026 16:53:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D0=B4=D0=BD=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BB=D0=B8=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 34 +- .../neiro/ui/calendar/DaySummaryStats.kt | 166 +++++- .../neiro/ui/components/CalendarDialogs.kt | 38 +- .../neiro/ui/components/DayStatsDialogs.kt | 531 ++++++++++++++++++ .../neiro/ui/components/DaySummaryCard.kt | 63 ++- .../neiro/ui/screens/CalendarScreen.kt | 115 +++- .../neiro/ui/calendar/DaySummaryStatsTest.kt | 184 ++++++ 7 files changed, 1076 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/ru/greemlab/neiro/ui/components/DayStatsDialogs.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b95e4cd8..03bb6a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,37 @@ ## [Не выпущено] -Пока пусто. Сюда дописывается изменение, заметное пользователю, — тем же PR, -которым оно вносится, а не по памяти в день выпуска. Заголовки внутри: -«Добавлено», «Изменено», «Исправлено», «Убрано». +### Добавлено + +- **Плитки дня открываются по нажатию** — «занятий», «проведено», «Заработано» + и «Ожидается» под календарём теперь нажимаются, и каждая показывает, из чего + сложилась цифра: сколько было обычных занятий, сколько детей на интенсиве, + сколько диагностик, сколько денег принёс каждый вид. Строка появляется + только про то, что в этом дне правда было. +- **Разбор дня считает и отменённое** — сверху в каждом разборе стоит общая + цифра: сколько занятий должно было быть и сколько денег день мог принести, + если бы никто не отменился. Ниже — что из этого уже прошло, что ещё + ожидается и сколько потеряно на отменах. Плитки на экране по-прежнему + показывают только живые записи. Деньги интенсива в суммы входят, а вот + занятием он не считается нигде. + +### Исправлено + +- **Пустая строка «Диагностик 0»** — в разборе занятий за месяц строка стояла + и в месяцах, где диагностик не было, и повторяла цифру «Проведено». Теперь + разбивка появляется, только когда диагностики за месяц были. +- **Обрезанные подписи в дне с интенсивом** — в такой день под календарём + вставала третья плитка, и подписи ужимались до «заня…», «пров…», «инте…». + Теперь плиток всегда четыре, по две в ряду, а интенсив виден в разборе, + который открывается нажатием. + +### Изменено + +- **Интенсив больше не занимает место в ряду плиток** — раньше в день с + интенсивом под календарём вставала третья плитка. Теперь интенсив живёт в + разборе: нажатие на «занятий» или «проведено» покажет, сколько интенсивов + было и сколько детей на них пришло. В счётчике занятий интенсив по-прежнему + не участвует — день с одним интенсивом показывает «занятий 0». ## [0.2.2] — 2026-08-21 diff --git a/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt b/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt index e2f51c8a..8eb77c8d 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt @@ -3,6 +3,41 @@ package ru.greemlab.neiro.ui.calendar import androidx.compose.runtime.Immutable import ru.greemlab.neiro.domain.models.EarningsContext +/** + * Разбивка дня по одному виду записей: занятия, интенсивы, диагностики. + * + * У интенсива счётчики считают детей, а не сами интенсивы: на плашке дня и в + * её диалогах человек считает головы, а не строки расписания. Сколько было + * самих интенсивов — отдельно, в [DaySummaryStats.intensiveCount]. + */ +@Immutable +data class DayKindStats( + /** Запланировано за день без отменённых. */ + val planned: Int = 0, + /** Пришли — то, что уже в заработке. */ + val attended: Int = 0, + /** Подтвердились, но ещё не пришли. */ + val confirmed: Int = 0, + /** Ждут подтверждения. */ + val pending: Int = 0, + /** Отменённые записи — их не будет, но день их планировал. */ + val cancelled: Int = 0, + val earned: Double = 0.0, + val expected: Double = 0.0, + /** Деньги, которые не придут из-за отмен. */ + val cancelledAmount: Double = 0.0, +) { + /** Вида в дне не было — строку в диалоге не показываем. */ + val isEmpty: Boolean + get() = planned == 0 && cancelled == 0 && earned == 0.0 && expected == 0.0 + + /** Сколько записей этого вида должно было быть — вместе с отменёнными. */ + val scheduled: Int get() = planned + cancelled + + /** Сколько вид мог принести, если бы никто не отменил. */ + val potential: Double get() = earned + expected + cancelledAmount +} + /** Сводка по одному дню для панели под календарём. */ @Immutable data class DaySummaryStats( @@ -16,7 +51,63 @@ data class DaySummaryStats( val earned: Double = 0.0, val expected: Double = 0.0, val lost: Double = 0.0, -) + /** Только ученики: без интенсивов и диагностик. */ + val lessons: DayKindStats = DayKindStats(), + /** Дети интенсивов за день. */ + val intensives: DayKindStats = DayKindStats(), + val diagnostics: DayKindStats = DayKindStats(), + /** Сколько интенсивов в дне — детей в них считает [intensives]. */ + val intensiveCount: Int = 0, + /** + * Заработок дня пришёл фактом из YClients, а не посчитан по ставкам. + * Тогда разбивка по видам — оценка, и диалог обязан это сказать. + */ + val earnedFromFact: Boolean = false, +) { + /** + * Занятие — это ученик или диагностика. Интенсив занятием не считается + * ни в одной цифре: у него своя запись в дне и свои дети, и день с одним + * интенсивом честно показывает «занятий 0». Разбор по нажатию покажет, + * что в этом дне интенсив всё-таки был. + * + * Счётчики живых занятий уже лежат готовыми: [totalLessons], + * [attendedLessons], [confirmedLessons], [pendingLessons]. + */ + val cancelledLessons: Int get() = lessons.cancelled + diagnostics.cancelled + + /** Сколько занятий должно было быть — вместе с отменёнными. */ + val scheduledLessons: Int get() = totalLessons + cancelledLessons + + /** Деньги, которые не придут из-за отмен — здесь интенсив считается. */ + val cancelledAmount: Double + get() = lessons.cancelledAmount + intensives.cancelledAmount + diagnostics.cancelledAmount + + /** Потолок дня: заработанное, ожидаемое и потерянное на отменах вместе. */ + val potentialEarned: Double get() = earned + expected + cancelledAmount +} + +/** Накопитель разбивки: считает один вид записей за день. */ +private class DayKindAccumulator { + var planned = 0 + var attended = 0 + var confirmed = 0 + var pending = 0 + var cancelled = 0 + var earned = 0.0 + var expected = 0.0 + var cancelledAmount = 0.0 + + fun build(): DayKindStats = DayKindStats( + planned = planned, + attended = attended, + confirmed = confirmed, + pending = pending, + cancelled = cancelled, + earned = earned, + expected = expected, + cancelledAmount = cancelledAmount, + ) +} /** * Сводка дня. Если за этот день есть факт из YClients ([dayFact]), заработок @@ -36,11 +127,16 @@ internal fun computeDayStats( var confirmedIntensiveChildren = 0 var pendingIntensiveChildren = 0 var hasIntensive = false + var intensiveCount = 0 var earned = 0.0 var expected = 0.0 var lost = 0.0 var manualIntensiveEarned = 0.0 + val lessonsAcc = DayKindAccumulator() + val intensivesAcc = DayKindAccumulator() + val diagnosticsAcc = DayKindAccumulator() + val parsed = sessions.map(SessionParser::parse) val intensiveChildrenByTime = buildIntensiveChildrenByTime(parsed) @@ -54,12 +150,34 @@ internal fun computeDayStats( is Session.Student -> rates.pricePerSession } lost += price + when (session) { + is Session.Intensive -> { + intensivesAcc.cancelled += if (session.children.isEmpty()) { + 1 + } else { + session.children.size + } + intensivesAcc.cancelledAmount += price + } + + is Session.Diagnostics -> { + diagnosticsAcc.cancelled++ + diagnosticsAcc.cancelledAmount += price + } + + is Session.Student -> { + lessonsAcc.cancelled++ + lessonsAcc.cancelledAmount += price + } + } continue } when (session) { is Session.Intensive -> { hasIntensive = true + intensiveCount++ + val arrived = session.arrivedChildCount() confirmedIntensiveChildren += session.confirmedChildCount() pendingIntensiveChildren += session.pendingChildCount() val actual = session.totalAmount(rates.pricePerIntensiveChild, onlyArrived = true) @@ -67,20 +185,55 @@ internal fun computeDayStats( earned += actual if (session.amountFixed) manualIntensiveEarned += actual expected += (planned - actual).coerceAtLeast(0.0) + + // Интенсив без детей — одна запись, иначе считаем головы: + // счётчики детей [confirmedChildCount] и [pendingChildCount] + // ведут себя так же. + intensivesAcc.planned += if (session.children.isEmpty()) { + 1 + } else { + session.children.visibleChildren().size + } + intensivesAcc.attended += arrived + // confirmedChildCount() включает пришедших — в разбивке + // «подтвердились» это те, кто ещё впереди. + intensivesAcc.confirmed += (session.confirmedChildCount() - arrived).coerceAtLeast(0) + intensivesAcc.pending += session.pendingChildCount() + intensivesAcc.earned += actual + intensivesAcc.expected += (planned - actual).coerceAtLeast(0.0) + + // Отменённый ребёнок внутри живого интенсива: в деньгах его уже + // нет — ни в заработке, ни в ожидании, — но день его планировал. + // При сумме, заданной руками, отмена ребёнка её не меняет, и + // терять там нечего. + val cancelledChildren = session.children.count { it.isEffectivelyDeleted() } + if (cancelledChildren > 0) { + intensivesAcc.cancelled += cancelledChildren + if (!session.amountFixed) { + intensivesAcc.cancelledAmount += + session.unitPrice(rates.pricePerIntensiveChild) * cancelledChildren + } + } } is Session.Diagnostics -> { totalLessons++ + diagnosticsAcc.planned++ val price = if (rates.pricePerDiagnostics > 0.0) rates.pricePerDiagnostics else session.amount if (session.countsTowardEarnings()) { attendedLessons++ earned += price + diagnosticsAcc.attended++ + diagnosticsAcc.earned += price } else { expected += price + diagnosticsAcc.expected += price if (session.status == AttendanceStatus.CONFIRMED) { confirmedLessons++ + diagnosticsAcc.confirmed++ } else if (session.status == AttendanceStatus.EXPECTED) { pendingLessons++ + diagnosticsAcc.pending++ } } } @@ -88,16 +241,22 @@ internal fun computeDayStats( is Session.Student -> { if (isStudentCoveredByIntensive(session, intensiveChildrenByTime)) continue totalLessons++ + lessonsAcc.planned++ val pay = rates.pricePerSession if (session.countsTowardEarnings()) { attendedLessons++ earned += pay + lessonsAcc.attended++ + lessonsAcc.earned += pay } else { expected += pay + lessonsAcc.expected += pay if (session.status == AttendanceStatus.CONFIRMED) { confirmedLessons++ + lessonsAcc.confirmed++ } else if (session.status == AttendanceStatus.EXPECTED) { pendingLessons++ + lessonsAcc.pending++ } } } @@ -115,5 +274,10 @@ internal fun computeDayStats( earned = if (dayFact != null) dayFact + manualIntensiveEarned else earned, expected = expected, lost = lost, + lessons = lessonsAcc.build(), + intensives = intensivesAcc.build(), + diagnostics = diagnosticsAcc.build(), + intensiveCount = intensiveCount, + earnedFromFact = dayFact != null, ) } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt index 42a3da59..06f972db 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/CalendarDialogs.kt @@ -88,7 +88,7 @@ private val StatsDialogModifier: Modifier */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun StatsDialogScaffold( +internal fun StatsDialogScaffold( title: String, onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit, @@ -193,22 +193,26 @@ fun LessonsDetailsDialog( iconTint = neiroSemanticColors.scheduleHeader, ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - LessonStatRow( - label = "Занятий", - value = stats.completedSessionsCount, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - LessonStatRow( - label = "Диагностик", - value = stats.completedDiagnosticsCount, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // Без диагностик разбивка слово в слово повторяет «Проведено» — + // показываем её, только когда есть что разделять. + if (stats.completedDiagnosticsCount > 0) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + LessonStatRow( + label = "Занятий", + value = stats.completedSessionsCount, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + LessonStatRow( + label = "Диагностик", + value = stats.completedDiagnosticsCount, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/DayStatsDialogs.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/DayStatsDialogs.kt new file mode 100644 index 00000000..db2e7447 --- /dev/null +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/DayStatsDialogs.kt @@ -0,0 +1,531 @@ +package ru.greemlab.neiro.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.EventBusy +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.Payments +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Summarize +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ru.greemlab.neiro.theme.glassDividerColor +import ru.greemlab.neiro.theme.neiroSemanticColors +import ru.greemlab.neiro.ui.calendar.DayKindStats +import ru.greemlab.neiro.ui.calendar.DaySummaryStats +import ru.greemlab.neiro.ui.util.formatDayMonth +import java.time.LocalDate + +/** + * Диалоги плиток дня: «занятий», «проведено», «Заработано», «Ожидается». + * + * Плитка показывает, что в дне есть сейчас, а разбор — здесь. Порядок разбора + * один на все четыре: сверху общая цифра за весь день, под ней конкретика по + * видам — занятия, интенсивы, диагностики. Общая цифра считает и отменённое: + * разбор отвечает на вопрос «сколько должно было быть», а не «сколько + * осталось» — это плитка и так говорит. + * + * Строка вида появляется, только если вид в дне был: день без диагностик не + * должен показывать «Диагностик 0». + * + * Занятие — это ученик или диагностика. Интенсив занятием не считается и в + * счётчики занятий не входит: у него свой блок в разборе. Поэтому день с + * одним интенсивом показывает «занятий 0», и это не ошибка. + */ + +/** Отступ вложенных строк — тот же, что в разборе занятий за месяц. */ +private val NestedRowIndent = 12.dp + +@Composable +private fun BreakdownColumn(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = NestedRowIndent), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + content() + } +} + +/** Пояснение под цифрами: почему сумма именно такая. */ +@Composable +private fun DialogNote(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** Разбор занятий за день: сколько должно было быть и что из этого прошло. */ +@Composable +fun DayLessonsDialog( + date: LocalDate, + stats: DaySummaryStats, + onDismiss: () -> Unit, +) { + val title = remember(date) { "Занятия за ${formatDayMonth(date)}" } + val semanticColors = neiroSemanticColors + + StatsDialogScaffold(title = title, onDismiss = onDismiss) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LessonStatRow( + label = "Всего занятий", + value = stats.scheduledLessons, + color = MaterialTheme.colorScheme.onSurface, + isBold = true, + icon = Icons.Rounded.Event, + iconTint = MaterialTheme.colorScheme.primary, + ) + + // Разбивка нужна, только когда диагностики есть: иначе она слово + // в слово повторяет строку выше. + if (!stats.diagnostics.isEmpty) { + BreakdownColumn { + LessonStatRow( + label = "Занятий", + value = stats.lessons.scheduled, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LessonStatRow( + label = "Диагностик", + value = stats.diagnostics.scheduled, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + HorizontalDivider(color = glassDividerColor()) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LessonStatRow( + label = "Проведено", + value = stats.attendedLessons, + color = semanticColors.scheduleHeader, + isBold = true, + icon = Icons.Rounded.CheckCircle, + iconTint = semanticColors.scheduleHeader, + ) + BreakdownColumn { + if (stats.confirmedLessons > 0) { + LessonStatRow( + label = "Подтверждено", + value = stats.confirmedLessons, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (stats.pendingLessons > 0) { + LessonStatRow( + label = "Ждут подтверждения", + value = stats.pendingLessons, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (stats.cancelledLessons > 0) { + LessonStatRow( + label = "Отменено", + value = stats.cancelledLessons, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + + if (stats.intensiveCount > 0) { + HorizontalDivider(color = glassDividerColor()) + IntensiveBlock(stats) + } + + if (stats.scheduledLessons == 0 && stats.intensiveCount == 0) { + DialogNote("На этот день ничего не записано.") + } + } +} + +/** + * Интенсив в разборе дня — своим блоком. + * + * Занятием он не считается, поэтому и не смешивается со счётчиками выше: + * день может показывать «занятий 0» и при этом честно рассказать здесь, что + * интенсив был и сколько детей на него пришло. + */ +@Composable +private fun IntensiveBlock(stats: DaySummaryStats) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LessonStatRow( + label = "Интенсивов", + value = stats.intensiveCount, + color = MaterialTheme.colorScheme.onSurface, + isBold = true, + icon = Icons.Rounded.Groups, + iconTint = MaterialTheme.colorScheme.tertiary, + ) + BreakdownColumn { + LessonStatRow( + label = "Детей всего", + value = stats.intensives.scheduled, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (stats.intensives.attended > 0) { + LessonStatRow( + label = "Пришли", + value = stats.intensives.attended, + color = neiroSemanticColors.scheduleHeader, + ) + } + if (stats.intensives.confirmed > 0) { + LessonStatRow( + label = "Подтвердились", + value = stats.intensives.confirmed, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (stats.intensives.pending > 0) { + LessonStatRow( + label = "Ждут подтверждения", + value = stats.intensives.pending, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (stats.intensives.cancelled > 0) { + LessonStatRow( + label = "Отменено", + value = stats.intensives.cancelled, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +/** Разбор проведённого за день: что уже прошло, а что впереди. */ +@Composable +fun DayConductedDialog( + date: LocalDate, + stats: DaySummaryStats, + onDismiss: () -> Unit, +) { + val title = remember(date) { "Проведено ${formatDayMonth(date)}" } + val semanticColors = neiroSemanticColors + val remaining = (stats.totalLessons - stats.attendedLessons).coerceAtLeast(0) + + StatsDialogScaffold(title = title, onDismiss = onDismiss) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LessonStatRow( + label = "Всего занятий", + value = stats.scheduledLessons, + color = MaterialTheme.colorScheme.onSurface, + isBold = true, + icon = Icons.Rounded.Event, + iconTint = MaterialTheme.colorScheme.primary, + ) + LessonStatRow( + label = "Проведено", + value = stats.attendedLessons, + color = semanticColors.scheduleHeader, + isBold = true, + icon = Icons.Rounded.CheckCircle, + iconTint = semanticColors.scheduleHeader, + ) + if (!stats.diagnostics.isEmpty) { + BreakdownColumn { + KindProgressRow("Занятия", stats.lessons) + KindProgressRow("Диагностики", stats.diagnostics) + } + } + } + + HorizontalDivider(color = glassDividerColor()) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LessonStatRow( + label = "Осталось", + value = remaining, + color = if (remaining > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + icon = Icons.Rounded.Schedule, + iconTint = semanticColors.expected, + ) + BreakdownColumn { + if (stats.pendingLessons > 0) { + LessonStatRow( + label = "Из них ждут подтверждения", + value = stats.pendingLessons, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (stats.cancelledLessons > 0) { + LessonStatRow( + label = "Отменено", + value = stats.cancelledLessons, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + + if (stats.intensiveCount > 0) { + HorizontalDivider(color = glassDividerColor()) + IntensiveBlock(stats) + } + + if (stats.totalLessons > 0 && remaining == 0) { + DialogNote("День закрыт — всё запланированное проведено.") + } + } +} + +/** «Проведено X из N» одной строкой — вид дня в разборе. */ +@Composable +private fun KindProgressRow(label: String, kind: DayKindStats) { + StatRow( + label = label, + value = "${kind.attended} из ${kind.scheduled}", + isHighlight = kind.scheduled > 0 && kind.attended == kind.scheduled, + ) +} + +/** Разбор заработанного за день: потолок дня, а под ним — что уже пришло. */ +@Composable +fun DayEarnedDialog( + date: LocalDate, + stats: DaySummaryStats, + onDismiss: () -> Unit, +) { + val title = remember(date) { "Заработано ${formatDayMonth(date)}" } + val semanticColors = neiroSemanticColors + val breakdownSum = remember(stats) { + stats.lessons.earned + stats.intensives.earned + stats.diagnostics.earned + } + // Факт из YClients приходит одной суммой за день. Разложить его по видам + // нечем — разбивка ниже считается по ставкам профиля, и при расхождении + // об этом надо сказать прямо, а не подгонять цифры друг под друга. + val factDiffers = stats.earnedFromFact && kotlin.math.abs(breakdownSum - stats.earned) >= 1.0 + + StatsDialogScaffold(title = title, onDismiss = onDismiss) { + ProfitRow( + label = "Можно было заработать", + value = stats.potentialEarned, + color = MaterialTheme.colorScheme.onSurface, + isBold = true, + icon = Icons.Rounded.Summarize, + iconTint = MaterialTheme.colorScheme.primary, + ) + + HorizontalDivider(color = glassDividerColor()) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ProfitRow( + label = "Заработано", + value = stats.earned, + color = semanticColors.scheduleHeader, + isBold = true, + icon = Icons.Rounded.Payments, + iconTint = semanticColors.scheduleHeader, + ) + BreakdownColumn { + if (stats.lessons.earned > 0.0) { + ProfitRow( + label = "Занятия", + value = stats.lessons.earned, + color = MaterialTheme.colorScheme.onSurfaceVariant, + compact = true, + ) + } + if (stats.intensives.earned > 0.0) { + ProfitRow( + label = "Интенсив", + value = stats.intensives.earned, + color = MaterialTheme.colorScheme.onSurfaceVariant, + compact = true, + ) + } + if (stats.diagnostics.earned > 0.0) { + ProfitRow( + label = "Диагностика", + value = stats.diagnostics.earned, + color = MaterialTheme.colorScheme.onSurfaceVariant, + compact = true, + ) + } + } + } + + if (stats.expected > 0.0 || stats.cancelledAmount > 0.0) { + HorizontalDivider(color = glassDividerColor()) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (stats.expected > 0.0) { + ProfitRow( + label = "Ещё ожидается", + value = stats.expected, + color = semanticColors.expected, + icon = Icons.Rounded.Schedule, + iconTint = semanticColors.expected, + ) + } + if (stats.cancelledAmount > 0.0) { + ProfitRow( + label = "Потеряно на отменах", + value = stats.cancelledAmount, + color = MaterialTheme.colorScheme.error, + prefix = "−", + icon = Icons.Rounded.EventBusy, + iconTint = MaterialTheme.colorScheme.error, + ) + } + } + } + + when { + factDiffers -> DialogNote( + "Сумма дня пришла из YClients, а разбивка посчитана по ставкам " + + "профиля — поэтому строки могут не сойтись с итогом.", + ) + + stats.earnedFromFact -> DialogNote("Сумма дня пришла из YClients.") + + stats.earned <= 0.0 -> DialogNote("За этот день пока ничего не начислено.") + } + } +} + +/** Разбор ожидаемого за день: что ещё должно прийти и что уже не придёт. */ +@Composable +fun DayExpectedDialog( + date: LocalDate, + stats: DaySummaryStats, + onDismiss: () -> Unit, +) { + val title = remember(date) { "Ожидается ${formatDayMonth(date)}" } + val semanticColors = neiroSemanticColors + + StatsDialogScaffold(title = title, onDismiss = onDismiss) { + ProfitRow( + label = "Можно было заработать", + value = stats.potentialEarned, + color = MaterialTheme.colorScheme.onSurface, + isBold = true, + icon = Icons.Rounded.Summarize, + iconTint = MaterialTheme.colorScheme.primary, + ) + + HorizontalDivider(color = glassDividerColor()) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ProfitRow( + label = "Ожидается", + value = stats.expected, + color = if (stats.expected > 0.0) { + semanticColors.expected + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + isBold = true, + icon = Icons.Rounded.Schedule, + iconTint = semanticColors.expected, + ) + BreakdownColumn { + if (stats.lessons.expected > 0.0) { + ProfitRow( + label = "Занятия", + value = stats.lessons.expected, + color = MaterialTheme.colorScheme.onSurfaceVariant, + compact = true, + ) + } + if (stats.intensives.expected > 0.0) { + ProfitRow( + label = "Интенсив", + value = stats.intensives.expected, + color = MaterialTheme.colorScheme.onSurfaceVariant, + compact = true, + ) + } + if (stats.diagnostics.expected > 0.0) { + ProfitRow( + label = "Диагностика", + value = stats.diagnostics.expected, + color = MaterialTheme.colorScheme.onSurfaceVariant, + compact = true, + ) + } + } + } + + HorizontalDivider(color = glassDividerColor()) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ProfitRow( + label = "Уже заработано", + value = stats.earned, + color = semanticColors.scheduleHeader, + icon = Icons.Rounded.Payments, + iconTint = semanticColors.scheduleHeader, + ) + // Отменённое не попадает ни в заработок, ни в ожидание — иначе день + // всё утро висел бы с суммой, которая уже никогда не придёт. + if (stats.cancelledAmount > 0.0) { + ProfitRow( + label = "Потеряно на отменах", + value = stats.cancelledAmount, + color = MaterialTheme.colorScheme.error, + prefix = "−", + icon = Icons.Rounded.EventBusy, + iconTint = MaterialTheme.colorScheme.error, + ) + } + } + + if (stats.expected <= 0.0) { + DialogNote( + if (stats.scheduledLessons > 0 || stats.intensiveCount > 0) { + "Ждать больше нечего — всё запланированное уже проведено." + } else { + "На этот день ничего не записано." + }, + ) + } + } +} diff --git a/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt b/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt index b761a823..4c0d7950 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/components/DaySummaryCard.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Payments import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.School @@ -37,7 +36,6 @@ import ru.greemlab.neiro.theme.NeiroSurfaceAlpha import ru.greemlab.neiro.theme.glassBorder import ru.greemlab.neiro.theme.neiroSemanticColors import ru.greemlab.neiro.ui.calendar.DaySummaryStats -import ru.greemlab.neiro.ui.calendar.formatIntensiveConductedLabel import ru.greemlab.neiro.ui.util.RU_LOCALE import ru.greemlab.neiro.ui.util.formatDayMonth import ru.greemlab.neiro.ui.util.formatRubles @@ -73,6 +71,12 @@ private val DaySummaryHeaderRowHeight: Dp = 26.dp * ([NeiroSurfaceAlpha]): день стоит на ступень выше панели вкладок, плитки — * ещё на ступень выше дня. Раньше плашка бралась от `background` и на амоледе * проваливалась в чёрный. + * + * Плиток всегда четыре, по две в ряду. Интенсив когда-то вставал третьей + * плиткой в верхний ряд, и подписи в нём ужимались до «заня…», «пров…», + * «инте…»: чип с иконкой съедает половину узкой плитки. Теперь интенсив + * живёт внутри разбора, который открывается нажатием на плитку. В счётчик + * занятий он не входит — интенсив не занятие. */ /** Заливка чипа под иконкой — та же доля акцента, что у плиток шапки месяца. */ private const val DayChipAlpha = 0.14f @@ -92,10 +96,18 @@ fun DaySummarySlot( date: LocalDate, stats: DaySummaryStats, modifier: Modifier = Modifier, + onLessonsClick: () -> Unit = {}, + onConductedClick: () -> Unit = {}, + onEarnedClick: () -> Unit = {}, + onExpectedClick: () -> Unit = {}, ) { DaySummaryCard( date = date, stats = stats, + onLessonsClick = onLessonsClick, + onConductedClick = onConductedClick, + onEarnedClick = onEarnedClick, + onExpectedClick = onExpectedClick, modifier = modifier .fillMaxWidth() .height(daySummarySlotHeight), @@ -106,6 +118,10 @@ fun DaySummarySlot( private fun DaySummaryCard( date: LocalDate, stats: DaySummaryStats, + onLessonsClick: () -> Unit, + onConductedClick: () -> Unit, + onEarnedClick: () -> Unit, + onExpectedClick: () -> Unit, modifier: Modifier = Modifier, ) { val dateLabel = remember(date) { @@ -115,11 +131,9 @@ private fun DaySummaryCard( } val earnedText = remember(stats.earned) { formatRubles(stats.earned) } val expectedText = remember(stats.expected) { formatRubles(stats.expected) } - val intensiveConductedText = remember(stats.confirmedIntensiveChildren, stats.pendingIntensiveChildren) { - formatIntensiveConductedLabel(stats.confirmedIntensiveChildren, stats.pendingIntensiveChildren) - } - val showIntensiveMetric = stats.hasIntensive + // Интенсив занятием не считается: день с одним интенсивом честно + // показывает «занятий 0», а сам интенсив ждёт в разборе по нажатию. val lessonsValue = remember(stats) { if (stats.pendingLessons > 0) { "${stats.confirmedLessons + stats.attendedLessons}/${stats.pendingLessons}" @@ -127,6 +141,9 @@ private fun DaySummaryCard( stats.totalLessons.toString() } } + val conductedValue = remember(stats) { + if (stats.totalLessons > 0) "${stats.attendedLessons}/${stats.totalLessons}" else "0" + } val semanticColors = neiroSemanticColors val cardShape = RoundedCornerShape(14.dp) @@ -166,36 +183,22 @@ private fun DaySummaryCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - // Третья плитка в ряду отбирает ширину у подписей: в тесном - // ряду чип ужимается, иначе «проведено» упирается в многоточие. DayTile( icon = Icons.Rounded.School, accent = MaterialTheme.colorScheme.primary, label = "занятий", value = lessonsValue, - compact = showIntensiveMetric, + onClick = onLessonsClick, modifier = Modifier.weight(1f), ) DayTile( icon = Icons.Rounded.CheckCircle, accent = semanticColors.scheduleHeader, label = "проведено", - value = if (stats.totalLessons > 0) { - "${stats.attendedLessons}/${stats.totalLessons}" - } else "0", - compact = showIntensiveMetric, + value = conductedValue, + onClick = onConductedClick, modifier = Modifier.weight(1f), ) - if (showIntensiveMetric) { - DayTile( - icon = Icons.Rounded.Groups, - accent = MaterialTheme.colorScheme.error, - label = "интенсив", - value = intensiveConductedText, - compact = true, - modifier = Modifier.weight(1f), - ) - } } Row( @@ -208,6 +211,7 @@ private fun DaySummaryCard( label = "Заработано", value = earnedText, valueColor = semanticColors.scheduleHeader, + onClick = onEarnedClick, modifier = Modifier.weight(1f), ) // Нулевое ожидание не новость — сумма уходит в серый, а чип @@ -224,6 +228,7 @@ private fun DaySummaryCard( semanticColors.expected }, muted = nothingExpected, + onClick = onExpectedClick, modifier = Modifier.weight(1f), ) } @@ -238,7 +243,6 @@ private fun DaySummaryCard( * доля акцента в его заливке и кегли повторены оттуда намеренно, чтобы два * блока на экране читались одним набором. * - * @param compact ряд из трёх плиток — чип ужимается ради подписи. * @param muted значение не несёт новости (нулевое ожидание) — подпись глуше. */ @Composable @@ -247,14 +251,13 @@ private fun DayTile( accent: Color, label: String, value: String, + onClick: () -> Unit, modifier: Modifier = Modifier, valueColor: Color = MaterialTheme.colorScheme.onSurface, - compact: Boolean = false, muted: Boolean = false, ) { - val chipSize = if (compact) 26.dp else 32.dp - val iconSize = if (compact) 15.dp else 17.dp Surface( + onClick = onClick, modifier = modifier, shape = RoundedCornerShape(14.dp), color = dayTileColor, @@ -264,18 +267,18 @@ private fun DayTile( .fillMaxWidth() .padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Surface( shape = RoundedCornerShape(10.dp), color = accent.copy(alpha = DayChipAlpha), - modifier = Modifier.size(chipSize), + modifier = Modifier.size(32.dp), ) { Box(contentAlignment = Alignment.Center) { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(iconSize), + modifier = Modifier.size(17.dp), tint = accent, ) } diff --git a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt index 27c83a4d..975529a6 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt @@ -53,6 +53,7 @@ import ru.greemlab.neiro.theme.neiroSemanticColors import ru.greemlab.neiro.ui.calendar.ArchiveSyncCompare import ru.greemlab.neiro.ui.calendar.CalendarMode import ru.greemlab.neiro.ui.calendar.CalendarViewModel +import ru.greemlab.neiro.ui.calendar.DaySummaryStats import ru.greemlab.neiro.ui.calendar.computeDayStats import ru.greemlab.neiro.ui.calendar.rememberMonthEarnings import ru.greemlab.neiro.ui.components.* @@ -91,6 +92,10 @@ private sealed interface CalendarOverlay { data object About : CalendarOverlay data object LessonsDetails : CalendarOverlay data object DayDetails : CalendarOverlay + data object DayLessons : CalendarOverlay + data object DayConducted : CalendarOverlay + data object DayEarned : CalendarOverlay + data object DayExpected : CalendarOverlay data object Notifications : CalendarOverlay } @@ -106,6 +111,10 @@ private val CalendarOverlay.isGlassDialogAboveCalendar: Boolean CalendarOverlay.ProfitDetails, CalendarOverlay.LessonsDetails, CalendarOverlay.DayDetails, + CalendarOverlay.DayLessons, + CalendarOverlay.DayConducted, + CalendarOverlay.DayEarned, + CalendarOverlay.DayExpected, CalendarOverlay.Notifications -> true else -> false @@ -258,6 +267,12 @@ fun CalendarScreen( val selectedDayFact = remember(salaryLedger, salaryStaffId, selectedDate) { selectedDate?.let { salaryLedger.dayFact(salaryStaffId, it) } } + val daySummaryStats = rememberDaySummaryStats( + selectedDate = selectedDate, + sessions = selectedDayContext?.effective.orEmpty(), + rates = rates, + dayFact = selectedDayFact, + ) val context = LocalContext.current val activeNotificationStore = remember(context) { InAppNotificationStore.get(context) } @@ -435,6 +450,10 @@ fun CalendarScreen( CalendarOverlay.RegistrationPrompt } }, + onDayLessonsClick = { overlay = CalendarOverlay.DayLessons }, + onDayConductedClick = { overlay = CalendarOverlay.DayConducted }, + onDayEarnedClick = { overlay = CalendarOverlay.DayEarned }, + onDayExpectedClick = { overlay = CalendarOverlay.DayExpected }, onNotificationsClick = { overlay = CalendarOverlay.Notifications }, unreadNotificationCount = unreadNotificationCount, ) @@ -564,6 +583,38 @@ fun CalendarScreen( onDismiss = { overlay = CalendarOverlay.None }, ) + is CalendarOverlay.DayLessons -> DayTileDialogHost( + date = selectedDate, + stats = daySummaryStats, + onDismiss = { overlay = CalendarOverlay.None }, + ) { date, dayStats, dismiss -> + DayLessonsDialog(date = date, stats = dayStats, onDismiss = dismiss) + } + + is CalendarOverlay.DayConducted -> DayTileDialogHost( + date = selectedDate, + stats = daySummaryStats, + onDismiss = { overlay = CalendarOverlay.None }, + ) { date, dayStats, dismiss -> + DayConductedDialog(date = date, stats = dayStats, onDismiss = dismiss) + } + + is CalendarOverlay.DayEarned -> DayTileDialogHost( + date = selectedDate, + stats = daySummaryStats, + onDismiss = { overlay = CalendarOverlay.None }, + ) { date, dayStats, dismiss -> + DayEarnedDialog(date = date, stats = dayStats, onDismiss = dismiss) + } + + is CalendarOverlay.DayExpected -> DayTileDialogHost( + date = selectedDate, + stats = daySummaryStats, + onDismiss = { overlay = CalendarOverlay.None }, + ) { date, dayStats, dismiss -> + DayExpectedDialog(date = date, stats = dayStats, onDismiss = dismiss) + } + is CalendarOverlay.ProfitDetails -> ProfitDetailsDialog( currentMonth = currentMonth, stats = stats, @@ -663,6 +714,10 @@ private val OverlaySaver = Saver( CalendarOverlay.About -> "about" CalendarOverlay.LessonsDetails -> "lessons" CalendarOverlay.DayDetails -> "day" + CalendarOverlay.DayLessons -> "day_lessons" + CalendarOverlay.DayConducted -> "day_conducted" + CalendarOverlay.DayEarned -> "day_earned" + CalendarOverlay.DayExpected -> "day_expected" CalendarOverlay.Notifications -> "notifications" } }, @@ -679,12 +734,54 @@ private val OverlaySaver = Saver( "about" -> CalendarOverlay.About "lessons" -> CalendarOverlay.LessonsDetails "day" -> CalendarOverlay.DayDetails + "day_lessons" -> CalendarOverlay.DayLessons + "day_conducted" -> CalendarOverlay.DayConducted + "day_earned" -> CalendarOverlay.DayEarned + "day_expected" -> CalendarOverlay.DayExpected "notifications" -> CalendarOverlay.Notifications else -> CalendarOverlay.None } }, ) +/** + * Диалог плитки дня живёт, пока жив сам день. + * + * Дата и сводка приходят из одного источника, так что пустыми они бывают + * только вместе — и только когда дня правда нет: после смерти процесса или + * пока профиль не настроен. Разбор дня без дня показывать нечего. + */ +@Composable +private fun DayTileDialogHost( + date: LocalDate?, + stats: DaySummaryStats?, + onDismiss: () -> Unit, + content: @Composable (LocalDate, DaySummaryStats, () -> Unit) -> Unit, +) { + if (date == null || stats == null) { + LaunchedEffect(Unit) { onDismiss() } + return + } + content(date, stats, onDismiss) +} + +/** + * Сводка выбранного дня. + * + * Считается в двух местах — в плашке дня и в её диалогах, — поэтому живёт + * отдельной функцией: аргументы одни, и разойтись цифрам негде. + */ +@Composable +private fun rememberDaySummaryStats( + selectedDate: LocalDate?, + sessions: List, + rates: EarningsContext, + dayFact: Double?, +): DaySummaryStats? = remember(selectedDate, sessions, rates, dayFact) { + if (selectedDate == null) return@remember null + computeDayStats(sessions, rates, dayFact) +} + /** * Чистый UI календарного экрана (без drawer и overlay). * Используется в [CalendarScreen] и в Compose Preview. @@ -720,13 +817,19 @@ fun CalendarScreenContent( onDateClick: (LocalDate) -> Unit, onProfitClick: () -> Unit = {}, onLessonsClick: () -> Unit = {}, + onDayLessonsClick: () -> Unit = {}, + onDayConductedClick: () -> Unit = {}, + onDayEarnedClick: () -> Unit = {}, + onDayExpectedClick: () -> Unit = {}, onNotificationsClick: () -> Unit = {}, unreadNotificationCount: Int = 0, ) { - val daySummaryStats = remember(selectedDate, selectedDaySessions, rates, selectedDayFact) { - if (selectedDate == null) return@remember null - computeDayStats(selectedDaySessions, rates, selectedDayFact) - } + val daySummaryStats = rememberDaySummaryStats( + selectedDate = selectedDate, + sessions = selectedDaySessions, + rates = rates, + dayFact = selectedDayFact, + ) var monthPickerVisible by rememberSaveable { mutableStateOf(false) } val pullRefreshState = rememberPullToRefreshState() val scrollableState = rememberScrollableState { 0f } @@ -800,6 +903,10 @@ fun CalendarScreenContent( DaySummarySlot( date = selectedDate, stats = daySummaryStats, + onLessonsClick = onDayLessonsClick, + onConductedClick = onDayConductedClick, + onEarnedClick = onDayEarnedClick, + onExpectedClick = onDayExpectedClick, ) Spacer(modifier = Modifier.height(4.dp)) } diff --git a/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt b/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt index b681bb88..682ffa85 100644 --- a/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/ui/calendar/DaySummaryStatsTest.kt @@ -1,6 +1,7 @@ package ru.greemlab.neiro.ui.calendar import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test import ru.greemlab.neiro.domain.models.EarningsContext @@ -143,4 +144,187 @@ class DaySummaryStatsTest { assertEquals(1, stats.confirmedLessons) assertEquals(1, stats.pendingLessons) } + + @Test + fun `day breakdown splits lessons diagnostics and intensive`() { + val intensive = SessionFormat.serializeIntensive( + price = "", + name = "Интенсив", + status = AttendanceStatus.EXPECTED, + time = "18:00-18:50", + children = listOf( + Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Маша", AttendanceStatus.EXPECTED), + ), + ) + val stats = computeDayStats( + listOf(intensive, "Иванов|3", "__DIAGNOSTICS__:500|Аня|true"), + rates = EarningsContext( + pricePerSession = 1000.0, + pricePerIntensiveChild = 800.0, + ), + ) + + assertEquals(1, stats.lessons.planned) + assertEquals(1, stats.lessons.attended) + assertEquals(1000.0, stats.lessons.earned, 0.0) + + assertEquals(1, stats.diagnostics.planned) + assertEquals(500.0, stats.diagnostics.earned, 0.0) + + assertEquals(1, stats.intensiveCount) + assertEquals(2, stats.intensives.planned) + assertEquals(1, stats.intensives.attended) + assertEquals(1, stats.intensives.pending) + assertEquals(800.0, stats.intensives.earned, 0.0) + assertEquals(800.0, stats.intensives.expected, 0.0) + + // Интенсив занятием не считается: в дне ученик и диагностика. + assertEquals(2, stats.totalLessons) + assertEquals(2, stats.attendedLessons) + assertEquals(2, stats.scheduledLessons) + } + + @Test + fun `day without intensive has no intensive breakdown`() { + val stats = computeDayStats( + listOf("Иванов|3", "Петров|0"), + rates = EarningsContext(pricePerSession = 1000.0), + ) + assertEquals(0, stats.intensiveCount) + assertTrue(stats.intensives.isEmpty) + assertEquals(2, stats.totalLessons) + assertEquals(1, stats.attendedLessons) + assertEquals(1, stats.pendingLessons) + } + + @Test + fun `confirmed intensive child is not counted as arrived in breakdown`() { + val intensive = SessionFormat.serializeIntensive( + price = "", + name = "Интенсив", + status = AttendanceStatus.CONFIRMED, + time = "18:00-18:50", + children = listOf( + Session.IntensiveChild("Дима", AttendanceStatus.CONFIRMED), + Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + ), + ) + val stats = computeDayStats( + listOf(intensive), + rates = EarningsContext(pricePerIntensiveChild = 800.0), + ) + assertEquals(2, stats.intensives.planned) + assertEquals(1, stats.intensives.attended) + assertEquals(1, stats.intensives.confirmed) + assertEquals(0, stats.intensives.pending) + } + + @Test + fun `day fact marks earnings as coming from api`() { + val stats = computeDayStats( + listOf("Иванов|3"), + rates = EarningsContext(pricePerSession = 1000.0), + dayFact = 1500.0, + ) + assertTrue(stats.earnedFromFact) + assertEquals(1500.0, stats.earned, 0.0) + // Разбивка остаётся расчётной — диалог показывает её отдельно от факта. + assertEquals(1000.0, stats.lessons.earned, 0.0) + } + + @Test + fun `cancelled student stays in scheduled and potential earnings`() { + val stats = computeDayStats( + listOf("Иванов|2", "Петров|3"), + rates = EarningsContext(pricePerSession = 1000.0), + ) + assertEquals(1, stats.lessons.planned) + assertEquals(1, stats.lessons.cancelled) + assertEquals(2, stats.lessons.scheduled) + assertEquals(1000.0, stats.lessons.cancelledAmount, 0.0) + + assertEquals(2, stats.scheduledLessons) + assertEquals(1, stats.cancelledLessons) + assertEquals(1000.0, stats.cancelledAmount, 0.0) + // Потолок дня: пришедший ученик плюс тот, что отменился. + assertEquals(2000.0, stats.potentialEarned, 0.0) + } + + @Test + fun `cancelled intensive child counts as scheduled but not as money`() { + val intensive = SessionFormat.serializeIntensive( + price = "", + name = "Интенсив", + status = AttendanceStatus.ARRIVED, + time = "18:00-18:50", + children = listOf( + Session.IntensiveChild("Дима", AttendanceStatus.CANCELLED), + Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + ), + ) + val stats = computeDayStats( + listOf(intensive), + rates = EarningsContext(pricePerIntensiveChild = 800.0), + ) + assertEquals(1, stats.intensives.planned) + assertEquals(1, stats.intensives.attended) + assertEquals(1, stats.intensives.cancelled) + assertEquals(2, stats.intensives.scheduled) + assertEquals(800.0, stats.intensives.earned, 0.0) + assertEquals(0.0, stats.intensives.expected, 0.0) + assertEquals(800.0, stats.intensives.cancelledAmount, 0.0) + assertEquals(1600.0, stats.potentialEarned, 0.0) + } + + @Test + fun `fixed intensive amount loses nothing on cancelled child`() { + val intensive = SessionFormat.serializeIntensive( + price = "5600", + name = "Интенсив", + status = AttendanceStatus.ARRIVED, + time = "19:00-19:50", + children = listOf( + Session.IntensiveChild("Дима", AttendanceStatus.CANCELLED), + Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + ), + amountFixed = true, + ) + val stats = computeDayStats( + listOf(intensive), + rates = EarningsContext(pricePerIntensiveChild = 1400.0), + ) + assertEquals(1, stats.intensives.cancelled) + // Сумма задана руками — отмена ребёнка её не трогает, терять нечего. + assertEquals(0.0, stats.intensives.cancelledAmount, 0.0) + assertEquals(5600.0, stats.earned, 0.0) + assertEquals(5600.0, stats.potentialEarned, 0.0) + } + + + @Test + fun `day with only intensive has zero lessons`() { + val intensive = SessionFormat.serializeIntensive( + price = "", + name = "Интенсив", + status = AttendanceStatus.ARRIVED, + time = "18:00-18:50", + children = listOf( + Session.IntensiveChild("Дима", AttendanceStatus.ARRIVED), + Session.IntensiveChild("Маша", AttendanceStatus.ARRIVED), + ), + ) + val stats = computeDayStats( + listOf(intensive), + rates = EarningsContext(pricePerIntensiveChild = 800.0), + ) + // Интенсив занятием не считается — плитка дня честно покажет ноль. + assertEquals(0, stats.totalLessons) + assertEquals(0, stats.attendedLessons) + assertEquals(0, stats.scheduledLessons) + // Но сам интенсив и его дети в разборе есть. + assertEquals(1, stats.intensiveCount) + assertEquals(2, stats.intensives.attended) + assertEquals(1600.0, stats.earned, 0.0) + } }