mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Added financial report
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto";
|
||||
import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@@ -83,6 +83,35 @@ export class ReportsController {
|
||||
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||
}
|
||||
|
||||
// ── Finance Summary ──────────────────────────────────────────────────────
|
||||
|
||||
@Get("finance")
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, and payment method",
|
||||
description:
|
||||
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
|
||||
"destination station pair, and payment method. Filter by originStationId and/or destinationStationId " +
|
||||
"independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " +
|
||||
"Returns per-bucket rows plus roll-ups by period, segment, and method for charting.",
|
||||
})
|
||||
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
|
||||
return this.service.getFinanceSummary(query);
|
||||
}
|
||||
|
||||
@Get("finance/export")
|
||||
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
|
||||
@ApiProduces("text/csv")
|
||||
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
|
||||
async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise<void> {
|
||||
const csv = await this.service.exportFinanceSummaryCsv(query);
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`,
|
||||
);
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||
|
||||
@Get("blocked-seats-revenue-loss")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaymentMethodType } from '@prisma/client';
|
||||
import { SeatBlockReasonCategory } from '../seats/seats.dto';
|
||||
|
||||
export enum ReportType {
|
||||
@@ -103,3 +104,31 @@ export class BlockedSeatsRevenueLossQueryDto {
|
||||
})
|
||||
@IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy;
|
||||
}
|
||||
|
||||
// ── Finance Summary ──────────────────────────────────────────────────────────
|
||||
|
||||
export enum FinanceGranularity {
|
||||
DAILY = 'daily',
|
||||
WEEKLY = 'weekly',
|
||||
MONTHLY = 'monthly',
|
||||
}
|
||||
|
||||
export class FinanceSummaryQueryDto {
|
||||
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateFrom: string;
|
||||
|
||||
@ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateTo: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY })
|
||||
@IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' })
|
||||
@IsOptional() @IsString() originStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' })
|
||||
@IsOptional() @IsString() destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' })
|
||||
@IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import {
|
||||
BlockedSeatsLossSortBy,
|
||||
BlockedSeatsRevenueLossQueryDto,
|
||||
FinanceGranularity,
|
||||
FinanceSummaryQueryDto,
|
||||
GenerateReportDto,
|
||||
ReportType,
|
||||
} from "./reports.dto";
|
||||
@@ -84,6 +86,33 @@ function toCsvCell(value: string | number): string {
|
||||
return `"${String(value).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets a paid-at timestamp into the requested reporting period, keyed so buckets sort
|
||||
* chronologically as plain strings. Weekly buckets are labelled by their Monday (UTC).
|
||||
*/
|
||||
function periodKeyFor(date: Date, granularity: FinanceGranularity): string {
|
||||
if (granularity === FinanceGranularity.MONTHLY) {
|
||||
return date.toISOString().slice(0, 7);
|
||||
}
|
||||
if (granularity === FinanceGranularity.WEEKLY) {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
const isoDay = d.getUTCDay() || 7; // Monday=1 .. Sunday=7
|
||||
d.setUTCDate(d.getUTCDate() - (isoDay - 1));
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
export interface FinanceBucket {
|
||||
period: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
method: string;
|
||||
bookingCount: number;
|
||||
revenueEtbMinor: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
@@ -1051,6 +1080,161 @@ export class ReportsService {
|
||||
return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows };
|
||||
}
|
||||
|
||||
// ── Finance Summary ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Revenue collected in the window, grouped by reporting period (day/week/month), origin →
|
||||
* destination station pair, and payment method — the shape finance reconciles against
|
||||
* provider settlement statements.
|
||||
*
|
||||
* Grouped by the booking's own origin/destination, not the parent Route — a route like
|
||||
* "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any
|
||||
* sub-segment of it (e.g. Lebu → Adama). Filtering by station lets finance ask about any
|
||||
* A→B pair, not just whole routes.
|
||||
*
|
||||
* Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`,
|
||||
* so a booking made in one period but paid in another lands in the period it was paid.
|
||||
*/
|
||||
async getFinanceSummary(query: FinanceSummaryQueryDto) {
|
||||
const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z");
|
||||
const dateTo = new Date(query.dateTo + "T23:59:59.999Z");
|
||||
const granularity = query.granularity ?? FinanceGranularity.DAILY;
|
||||
|
||||
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: "ETB" as any },
|
||||
orderBy: { effectiveDate: "desc" },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === "ETB") return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
};
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
paymentIntent: {
|
||||
paidAt: { gte: dateFrom, lte: dateTo },
|
||||
...(query.method ? { method: query.method } : {}),
|
||||
},
|
||||
...(query.originStationId ? { originStationId: query.originStationId } : {}),
|
||||
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
|
||||
},
|
||||
select: {
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
schedule: { select: { originStationId: true, destinationStationId: true } },
|
||||
paymentIntent: { select: { paidAt: true, method: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Booking.originStationId/destinationStationId are set on every create path (guest and
|
||||
// authenticated booking both pass them from the DTO); the schedule's own endpoints are
|
||||
// only a fallback for the rare legacy row that predates those columns.
|
||||
const stationIds = new Set<string>();
|
||||
for (const b of bookings) {
|
||||
const origin = b.originStationId ?? b.schedule.originStationId;
|
||||
const destination = b.destinationStationId ?? b.schedule.destinationStationId;
|
||||
if (origin) stationIds.add(origin);
|
||||
if (destination) stationIds.add(destination);
|
||||
}
|
||||
const stations = stationIds.size > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const stationName = new Map(stations.map((s) => [s.id, s.name]));
|
||||
|
||||
const buckets = new Map<string, FinanceBucket>();
|
||||
const bucketFor = (
|
||||
period: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
method: string,
|
||||
): FinanceBucket => {
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
};
|
||||
|
||||
for (const b of bookings) {
|
||||
const pi = b.paymentIntent!;
|
||||
const period = periodKeyFor(pi.paidAt!, granularity);
|
||||
const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN";
|
||||
const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN";
|
||||
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`;
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method);
|
||||
bucket.bookingCount += 1;
|
||||
bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency);
|
||||
}
|
||||
|
||||
const rows = [...buckets.values()].sort((a, b) =>
|
||||
a.period === b.period
|
||||
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method)
|
||||
: a.period.localeCompare(b.period),
|
||||
);
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, r) => {
|
||||
acc.bookingCount += r.bookingCount;
|
||||
acc.revenueEtbMinor += r.revenueEtbMinor;
|
||||
return acc;
|
||||
},
|
||||
{ bookingCount: 0, revenueEtbMinor: 0 },
|
||||
);
|
||||
|
||||
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => {
|
||||
const map = new Map<string, { key: string; label: string; revenueEtbMinor: number; bookingCount: number }>();
|
||||
for (const r of rows) {
|
||||
const key = keyOf(r);
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.revenueEtbMinor += r.revenueEtbMinor;
|
||||
entry.bookingCount += r.bookingCount;
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor);
|
||||
};
|
||||
|
||||
return {
|
||||
granularity,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
currency: "ETB",
|
||||
totals,
|
||||
byPeriod: rollUp((r) => r.period, (r) => r.period),
|
||||
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel),
|
||||
byMethod: rollUp((r) => r.method, (r) => r.method),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
/** CSV of the finance summary, one row per period + origin/destination segment + payment method. */
|
||||
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
|
||||
const report = await this.getFinanceSummary(query);
|
||||
|
||||
const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"];
|
||||
const rows = report.rows.map((r) => [
|
||||
r.period,
|
||||
r.segmentLabel,
|
||||
r.method,
|
||||
r.bookingCount,
|
||||
(r.revenueEtbMinor / 100).toFixed(2),
|
||||
]);
|
||||
|
||||
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
|
||||
search?: string;
|
||||
seatClass?: string;
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"html-to-image": "^1.11.11",
|
||||
"lucide-react": "^0.446.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react';
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Line, LineChart,
|
||||
ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { financeApi, type FinanceGranularity, type FinanceSummaryFilters } from '@/lib/api/finance';
|
||||
import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
|
||||
interface StationOption {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Fixed order so a method keeps its colour/slot when the method filter narrows the set.
|
||||
const PAYMENT_METHOD_ORDER = ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'CARD', 'WALLET', 'DMONEY', 'CAC_BANK', 'CBE_BILL'] as const;
|
||||
const PAYMENT_METHOD_LABELS: Record<string, string> = {
|
||||
TELEBIRR: 'Telebirr',
|
||||
CBE_BIRR: 'CBE Birr',
|
||||
EBIRR: 'eBirr',
|
||||
WAAFI: 'Waafi',
|
||||
CARD: 'Card',
|
||||
WALLET: 'Wallet',
|
||||
DMONEY: 'DMoney',
|
||||
CAC_BANK: 'CAC Bank',
|
||||
CBE_BILL: 'CBE Bill',
|
||||
};
|
||||
function methodLabel(method: string): string {
|
||||
return PAYMENT_METHOD_LABELS[method] ?? method;
|
||||
}
|
||||
|
||||
function periodLabel(period: string, granularity: FinanceGranularity): string {
|
||||
if (granularity === 'monthly') {
|
||||
return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
return new Date(`${period}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const TABLE_PAGE_SIZE = 25;
|
||||
|
||||
/** Mirrors the loaded layout's shape (KPI tiles, two charts, method breakdown, detail table) so nothing jumps when data arrives. */
|
||||
function FinanceReportSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="card flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-7 w-7 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="card">
|
||||
<Skeleton className="h-4 w-40 mb-4" />
|
||||
<Skeleton className="h-[280px] w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<Skeleton className="h-4 w-56 mb-2" />
|
||||
<Skeleton className="h-3 w-32 mb-4" />
|
||||
<Skeleton className="h-7 w-full mb-4" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<Skeleton className="h-4 w-52" />
|
||||
</div>
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinanceReportPage() {
|
||||
const isDark = useTheme((s) => s.isDark);
|
||||
const palette = getChartPalette(isDark);
|
||||
|
||||
const [dateRangePreset, setDateRangePreset] = useState('90');
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [granularity, setGranularity] = useState<FinanceGranularity>('daily');
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [method, setMethod] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const trendCardRef = useRef<HTMLDivElement>(null);
|
||||
const segmentCardRef = useRef<HTMLDivElement>(null);
|
||||
const methodCardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { dateFrom, dateTo } = useMemo(() => {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
if (dateRangePreset === 'custom') {
|
||||
if (customFrom && customTo) {
|
||||
return customFrom <= customTo
|
||||
? { dateFrom: customFrom, dateTo: customTo }
|
||||
: { dateFrom: customTo, dateTo: customFrom };
|
||||
}
|
||||
const fallbackStart = new Date(end);
|
||||
fallbackStart.setDate(end.getDate() - 90);
|
||||
return {
|
||||
dateFrom: fallbackStart.toISOString().split('T')[0],
|
||||
dateTo: end.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - Number(dateRangePreset));
|
||||
return {
|
||||
dateFrom: start.toISOString().split('T')[0],
|
||||
dateTo: end.toISOString().split('T')[0],
|
||||
};
|
||||
}, [dateRangePreset, customFrom, customTo]);
|
||||
|
||||
const filters: FinanceSummaryFilters = useMemo(
|
||||
() => ({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
granularity,
|
||||
originStationId: originStationId || undefined,
|
||||
destinationStationId: destinationStationId || undefined,
|
||||
method: method || undefined,
|
||||
}),
|
||||
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method],
|
||||
);
|
||||
|
||||
const { data: stations = [] } = useQuery<StationOption[]>({
|
||||
queryKey: ['stations'],
|
||||
queryFn: () => apiClient.get<StationOption[]>('/stations'),
|
||||
});
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
queryKey: ['reports-finance', filters],
|
||||
placeholderData: (previous) => previous,
|
||||
queryFn: () => financeApi.getSummary(filters),
|
||||
});
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const pg = usePagination(rows, TABLE_PAGE_SIZE);
|
||||
|
||||
const resetFilters = () => {
|
||||
setDateRangePreset('90');
|
||||
setCustomFrom('');
|
||||
setCustomTo('');
|
||||
setGranularity('daily');
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setMethod('');
|
||||
};
|
||||
|
||||
/** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */
|
||||
const captureCard = async (node: HTMLDivElement | null): Promise<ChartImage | undefined> => {
|
||||
if (!node) return undefined;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const dataUrl = await toPng(node, { pixelRatio: 2, cacheBust: true, backgroundColor: palette.surface });
|
||||
return { dataUrl, width: Math.round(rect.width), height: Math.round(rect.height) };
|
||||
};
|
||||
|
||||
const doExport = async () => {
|
||||
if (!data) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const [trend, segment, method_] = await Promise.all([
|
||||
captureCard(trendCardRef.current),
|
||||
captureCard(segmentCardRef.current),
|
||||
captureCard(methodCardRef.current),
|
||||
]);
|
||||
|
||||
const stationLabel = (id: string) => stations.find((s) => s.id === id)?.name ?? 'Any';
|
||||
|
||||
const blob = await buildFinanceWorkbook({
|
||||
report: data,
|
||||
filters: {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
granularity,
|
||||
originLabel: originStationId ? stationLabel(originStationId) : 'Any',
|
||||
destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any',
|
||||
methodLabel: method ? methodLabel(method) : 'All',
|
||||
},
|
||||
methodLabel,
|
||||
periodLabel,
|
||||
images: { trend, segment, method: method_ },
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `finance-summary-${dateFrom}-to-${dateTo}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trendData = useMemo(
|
||||
() =>
|
||||
(data?.byPeriod ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.map((p) => ({
|
||||
label: periodLabel(p.key, data!.granularity),
|
||||
revenue: p.revenueEtbMinor / 100,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
const segmentData = useMemo(
|
||||
() =>
|
||||
(data?.bySegment ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor)
|
||||
.map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const methodBreakdown = useMemo(() => {
|
||||
const rowsByMethod = data?.byMethod ?? [];
|
||||
const total = rowsByMethod.reduce((sum, r) => sum + r.revenueEtbMinor, 0);
|
||||
return rowsByMethod
|
||||
.slice()
|
||||
.sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any))
|
||||
.map((r) => ({
|
||||
...r,
|
||||
color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.key as any)),
|
||||
sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0,
|
||||
}));
|
||||
}, [data, palette]);
|
||||
|
||||
const totals = data?.totals;
|
||||
const hasData = (totals?.bookingCount ?? 0) > 0;
|
||||
const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Finance Summary</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Revenue collected by period, origin/destination, and payment method — for daily, weekly, or monthly finance reporting.
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton icon={FileSpreadsheet} variant="secondary" onClick={doExport} loading={exporting} disabled={!hasData}>
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div>
|
||||
<label className="label">Date Range</label>
|
||||
<select className="input" value={dateRangePreset} onChange={(e) => setDateRangePreset(e.target.value)}>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="90">Last 90 Days</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
{dateRangePreset === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input type="date" className="input" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input type="date" className="input" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Granularity</label>
|
||||
<select className="input" value={granularity} onChange={(e) => setGranularity(e.target.value as FinanceGranularity)}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Origin</label>
|
||||
<select className="input" value={originStationId} onChange={(e) => setOriginStationId(e.target.value)}>
|
||||
<option value="">Any origin</option>
|
||||
{stations.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Destination</label>
|
||||
<select className="input" value={destinationStationId} onChange={(e) => setDestinationStationId(e.target.value)}>
|
||||
<option value="">Any destination</option>
|
||||
{stations.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment Method</label>
|
||||
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
<option value="">All methods</option>
|
||||
{PAYMENT_METHOD_ORDER.map((m) => (
|
||||
<option key={m} value={m}>{methodLabel(m)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button type="button" onClick={resetFilters} className="text-xs text-primary hover:underline">
|
||||
Reset filters
|
||||
</button>
|
||||
{isFetching && <span className="text-xs text-muted-foreground">Refreshing…</span>}
|
||||
</div>
|
||||
{isError && <p className="text-xs text-red-500 mt-3">Failed to load the finance summary. Check the filters and try again.</p>}
|
||||
</div>
|
||||
|
||||
{isLoading && !data ? (
|
||||
<FinanceReportSkeleton />
|
||||
) : !hasData ? (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Banknote className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No paid bookings in this window.</p>
|
||||
<p className="text-xs mt-1">Widen the date range, or clear the origin/destination/method filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={isFetching ? 'opacity-60 transition-opacity space-y-6' : 'space-y-6'}>
|
||||
{/* KPI tiles */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
||||
<Banknote className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
|
||||
{formatCurrency(totals!.revenueEtbMinor, 'ETB')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bookings</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{totals!.bookingCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Avg. per Booking</p>
|
||||
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||
<Receipt className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{formatCurrency(avgPerBookingMinor, 'ETB')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend + route charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card" ref={trendCardRef}>
|
||||
<h3 className="text-base font-semibold mb-4">
|
||||
Revenue Trend <span className="text-xs font-normal text-muted-foreground">(ETB, {granularity})</span>
|
||||
</h3>
|
||||
{trendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<Line type="monotone" dataKey="revenue" name="Revenue" stroke={palette.sequential} dot={{ r: 3 }} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" ref={segmentCardRef}>
|
||||
<h3 className="text-base font-semibold mb-4">Revenue by Segment</h3>
|
||||
{segmentData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={segmentData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
|
||||
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<Bar dataKey="revenue" fill={palette.sequential} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method breakdown — part-to-whole stacked bar + legend table */}
|
||||
<div className="card" ref={methodCardRef}>
|
||||
<h3 className="text-base font-semibold text-foreground">Revenue by Payment Method</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">Share of revenue, in ETB</p>
|
||||
<div
|
||||
className="flex w-full h-7 rounded-md overflow-hidden"
|
||||
role="img"
|
||||
aria-label={`Revenue by payment method: ${methodBreakdown
|
||||
.map((m) => `${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`)
|
||||
.join(', ')}`}
|
||||
>
|
||||
{methodBreakdown.map((m, i) => (
|
||||
<div
|
||||
key={m.key}
|
||||
className="h-full"
|
||||
style={{ width: `${m.sharePercent}%`, background: m.color, marginRight: i < methodBreakdown.length - 1 ? 2 : 0 }}
|
||||
title={`${methodLabel(m.key)} — ${formatCurrency(m.revenueEtbMinor, 'ETB')}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium py-2">Method</th>
|
||||
<th className="text-right font-medium py-2">Bookings</th>
|
||||
<th className="text-right font-medium py-2">Share</th>
|
||||
<th className="text-right font-medium py-2">Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{methodBreakdown.map((m) => (
|
||||
<tr key={m.key}>
|
||||
<td className="py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: m.color }} aria-hidden="true" />
|
||||
<span className="text-foreground">{methodLabel(m.key)}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.bookingCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.sharePercent.toFixed(1)}%</td>
|
||||
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(m.revenueEtbMinor, 'ETB')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Detail table */}
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Period × Segment × Method detail
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pg.paged.map((r, i) => (
|
||||
<tr key={`${r.period}-${r.originStationId}-${r.destinationStationId}-${r.method}-${i}`} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-foreground">{periodLabel(r.period, granularity)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.segmentLabel}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{methodLabel(r.method)}</td>
|
||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{r.bookingCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap font-medium">{formatCurrency(r.revenueEtbMinor, 'ETB')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{pg.paged.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={pg.page} totalPages={pg.totalPages} onPageChange={pg.setPage} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
title: 'Analytics & Reports',
|
||||
items: [
|
||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
|
||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A shimmering placeholder block. Give it the size/shape of the content it stands in for. */
|
||||
export default function Skeleton({ className }: SkeletonProps) {
|
||||
return <div className={cn('skeleton', className)} aria-hidden="true" />;
|
||||
}
|
||||
65
apps/edr-passenger-web/backoffice/src/lib/api/finance.ts
Normal file
65
apps/edr-passenger-web/backoffice/src/lib/api/finance.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export type FinanceGranularity = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export interface FinanceSummaryFilters {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
granularity?: FinanceGranularity;
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export interface FinanceBucketRow {
|
||||
period: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
method: string;
|
||||
bookingCount: number;
|
||||
revenueEtbMinor: number;
|
||||
}
|
||||
|
||||
export interface FinanceRollupRow {
|
||||
key: string;
|
||||
label: string;
|
||||
revenueEtbMinor: number;
|
||||
bookingCount: number;
|
||||
}
|
||||
|
||||
export interface FinanceTotals {
|
||||
bookingCount: number;
|
||||
revenueEtbMinor: number;
|
||||
}
|
||||
|
||||
export interface FinanceSummaryReport {
|
||||
granularity: FinanceGranularity;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
currency: string;
|
||||
totals: FinanceTotals;
|
||||
byPeriod: FinanceRollupRow[];
|
||||
bySegment: FinanceRollupRow[];
|
||||
byMethod: FinanceRollupRow[];
|
||||
rows: FinanceBucketRow[];
|
||||
}
|
||||
|
||||
/** Drops blanks so the API applies its own defaults (granularity=daily, no station/method filter). */
|
||||
function toParams(filters: FinanceSummaryFilters): Record<string, string> {
|
||||
const params: Record<string, string> = { dateFrom: filters.dateFrom, dateTo: filters.dateTo };
|
||||
if (filters.granularity) params.granularity = filters.granularity;
|
||||
if (filters.originStationId) params.originStationId = filters.originStationId;
|
||||
if (filters.destinationStationId) params.destinationStationId = filters.destinationStationId;
|
||||
if (filters.method) params.method = filters.method;
|
||||
return params;
|
||||
}
|
||||
|
||||
export const financeApi = {
|
||||
getSummary: (filters: FinanceSummaryFilters) =>
|
||||
apiClient.get<FinanceSummaryReport>('/reports/finance', { params: toParams(filters) }),
|
||||
|
||||
/** CSV export. `getRaw` because the endpoint streams a bare CSV body, not the `{success,data}` envelope. */
|
||||
exportCsv: (filters: FinanceSummaryFilters) =>
|
||||
apiClient.getRaw<string>('/reports/finance/export', { params: toParams(filters) }),
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { FinanceGranularity, FinanceSummaryReport } from '@/lib/api/finance';
|
||||
|
||||
// Brand palette — rgb(20,113,76), the same green used by ActionButton's primary variant,
|
||||
// so the exported file reads as the same product as the on-screen report.
|
||||
const BRAND = 'FF14714C';
|
||||
const BRAND_DARK = 'FF0E5A3D';
|
||||
const BRAND_TINT = 'FFEAF5EF';
|
||||
const INK = 'FF1F2937';
|
||||
const MUTED = 'FF6B7280';
|
||||
const ROW_ALT = 'FFF7F8F7';
|
||||
const BORDER = 'FFE2E5E1';
|
||||
const WHITE = 'FFFFFFFF';
|
||||
|
||||
const CURRENCY_FMT = '"ETB" #,##0.00';
|
||||
const THIN_BORDER: Partial<ExcelJS.Borders> = {
|
||||
top: { style: 'thin', color: { argb: BORDER } },
|
||||
left: { style: 'thin', color: { argb: BORDER } },
|
||||
bottom: { style: 'thin', color: { argb: BORDER } },
|
||||
right: { style: 'thin', color: { argb: BORDER } },
|
||||
};
|
||||
|
||||
export interface ChartImage {
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FinanceWorkbookInput {
|
||||
report: FinanceSummaryReport;
|
||||
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string };
|
||||
methodLabel: (method: string) => string;
|
||||
periodLabel: (period: string, granularity: FinanceGranularity) => string;
|
||||
images: { trend?: ChartImage; segment?: ChartImage; method?: ChartImage };
|
||||
}
|
||||
|
||||
function styleHeaderCell(cell: ExcelJS.Cell) {
|
||||
cell.font = { bold: true, color: { argb: WHITE }, size: 11 };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||
cell.border = THIN_BORDER;
|
||||
}
|
||||
|
||||
function addTableHeader(ws: ExcelJS.Worksheet, rowIndex: number, headers: string[], alignRight: Set<number> = new Set()) {
|
||||
const row = ws.getRow(rowIndex);
|
||||
headers.forEach((h, i) => {
|
||||
const cell = row.getCell(i + 1);
|
||||
cell.value = h;
|
||||
styleHeaderCell(cell);
|
||||
if (alignRight.has(i)) cell.alignment = { vertical: 'middle', horizontal: 'right' };
|
||||
});
|
||||
row.height = 20;
|
||||
row.commit();
|
||||
}
|
||||
|
||||
function bandRow(ws: ExcelJS.Worksheet, rowIndex: number, colCount: number, isAlt: boolean) {
|
||||
const row = ws.getRow(rowIndex);
|
||||
for (let c = 1; c <= colCount; c++) {
|
||||
const cell = row.getCell(c);
|
||||
cell.border = THIN_BORDER;
|
||||
if (isAlt) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: ROW_ALT } };
|
||||
}
|
||||
}
|
||||
|
||||
function titleBanner(ws: ExcelJS.Worksheet, title: string, subtitle: string, colSpan: number) {
|
||||
ws.mergeCells(1, 1, 1, colSpan);
|
||||
const titleCell = ws.getCell(1, 1);
|
||||
titleCell.value = title;
|
||||
titleCell.font = { bold: true, size: 18, color: { argb: WHITE } };
|
||||
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
|
||||
titleCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
|
||||
ws.getRow(1).height = 34;
|
||||
for (let c = 1; c <= colSpan; c++) ws.getCell(1, c).fill = titleCell.fill;
|
||||
|
||||
ws.mergeCells(2, 1, 2, colSpan);
|
||||
const subCell = ws.getCell(2, 1);
|
||||
subCell.value = subtitle;
|
||||
subCell.font = { italic: true, size: 10, color: { argb: MUTED } };
|
||||
subCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
|
||||
ws.getRow(2).height = 18;
|
||||
}
|
||||
|
||||
function kpiCard(ws: ExcelJS.Worksheet, startRow: number, startCol: number, span: number, label: string, value: string, accent: string) {
|
||||
ws.mergeCells(startRow, startCol, startRow, startCol + span - 1);
|
||||
ws.mergeCells(startRow + 1, startCol, startRow + 1, startCol + span - 1);
|
||||
|
||||
const labelCell = ws.getCell(startRow, startCol);
|
||||
labelCell.value = label.toUpperCase();
|
||||
labelCell.font = { bold: true, size: 9, color: { argb: MUTED } };
|
||||
labelCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
|
||||
|
||||
const valueCell = ws.getCell(startRow + 1, startCol);
|
||||
valueCell.value = value;
|
||||
valueCell.font = { bold: true, size: 16, color: { argb: accent } };
|
||||
valueCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
|
||||
|
||||
for (let r = startRow; r <= startRow + 1; r++) {
|
||||
for (let c = startCol; c < startCol + span; c++) {
|
||||
const cell = ws.getCell(r, c);
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } };
|
||||
cell.border = {
|
||||
top: r === startRow ? { style: 'thin', color: { argb: BORDER } } : undefined,
|
||||
bottom: r === startRow + 1 ? { style: 'thin', color: { argb: BORDER } } : undefined,
|
||||
left: c === startCol ? { style: 'thin', color: { argb: BORDER } } : undefined,
|
||||
right: c === startCol + span - 1 ? { style: 'thin', color: { argb: BORDER } } : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
ws.getRow(startRow).height = 16;
|
||||
ws.getRow(startRow + 1).height = 26;
|
||||
}
|
||||
|
||||
function addImage(wb: ExcelJS.Workbook, ws: ExcelJS.Worksheet, image: ChartImage | undefined, anchorRow: number, heading: string) {
|
||||
const headingCell = ws.getCell(anchorRow, 1);
|
||||
headingCell.value = heading;
|
||||
headingCell.font = { bold: true, size: 12, color: { argb: INK } };
|
||||
ws.getRow(anchorRow).height = 20;
|
||||
|
||||
if (!image) {
|
||||
const emptyCell = ws.getCell(anchorRow + 1, 1);
|
||||
emptyCell.value = 'No chart available for the current filters.';
|
||||
emptyCell.font = { italic: true, size: 10, color: { argb: MUTED } };
|
||||
return anchorRow + 3;
|
||||
}
|
||||
|
||||
const maxWidth = 640;
|
||||
const scale = image.width > maxWidth ? maxWidth / image.width : 1;
|
||||
const width = Math.round(image.width * scale);
|
||||
const height = Math.round(image.height * scale);
|
||||
|
||||
const imageId = wb.addImage({ base64: image.dataUrl, extension: 'png' });
|
||||
ws.addImage(imageId, {
|
||||
tl: { col: 0.15, row: anchorRow + 0.15 },
|
||||
ext: { width, height },
|
||||
});
|
||||
|
||||
// Advance past the image height (≈20px per row) plus a spacer row.
|
||||
const rowsUsed = Math.ceil(height / 20) + 2;
|
||||
return anchorRow + rowsUsed;
|
||||
}
|
||||
|
||||
export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise<Blob> {
|
||||
const { report, filters, images } = input;
|
||||
const methodLabel = input.methodLabel;
|
||||
const periodLabel = input.periodLabel;
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'EDR Passenger Backoffice';
|
||||
wb.created = new Date();
|
||||
|
||||
// ── Summary sheet ─────────────────────────────────────────────────────────
|
||||
const summary = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] });
|
||||
summary.columns = [{ width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }];
|
||||
|
||||
titleBanner(
|
||||
summary,
|
||||
'EDR Passenger — Finance Summary',
|
||||
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Generated ${new Date().toLocaleString('en-US')}`,
|
||||
6,
|
||||
);
|
||||
|
||||
const avgPerBooking = report.totals.bookingCount > 0 ? report.totals.revenueEtbMinor / report.totals.bookingCount : 0;
|
||||
kpiCard(summary, 4, 1, 2, 'Total Revenue', `ETB ${(report.totals.revenueEtbMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, BRAND_DARK);
|
||||
kpiCard(summary, 4, 3, 2, 'Bookings', report.totals.bookingCount.toLocaleString('en-US'), INK);
|
||||
kpiCard(summary, 4, 5, 2, 'Avg. per Booking', `ETB ${(avgPerBooking / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, INK);
|
||||
|
||||
let cursor = 7;
|
||||
cursor = addImage(wb, summary, images.trend, cursor, 'Revenue Trend') + 1;
|
||||
cursor = addImage(wb, summary, images.segment, cursor, 'Revenue by Segment') + 1;
|
||||
addImage(wb, summary, images.method, cursor, 'Revenue by Payment Method');
|
||||
|
||||
// ── By Period sheet ──────────────────────────────────────────────────────
|
||||
const byPeriod = wb.addWorksheet('By Period', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
byPeriod.columns = [{ width: 18 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(byPeriod, 1, ['Period', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
|
||||
const periodRows = [...report.byPeriod].sort((a, b) => a.key.localeCompare(b.key));
|
||||
periodRows.forEach((p, i) => {
|
||||
const r = byPeriod.getRow(i + 2);
|
||||
r.getCell(1).value = periodLabel(p.key, report.granularity);
|
||||
r.getCell(2).value = p.bookingCount;
|
||||
r.getCell(2).alignment = { horizontal: 'right' };
|
||||
r.getCell(3).value = p.revenueEtbMinor / 100;
|
||||
r.getCell(3).numFmt = CURRENCY_FMT;
|
||||
r.getCell(3).alignment = { horizontal: 'right' };
|
||||
bandRow(byPeriod, i + 2, 3, i % 2 === 1);
|
||||
});
|
||||
byPeriod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } };
|
||||
|
||||
// ── By Segment sheet ─────────────────────────────────────────────────────
|
||||
const bySegment = wb.addWorksheet('By Segment', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
bySegment.columns = [{ width: 34 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(bySegment, 1, ['Origin → Destination', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
|
||||
report.bySegment.forEach((s, i) => {
|
||||
const r = bySegment.getRow(i + 2);
|
||||
r.getCell(1).value = s.label;
|
||||
r.getCell(2).value = s.bookingCount;
|
||||
r.getCell(2).alignment = { horizontal: 'right' };
|
||||
r.getCell(3).value = s.revenueEtbMinor / 100;
|
||||
r.getCell(3).numFmt = CURRENCY_FMT;
|
||||
r.getCell(3).alignment = { horizontal: 'right' };
|
||||
bandRow(bySegment, i + 2, 3, i % 2 === 1);
|
||||
});
|
||||
bySegment.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } };
|
||||
|
||||
// ── By Method sheet ──────────────────────────────────────────────────────
|
||||
const byMethod = wb.addWorksheet('By Method', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
byMethod.columns = [{ width: 20 }, { width: 14 }, { width: 20 }, { width: 12 }];
|
||||
addTableHeader(byMethod, 1, ['Payment Method', 'Bookings', 'Revenue (ETB)', 'Share'], new Set([1, 2, 3]));
|
||||
const methodTotal = report.byMethod.reduce((sum, m) => sum + m.revenueEtbMinor, 0);
|
||||
report.byMethod.forEach((m, i) => {
|
||||
const r = byMethod.getRow(i + 2);
|
||||
r.getCell(1).value = methodLabel(m.key);
|
||||
r.getCell(2).value = m.bookingCount;
|
||||
r.getCell(2).alignment = { horizontal: 'right' };
|
||||
r.getCell(3).value = m.revenueEtbMinor / 100;
|
||||
r.getCell(3).numFmt = CURRENCY_FMT;
|
||||
r.getCell(3).alignment = { horizontal: 'right' };
|
||||
r.getCell(4).value = methodTotal > 0 ? m.revenueEtbMinor / methodTotal : 0;
|
||||
r.getCell(4).numFmt = '0.0%';
|
||||
r.getCell(4).alignment = { horizontal: 'right' };
|
||||
bandRow(byMethod, i + 2, 4, i % 2 === 1);
|
||||
});
|
||||
byMethod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } };
|
||||
|
||||
// ── Detail sheet — every row, unpaginated ───────────────────────────────
|
||||
const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
detail.columns = [{ width: 18 }, { width: 34 }, { width: 18 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Bookings', 'Revenue (ETB)'], new Set([2, 3]));
|
||||
report.rows.forEach((row, i) => {
|
||||
const r = detail.getRow(i + 2);
|
||||
r.getCell(1).value = periodLabel(row.period, report.granularity);
|
||||
r.getCell(2).value = row.segmentLabel;
|
||||
r.getCell(3).value = methodLabel(row.method);
|
||||
r.getCell(4).value = row.bookingCount;
|
||||
r.getCell(4).alignment = { horizontal: 'right' };
|
||||
r.getCell(5).value = row.revenueEtbMinor / 100;
|
||||
r.getCell(5).numFmt = CURRENCY_FMT;
|
||||
r.getCell(5).alignment = { horizontal: 'right' };
|
||||
bandRow(detail, i + 2, 5, i % 2 === 1);
|
||||
});
|
||||
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
@@ -68,6 +68,24 @@
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: -300px 0; }
|
||||
to { background-position: 300px 0; }
|
||||
}
|
||||
.skeleton {
|
||||
border-radius: 0.5rem;
|
||||
background-color: hsl(var(--muted));
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--muted)) 0%,
|
||||
hsl(var(--muted-foreground) / 0.18) 50%,
|
||||
hsl(var(--muted)) 100%
|
||||
);
|
||||
background-size: 600px 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
293
pnpm-lock.yaml
generated
293
pnpm-lock.yaml
generated
@@ -604,7 +604,7 @@ importers:
|
||||
version: 5.101.0(react@19.2.6)
|
||||
'@tria-plc/iamui':
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
'@vis.gl/react-google-maps':
|
||||
specifier: ^1.8.3
|
||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -983,6 +983,12 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^3.0.0
|
||||
version: 3.6.0
|
||||
exceljs:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
html-to-image:
|
||||
specifier: ^1.11.11
|
||||
version: 1.11.13
|
||||
lucide-react:
|
||||
specifier: ^0.446.0
|
||||
version: 0.446.0(react@18.3.1)
|
||||
@@ -8338,6 +8344,9 @@ packages:
|
||||
resolution: {integrity: sha512-XxzooSo6oBoxBEUazgjdXj7VwTn/iSTSZzTYKzYY6I916tkaYzypHxy+pbVU1h+0UQ9JlVf5XkNQyxOAiiQO1g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
@@ -13063,11 +13072,11 @@ snapshots:
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -13102,7 +13111,7 @@ snapshots:
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13111,14 +13120,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-member-expression-to-functions@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13133,9 +13135,9 @@ snapshots:
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -13150,13 +13152,13 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13309,18 +13311,6 @@ snapshots:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -13805,7 +13795,7 @@ snapshots:
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/runtime': 7.29.7
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
@@ -13971,7 +13961,7 @@ snapshots:
|
||||
'@eslint/eslintrc@2.1.4':
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
espree: 9.6.1
|
||||
globals: 13.24.0
|
||||
ignore: 5.3.2
|
||||
@@ -14131,7 +14121,7 @@ snapshots:
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -15730,7 +15720,7 @@ snapshots:
|
||||
|
||||
'@puppeteer/browsers@2.13.2':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
extract-zip: 2.0.1
|
||||
progress: 2.0.3
|
||||
proxy-agent: 6.5.0
|
||||
@@ -17804,7 +17794,7 @@ snapshots:
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18103,130 +18093,6 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
|
||||
dependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
|
||||
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf/renderer': 4.5.1(react@19.2.6)
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
'@tanstack/react-query': 5.101.0(react@19.2.6)
|
||||
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
|
||||
'@types/dompurify': 3.2.0
|
||||
'@types/node': 24.13.1
|
||||
'@types/tinymce': 4.6.9
|
||||
axios: 1.17.0
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
date-fns: 3.6.0
|
||||
dayjs: 1.11.21
|
||||
dompurify: 3.4.8
|
||||
ethiopian-calendar-date-converter: 2.1.6
|
||||
ethiopian-calendar-new: 1.1.0
|
||||
file-type: 18.7.0
|
||||
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
html2canvas: 1.4.1
|
||||
i18next: 25.10.10(typescript@5.9.3)
|
||||
i18next-browser-languagedetector: 8.2.1
|
||||
jquery: 3.7.1
|
||||
js-cookie: 3.0.8
|
||||
jspdf: 3.0.4
|
||||
lodash: 4.18.1
|
||||
lucide-react: 0.513.0(react@19.2.6)
|
||||
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
|
||||
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
path: 0.12.7
|
||||
pdf-lib: 1.17.1
|
||||
qs: 6.15.2
|
||||
react: 19.2.6
|
||||
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
|
||||
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-dropzone: 14.4.1(react@19.2.6)
|
||||
react-hook-form: 7.77.0(react@19.2.6)
|
||||
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
react-icons: 5.6.0(react@19.2.6)
|
||||
react-image-crop: 11.0.10(react@19.2.6)
|
||||
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
|
||||
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
|
||||
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
|
||||
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
|
||||
socket.io-client: 4.8.3
|
||||
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
tailwind-merge: 3.6.0
|
||||
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
|
||||
tailwindcss: 4.3.0
|
||||
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
|
||||
tinymce: 7.9.3
|
||||
url: 0.11.4
|
||||
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
xlsx: 0.18.5
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@emotion/is-prop-valid'
|
||||
- '@mui/icons-material'
|
||||
- '@mui/material'
|
||||
- '@mui/x-date-pickers'
|
||||
- '@types/prop-types'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- bufferutil
|
||||
- debug
|
||||
- pdfjs-dist
|
||||
- prop-types
|
||||
- react-is
|
||||
- react-native
|
||||
- redux
|
||||
- rolldown
|
||||
- rollup
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
@@ -18624,7 +18490,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -18634,7 +18500,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18653,7 +18519,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
@@ -18668,7 +18534,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.2
|
||||
tinyglobby: 0.2.17
|
||||
@@ -18957,7 +18823,7 @@ snapshots:
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -19471,16 +19337,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
|
||||
dependencies:
|
||||
'@babel/helper-annotate-as-pure': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
||||
picomatch: 4.0.4
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-polyfill@6.26.0:
|
||||
dependencies:
|
||||
babel-runtime: 6.26.0
|
||||
@@ -19636,7 +19492,7 @@ snapshots:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
@@ -20685,7 +20541,7 @@ snapshots:
|
||||
engine.io-client@6.6.5:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.20.1
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
@@ -20705,7 +20561,7 @@ snapshots:
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
@@ -20936,7 +20792,7 @@ snapshots:
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
get-tsconfig: 4.14.0
|
||||
is-bun-module: 2.0.0
|
||||
@@ -21064,7 +20920,7 @@ snapshots:
|
||||
ajv: 6.15.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
doctrine: 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 7.2.2
|
||||
@@ -21301,7 +21157,7 @@ snapshots:
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
@@ -21354,7 +21210,7 @@ snapshots:
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
@@ -21509,7 +21365,7 @@ snapshots:
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
@@ -21757,7 +21613,7 @@ snapshots:
|
||||
dependencies:
|
||||
basic-ftp: 5.3.1
|
||||
data-uri-to-buffer: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22046,6 +21902,8 @@ snapshots:
|
||||
is-self-closing: 1.0.1
|
||||
kind-of: 6.0.3
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
@@ -22064,7 +21922,7 @@ snapshots:
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22077,14 +21935,14 @@ snapshots:
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22524,7 +22382,7 @@ snapshots:
|
||||
|
||||
istanbul-lib-source-maps@4.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
source-map: 0.6.1
|
||||
transitivePeerDependencies:
|
||||
@@ -23184,7 +23042,7 @@ snapshots:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
commander: 13.1.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
execa: 8.0.1
|
||||
lilconfig: 3.1.3
|
||||
listr2: 8.3.3
|
||||
@@ -23871,7 +23729,7 @@ snapshots:
|
||||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
@@ -24397,7 +24255,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-uri: 6.0.5
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
@@ -24747,7 +24605,7 @@ snapshots:
|
||||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
@@ -24776,7 +24634,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@puppeteer/browsers': 2.13.2
|
||||
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
devtools-protocol: 0.0.1608973
|
||||
typed-query-selector: 2.12.2
|
||||
webdriver-bidi-protocol: 0.4.1
|
||||
@@ -25029,15 +24887,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
||||
dependencies:
|
||||
date-fns: 3.6.0
|
||||
@@ -25648,7 +25497,7 @@ snapshots:
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
@@ -25770,7 +25619,7 @@ snapshots:
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -25986,7 +25835,7 @@ snapshots:
|
||||
|
||||
socket.io-adapter@2.5.8:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -25996,7 +25845,7 @@ snapshots:
|
||||
socket.io-client@4.8.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-client: 6.6.5
|
||||
socket.io-parser: 4.2.6
|
||||
transitivePeerDependencies:
|
||||
@@ -26007,7 +25856,7 @@ snapshots:
|
||||
socket.io-parser@4.2.6:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -26016,7 +25865,7 @@ snapshots:
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io: 6.6.9
|
||||
socket.io-adapter: 2.5.8
|
||||
socket.io-parser: 4.2.6
|
||||
@@ -26028,7 +25877,7 @@ snapshots:
|
||||
socks-proxy-agent@8.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
socks: 2.8.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -26323,24 +26172,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
'@emotion/stylis': 0.8.5
|
||||
'@emotion/unitless': 0.7.5
|
||||
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
|
||||
css-to-react-native: 3.2.0
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-is: 19.2.7
|
||||
shallowequal: 1.1.0
|
||||
supports-color: 5.5.0
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -26366,7 +26197,7 @@ snapshots:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.5
|
||||
formidable: 3.5.4
|
||||
@@ -26879,7 +26710,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -26903,7 +26734,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -27264,7 +27095,7 @@ snapshots:
|
||||
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
@@ -27282,7 +27113,7 @@ snapshots:
|
||||
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
@@ -27329,7 +27160,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
@@ -27365,7 +27196,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
||||
Reference in New Issue
Block a user