Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs
T
gorbenko.pavel 240e5984b1
Tests / frontend-test (pull_request) Successful in 53s
Tests / backend-test (pull_request) Successful in 5m34s
feat(отчёт): строка на каждый приём + столбец «Ф.И.О. нанимаемого сотрудника»
На одну заявку на подбор приходится несколько заявок на приём («Разнорабочий,
4 вакансии» → 4 кандидата), и раньше они схлопывались в одну строку: ФИО не
было вовсе, а «Дата выхода» показывала только САМУЮ ПОЗДНЮЮ из четырёх. Кто
именно вышел и когда — из отчёта было не узнать.

Теперь вакансия занимает столько строк, сколько по ней приёмов. Построчные
колонки — «Дата выхода», новая «Ф.И.О. нанимаемого сотрудника» и «Вместо
сотрудника Ф.И.О.» (у каждого приёма своё «взамен кого»); все остальные общие
и объединяются по вертикали.

Модель: VacancyReportRow.Lines — именно СТРОКИ ЛИСТА, а не «приёмы». У вакансии
без приёмов строка всё равно одна (иначе она пропала бы из отчёта), с пустыми
ФИО и датой, но с «Вместо сотрудника» из причины открытия вакансии — прежнее
поведение сохранено. Реальное число приёмов осталось в ClosedCount.

Отклонённые приёмы теперь отбрасываются целиком, а не только из счётчика:
отдельная строка с ФИО отклонённого кандидата утверждала бы «вот кого приняли».

Объединение заводится только при lineCount > 1 — область 1×1 Excel считает
некорректной и портит файл. Значение общих колонок пишется ТОЛЬКО в первую
строку: у объединённой области Excel хранит его в верхней левой ячейке, а дубли
сломали бы SUM в итогах (посчитал бы вакансию по разу на каждый приём) — на это
есть отдельный тест.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:29:13 +05:00

557 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using NPOI.OOXML.XSSF.UserModel;
using NPOI.SS.UserModel;
using NPOI.SS.UserModel.Charts;
using NPOI.SS.Util;
using NPOI.XSSF.UserModel;
namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>
/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR:
/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по
/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя
/// круговыми диаграммами).
///
/// Листы собираются кодом, а не заполнением файла-шаблона: число строк
/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) —
/// шаблон с такой структурой пришлось бы править на каждое изменение состава.
///
/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов
/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный
/// лист выглядел бы просто как две таблицы без визуализации, как в образце.
///
/// Производные колонки (G «в работе», J «срок отработки») и итоги первого
/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше
/// руками, и после правки строки цифры должны пересчитаться сами.
/// </summary>
public static class VacancyReportWorkbook
{
private const string SheetName = "Список вакансий (все)";
private const string SummarySheetName = "Результаты";
private const string DateFormat = "dd.mm.yyyy";
private const string WholePercentFormat = "0%";
private const string PrecisePercentFormat = "0.00%";
/// <summary>Колонка первого листа: заголовок и ширина (ширины — из образца отчёта).</summary>
private static readonly (string Title, double Width)[] Columns =
[
(" ", 6.1),
("№", 4.9),
("Вакансия", 57.4),
("Подразделение", 17.1),
("Кол-во вакансий", 8.3),
("количество закрытых вакансий", 9.1),
("количество вакансий в работе", 11.6),
("Дата подачи заявки", 14.7),
("Дата закрытия вакансии", 14.9),
("Срок отработки", 12.3),
("Дата выхода", 13.9),
("Ф.И.О. нанимаемого сотрудника", 30.0),
("Инициатор заявки", 26.9),
("Вместо сотрудника Ф.И.О.", 26.0),
("Комментарии", 51.3),
("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
("Рекрутер", 23.9),
];
// Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.).
private const int ColTotalNumber = 0;
private const int ColGroupNumber = 1;
private const int ColPosition = 2;
private const int ColDepartment = 3;
private const int ColVacancies = 4;
private const int ColClosed = 5;
private const int ColInProgress = 6;
private const int ColSubmitDate = 7;
private const int ColClosingDate = 8;
private const int ColLeadTime = 9;
private const int ColStartDate = 10;
private const int ColCandidateName = 11;
private const int ColInitiator = 12;
private const int ColReplaced = 13;
private const int ColComment = 14;
private const int ColClosedFlag = 15;
private const int ColRecruiter = 16;
/// <summary>
/// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные
/// общие — при нескольких приёмах по одной заявке на подбор они объединяются
/// по вертикали (см. WriteVacancy).
/// </summary>
private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced];
private const int HeaderRowIdx = 0;
public static byte[] Build(VacancyReport report)
{
using var workbook = new XSSFWorkbook();
var styles = new Styles(workbook);
BuildVacancyListSheet(workbook, styles, report);
BuildSummarySheet(workbook, styles, report);
using var stream = new MemoryStream();
workbook.Write(stream, leaveOpen: true);
return stream.ToArray();
}
/// <summary>Имя файла — с периодом, как в ручном отчёте HR.</summary>
public static string FileName(VacancyReport report) =>
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
// ==================== Лист 1: «Список вакансий (все)» ====================
private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
{
var sheet = (XSSFSheet)workbook.CreateSheet(SheetName);
WriteHeader(sheet, styles);
var rowIdx = HeaderRowIdx + 1;
var totalNumber = 0;
var firstDataRowIdx = rowIdx;
foreach (var group in report.Groups)
{
if (group.Name.Length > 0) WriteGroupSeparator(sheet, styles, rowIdx++, group.Name);
var groupNumber = 0;
foreach (var item in group.Rows)
rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item);
}
var lastDataRowIdx = rowIdx - 1;
// Итоги — только если есть что суммировать: SUM по пустому диапазону
// (firstDataRowIdx > lastDataRowIdx) дал бы формулу с перевёрнутым диапазоном.
if (lastDataRowIdx >= firstDataRowIdx) WriteTotals(sheet, styles, rowIdx, firstDataRowIdx, lastDataRowIdx);
FinishVacancyListSheet(sheet, styles, lastDataRowIdx);
}
private static void WriteHeader(XSSFSheet sheet, Styles styles)
{
var row = sheet.CreateRow(HeaderRowIdx);
// Заголовки длинные и переносятся — без заданной высоты Excel показал бы
// одну строку из трёх (высота 90 — из образца отчёта).
row.HeightInPoints = 90f;
for (var col = 0; col < Columns.Length; col++)
{
var (title, width) = Columns[col];
var cell = row.CreateCell(col);
cell.SetCellValue(title);
cell.CellStyle = styles.Header;
sheet.SetColumnWidth(col, (int)Math.Round(width * 256));
}
}
/// <summary>
/// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
/// начинается заново — так же, как в ручном отчёте.
/// </summary>
private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name)
{
var row = sheet.CreateRow(rowIdx);
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.GroupSeparator;
row.GetCell(ColGroupNumber).SetCellValue(name);
}
/// <summary>
/// Одна вакансия — столько строк, сколько у неё Lines (минимум одна).
/// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик.
///
/// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по
/// вертикали: у объединённой области Excel хранит значение в верхней левой
/// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя —
/// SUM в итогах посчитал бы одно и то же несколько раз).
/// </summary>
private static int WriteVacancy(
XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
{
var lines = item.Lines;
var lineCount = Math.Max(lines.Count, 1);
var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки.
// Все строки вакансии — со стилем данных, иначе у продолжений не будет
// границ и объединённая область выглядит рваной.
for (var offset = 0; offset < lineCount; offset++)
{
var row = sheet.CreateRow(firstRowIdx + offset);
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.Data;
}
var first = sheet.GetRow(firstRowIdx);
first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
first.GetCell(ColGroupNumber).SetCellValue(groupNumber);
first.GetCell(ColPosition).SetCellValue(item.Position);
first.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
first.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
first.GetCell(ColClosed).SetCellValue(item.ClosedCount);
first.GetCell(ColInProgress).SetCellFormula(
$"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
SetDate(first.GetCell(ColSubmitDate), styles, item.SubmitDate);
SetDate(first.GetCell(ColClosingDate), styles, item.ClosingDate);
// «Срок отработки» — разница дат в днях, и только когда известны обе:
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
// дней (пустая ячейка — это 00.01.1900).
if (item.SubmitDate is not null && item.ClosingDate is not null)
{
var leadTimeCell = first.GetCell(ColLeadTime);
leadTimeCell.CellStyle = styles.DataLeadTime;
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
}
first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
// Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
// отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
// заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
// в выгрузке.
first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
first.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
// Построчные поля — каждое в свою строку.
for (var offset = 0; offset < lines.Count; offset++)
{
var row = sheet.GetRow(firstRowIdx + offset);
var line = lines[offset];
row.GetCell(ColCandidateName).SetCellValue(line.CandidateName ?? string.Empty);
SetDate(row.GetCell(ColStartDate), styles, line.StartDate);
row.GetCell(ColReplaced).SetCellValue(line.ReplacedEmployee ?? string.Empty);
}
// Объединяем общие колонки только когда строк действительно больше одной:
// область 1×1 Excel считает некорректной и портит файл.
if (lineCount > 1)
{
var lastRowIdx = firstRowIdx + lineCount - 1;
for (var col = 0; col < Columns.Length; col++)
{
if (PerLineColumns.Contains(col)) continue;
sheet.AddMergedRegion(new CellRangeAddress(firstRowIdx, lastRowIdx, col, col));
}
}
return lineCount;
}
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
{
var row = sheet.CreateRow(rowIdx);
var firstExcelRow = firstDataRowIdx + 1;
var lastExcelRow = lastDataRowIdx + 1;
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.Totals;
foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress])
{
var letter = ColLetter(col);
row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})");
}
}
/// <summary>
/// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр
/// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида
/// вместе с отфильтрованными строками.
/// </summary>
private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx)
{
// ВАЖНО: даты НЕ переносятся сюда через SetDefaultColumnStyle — NPOI (в
// отличие от чистого OOXML, где стиль колонки должен уступать явному
// стилю ячейки) переписывает им стиль уже существующих ячеек колонки
// целиком, включая шапку: заголовки «Дата подачи заявки»/«Дата закрытия
// вакансии»/«Дата выхода» лишались синей заливки и получали формат даты
// вместо неё. Формат даты у самих дат и так стоит явно, ячейка за
// ячейкой (см. SetDate) — колонки, дописанные в файл руками ниже данных,
// формат не унаследуют, но это не тот случай, ради которого стоит опять
// ломать шапку.
//
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
// какая колонка какая.
sheet.CreateFreezePane(0, HeaderRowIdx + 1);
if (lastDataRowIdx > HeaderRowIdx)
sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1));
}
private static void SetDate(ICell cell, Styles styles, DateOnly? date)
{
if (date is null) return;
cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue));
cell.CellStyle = styles.DataDate;
}
// ==================== Лист 2: «Результаты» (сводка по рекрутёрам) ====================
/// <summary>
/// Сводка по рекрутёрам: горизонтальная таблица (кто сколько вакансий ведёт,
/// какая доля в работе/закрыта) и две вертикальные таблицы с долей каждого
/// рекрутёра в общем объёме — они же источник данных для двух круговых
/// диаграмм рядом. Раскладка — по образцу листа «Статус работы отдела_АХ» в
/// ручном отчёте HR.
/// </summary>
private static void BuildSummarySheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
{
var sheet = (XSSFSheet)workbook.CreateSheet(SummarySheetName);
sheet.SetColumnWidth(0, (int)Math.Round(26.0 * 256));
var titleRow = sheet.CreateRow(0);
titleRow.CreateCell(0).SetCellValue("результаты");
titleRow.GetCell(0).CellStyle = styles.Title;
var summary = VacancyReportSummary.Build(report);
if (summary.Recruiters.Count == 0)
{
sheet.CreateRow(2).CreateCell(0).SetCellValue("Нет данных за период.");
return;
}
var recruiters = summary.Recruiters;
for (var i = 0; i < recruiters.Count; i++)
sheet.SetColumnWidth(1 + i, (int)Math.Round(12.0 * 256));
WriteHorizontalTable(sheet, styles, summary);
const int horizontalTableRows = 4; // заголовок + «Всего» + «в работе» + «закрыто»
var vertTable1HeaderRowIdx = HeaderRowIdx + 1 + horizontalTableRows + 1; // +1 — пустая строка-отступ
var vertTable1DataStartRowIdx = vertTable1HeaderRowIdx + 1;
WriteVerticalTable(
sheet, styles, vertTable1HeaderRowIdx, "Общий объем работ", summary.GrandTotal,
recruiters, r => r.ShareOfTotal(summary.GrandTotal));
var vertTable2HeaderRowIdx = vertTable1DataStartRowIdx + recruiters.Count + 1;
var vertTable2DataStartRowIdx = vertTable2HeaderRowIdx + 1;
WriteVerticalTable(
sheet, styles, vertTable2HeaderRowIdx, "Общий объем в работе", summary.GrandInProgress,
recruiters, r => r.ShareOfInProgress(summary.GrandInProgress));
// Якоря диаграмм считаются НЕЗАВИСИМО от строк таблиц: у таблицы может
// быть 2 строки, а у диаграммы фиксированная высота ChartHeightRows — с
// якорем "на уровне шапки своей таблицы" вторая диаграмма при коротких
// таблицах вставала бы внутрь вертикального диапазона первой (см.
// ChartHeightRows). Обе выравнены по левому краю первой (горизонтальной)
// таблицы и просто идут подряд сверху вниз с отступом.
var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}";
var chart1TopRowIdx = HeaderRowIdx + 1;
var chart2TopRowIdx = chart1TopRowIdx + ChartHeightRows + 1;
CreatePieChart(
sheet, "Объём вакансий " + period,
firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart1TopRowIdx);
CreatePieChart(
sheet, "Объём вакансий в работе " + period,
firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart2TopRowIdx);
}
/// <summary>
/// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры
/// колонками, как в образце отчёта (там же W:Y).
/// </summary>
private static void WriteHorizontalTable(XSSFSheet sheet, Styles styles, VacancyReportSummary summary)
{
var headerRow = sheet.CreateRow(HeaderRowIdx + 1);
var totalRow = sheet.CreateRow(HeaderRowIdx + 2);
var inProgressRow = sheet.CreateRow(HeaderRowIdx + 3);
var closedRow = sheet.CreateRow(HeaderRowIdx + 4);
SetCell(headerRow, 0, "Кол-во вакансий", styles.SummaryHeaderYellow);
SetCell(totalRow, 0, "Всего", styles.SummaryLabel);
SetCell(inProgressRow, 0, "в работе (в%)", styles.SummaryRowGreenLabel);
SetCell(closedRow, 0, "закрыто", styles.SummaryLabel);
for (var i = 0; i < summary.Recruiters.Count; i++)
{
var r = summary.Recruiters[i];
var col = 1 + i;
SetCell(headerRow, col, r.Name, styles.SummaryHeaderYellow);
SetCell(totalRow, col, r.Total, styles.SummaryValue);
SetCell(inProgressRow, col, r.InProgressRate, styles.SummaryRowGreenPercent);
SetCell(closedRow, col, r.ClosedRate, styles.SummaryPercent);
}
}
/// <summary>
/// Одна вертикальная таблица: шапка «label | итого» (синяя заливка, итого —
/// красным полужирным, как в образце) плюс по строке на рекрутёра с его
/// долей в этом итого. Возвращаемый диапазон строк — вход для круговой
/// диаграммы (см. CreatePieChart).
/// </summary>
private static void WriteVerticalTable(
XSSFSheet sheet, Styles styles, int headerRowIdx, string label, int total,
IReadOnlyList<RecruiterBreakdown> recruiters, Func<RecruiterBreakdown, double> share)
{
var headerRow = sheet.CreateRow(headerRowIdx);
SetCell(headerRow, 0, label, styles.SummaryBlueHeader);
SetCell(headerRow, 1, total, styles.SummaryBlueTotal);
// Заливка — на всю ширину блока (по числу рекрутёров, как в горизонтальной
// таблице выше), а не только под текстом заголовка: иначе шапка выглядит
// как две узкие цветные ячейки на фоне остального белого листа, а не как
// сплошная плашка над таблицей.
for (var col = 2; col <= recruiters.Count; col++)
SetCell(headerRow, col, string.Empty, styles.SummaryBlueHeader);
for (var i = 0; i < recruiters.Count; i++)
{
var row = sheet.CreateRow(headerRowIdx + 1 + i);
SetCell(row, 0, recruiters[i].Name, styles.SummaryGreenText);
SetCell(row, 1, share(recruiters[i]), styles.SummaryGreenPercent);
}
}
/// <summary>
/// Высота якоря диаграммы в строках — фиксированная и не зависит от числа
/// рекрутёров: подгонять размер круга под число категорий смысла нет, малое
/// число секторов на большом круге читается ничуть не хуже. Используется и
/// при размещении диаграмм (см. BuildSummarySheet — вторая идёт сразу под
/// первой, без наложения).
/// </summary>
private const int ChartHeightRows = 14;
/// <summary>Круговая диаграмма по одной вертикальной таблице: категории — колонка A (имена рекрутёров), значения — колонка B (доля).</summary>
private static void CreatePieChart(
XSSFSheet sheet, string title, int firstDataRowIdx, int rowCount, int anchorTopRowIdx)
{
if (rowCount == 0) return;
var drawing = (XSSFDrawing)sheet.CreateDrawingPatriarch();
var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + ChartHeightRows);
var chart = (XSSFChart)drawing.CreateChart(anchor);
chart.SetTitle(title);
var lastDataRowIdx = firstDataRowIdx + rowCount - 1;
var data = chart.ChartDataFactory.CreatePieChartData<string, double>();
var categories = DataSources.FromStringCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 0, 0));
var values = DataSources.FromNumericCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 1, 1));
data.AddSeries(categories, values);
chart.Plot(data);
chart.GetOrCreateLegend();
}
private static void SetCell(IRow row, int col, string value, ICellStyle style)
{
var cell = row.CreateCell(col);
cell.SetCellValue(value);
cell.CellStyle = style;
}
private static void SetCell(IRow row, int col, double value, ICellStyle style)
{
var cell = row.CreateCell(col);
cell.SetCellValue(value);
cell.CellStyle = style;
}
private static string ColLetter(int col0Based) => CellReference.ConvertNumToColString(col0Based);
/// <summary>
/// Все стили обоих листов — созданы один раз на книгу и переиспользуются:
/// ICellStyle в NPOI (в отличие от ClosedXML) назначается ячейке целиком, а
/// не собирается по кусочкам, поэтому под каждую итоговую комбинацию
/// заливка+шрифт+формат нужен отдельный объект стиля.
/// </summary>
private sealed class Styles
{
public ICellStyle Header { get; }
public ICellStyle GroupSeparator { get; }
public ICellStyle Data { get; }
public ICellStyle DataDate { get; }
public ICellStyle DataLeadTime { get; }
public ICellStyle Totals { get; }
public ICellStyle Title { get; }
public ICellStyle SummaryHeaderYellow { get; }
public ICellStyle SummaryLabel { get; }
public ICellStyle SummaryValue { get; }
public ICellStyle SummaryPercent { get; }
public ICellStyle SummaryRowGreenLabel { get; }
public ICellStyle SummaryRowGreenPercent { get; }
public ICellStyle SummaryBlueHeader { get; }
public ICellStyle SummaryBlueTotal { get; }
public ICellStyle SummaryGreenText { get; }
public ICellStyle SummaryGreenPercent { get; }
// Цвета — из образца отчёта (заливка шапки FFE5E5FF и т.д.), чтобы
// выгрузка выглядела привычно рядом со старым файлом.
private static readonly byte[] HeaderFill = [0xE5, 0xE5, 0xFF];
private static readonly byte[] GroupFill = [0xF2, 0xF2, 0xF2];
private static readonly byte[] YellowFill = [0xFF, 0xFF, 0x00];
private static readonly byte[] GreenFill = [0xC6, 0xE0, 0xB4];
private static readonly byte[] BlueFill = [0x4A, 0x7E, 0xBB];
private static readonly byte[] RedFont = [0xC0, 0x00, 0x00];
private static readonly byte[] WhiteFont = [0xFF, 0xFF, 0xFF];
public Styles(XSSFWorkbook workbook)
{
var colorMap = new DefaultIndexedColorMap();
ICellStyle New(
byte[]? fill = null, bool bold = false, byte[]? fontColor = null, string? numberFormat = null,
bool wrap = false, bool border = false, bool topDoubleBorder = false,
HorizontalAlignment? align = null, VerticalAlignment? valign = null)
{
var style = (XSSFCellStyle)workbook.CreateCellStyle();
if (fill is not null)
{
style.SetFillForegroundColor(new XSSFColor(fill, colorMap));
style.FillPattern = FillPattern.SolidForeground;
}
if (bold || fontColor is not null)
{
var font = (XSSFFont)workbook.CreateFont();
font.IsBold = bold;
if (fontColor is not null) font.SetColor(new XSSFColor(fontColor, colorMap));
style.SetFont(font);
}
if (numberFormat is not null)
style.DataFormat = workbook.CreateDataFormat().GetFormat(numberFormat);
if (wrap) style.WrapText = true;
if (border)
{
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
}
if (topDoubleBorder) style.BorderTop = BorderStyle.Double;
if (align is not null) style.Alignment = align.Value;
if (valign is not null) style.VerticalAlignment = valign.Value;
return style;
}
Header = New(
fill: HeaderFill, bold: true, wrap: true, border: true,
align: HorizontalAlignment.Center, valign: VerticalAlignment.Center);
GroupSeparator = New(fill: GroupFill, bold: true);
Data = New(wrap: true, border: true);
DataDate = New(wrap: true, border: true, numberFormat: DateFormat);
DataLeadTime = New(wrap: true, border: true, numberFormat: "0");
Totals = New(bold: true, topDoubleBorder: true);
Title = New(bold: true);
SummaryHeaderYellow = New(fill: YellowFill, bold: true);
SummaryLabel = New(bold: true);
SummaryValue = New();
SummaryPercent = New(numberFormat: WholePercentFormat);
SummaryRowGreenLabel = New(fill: GreenFill, bold: true);
SummaryRowGreenPercent = New(fill: GreenFill, numberFormat: WholePercentFormat);
SummaryBlueHeader = New(fill: BlueFill, bold: true, fontColor: WhiteFont);
SummaryBlueTotal = New(fill: BlueFill, bold: true, fontColor: RedFont);
SummaryGreenText = New(fill: GreenFill);
SummaryGreenPercent = New(fill: GreenFill, numberFormat: PrecisePercentFormat);
}
}
}