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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,37 @@

## [Не выпущено]

Пока пусто. Сюда дописывается изменение, заметное пользователю, — тем же PR,
которым оно вносится, а не по памяти в день выпуска. Заголовки внутри:
«Добавлено», «Изменено», «Исправлено», «Убрано».
### Добавлено

- **Плитки дня открываются по нажатию** — «занятий», «проведено», «Заработано»
и «Ожидается» под календарём теперь нажимаются, и каждая показывает, из чего
сложилась цифра: сколько было обычных занятий, сколько детей на интенсиве,
сколько диагностик, сколько денег принёс каждый вид. Строка появляется
только про то, что в этом дне правда было.
- **Разбор дня считает и отменённое** — сверху в каждом разборе стоит общая
цифра: сколько занятий должно было быть и сколько денег день мог принести,
если бы никто не отменился. Ниже — что из этого уже прошло, что ещё
ожидается и сколько потеряно на отменах. Плитки на экране по-прежнему
показывают только живые записи. Деньги интенсива в суммы входят, а вот
занятием он не считается нигде.

### Исправлено

- **Пустая строка «Диагностик 0»** — в разборе занятий за месяц строка стояла
и в месяцах, где диагностик не было, и повторяла цифру «Проведено». Теперь
разбивка появляется, только когда диагностики за месяц были.
- **Обрезанные подписи в дне с интенсивом** — в такой день под календарём
вставала третья плитка, и подписи ужимались до «заня…», «пров…», «инте…».
Теперь плиток всегда четыре, по две в ряду, а интенсив виден в разборе,
который открывается нажатием.

### Изменено

- **Интенсив больше не занимает место в ряду плиток** — раньше в день с
интенсивом под календарём вставала третья плитка. Теперь интенсив живёт в
разборе: нажатие на «занятий» или «проведено» покажет, сколько интенсивов
было и сколько детей на них пришло. В счётчике занятий интенсив по-прежнему
не участвует — день с одним интенсивом показывает «занятий 0».

## [0.2.2] — 2026-08-21

Expand Down
166 changes: 165 additions & 1 deletion app/src/main/java/ru/greemlab/neiro/ui/calendar/DaySummaryStats.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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]), заработок
Expand All @@ -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)

Expand All @@ -54,50 +150,113 @@ 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)
val planned = session.totalAmount(rates.pricePerIntensiveChild, onlyArrived = false)
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++
}
}
}

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++
}
}
}
Expand All @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
}
}
}

Expand Down
Loading
Loading