feat(proj): init
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
import { RequireAuth } from "./auth/RequireAuth";
|
||||
import { TelegramLinkBridge } from "./auth/TelegramLinkBridge";
|
||||
import { CabinetLayout } from "./cabinet/CabinetLayout";
|
||||
import { CookieBanner } from "./legal/CookieBanner";
|
||||
import { LegalPage } from "./legal/LegalPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { OverviewPage } from "./pages/OverviewPage";
|
||||
import { BudgetsPage } from "./pages/BudgetsPage";
|
||||
import { OperationsPage } from "./pages/OperationsPage";
|
||||
import { JournalPage } from "./pages/JournalPage";
|
||||
import { PeriodPage } from "./pages/PeriodPage";
|
||||
import { WorkPage } from "./pages/WorkPage";
|
||||
import { CalendarPage } from "./pages/CalendarPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<TelegramLinkBridge />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/legal/:slug" element={<LegalPage />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<CabinetLayout />}>
|
||||
<Route index element={<OverviewPage />} />
|
||||
<Route path="budgets" element={<BudgetsPage />} />
|
||||
<Route path="work" element={<WorkPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="operations" element={<OperationsPage />} />
|
||||
<Route path="journal" element={<JournalPage />} />
|
||||
<Route path="period" element={<PeriodPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<CookieBanner />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { isNativeAppWebView } from "./auth/telegramRedirect";
|
||||
import type { TelegramLoginPayload } from "./types";
|
||||
import { TELEGRAM_BOT_USERNAME } from "./api";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
onTelegramAuth?: (user: TelegramLoginPayload) => void;
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onAuth: (user: TelegramLoginPayload) => void;
|
||||
};
|
||||
|
||||
export function TelegramLoginButton({ onAuth }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!TELEGRAM_BOT_USERNAME || !containerRef.current) return;
|
||||
|
||||
window.onTelegramAuth = (user) => {
|
||||
onAuth(user);
|
||||
};
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||
script.async = true;
|
||||
script.setAttribute("data-telegram-login", TELEGRAM_BOT_USERNAME);
|
||||
script.setAttribute("data-size", "large");
|
||||
script.setAttribute("data-radius", "10");
|
||||
script.setAttribute("data-request-access", "write");
|
||||
|
||||
// In the mobile WebView popups / iframe callbacks are unreliable.
|
||||
// Redirect mode brings the signed payload back as query params on /login.
|
||||
if (isNativeAppWebView()) {
|
||||
script.setAttribute("data-auth-url", `${window.location.origin}/login`);
|
||||
} else {
|
||||
script.setAttribute("data-onauth", "onTelegramAuth(user)");
|
||||
}
|
||||
|
||||
containerRef.current.innerHTML = "";
|
||||
containerRef.current.appendChild(script);
|
||||
|
||||
return () => {
|
||||
delete window.onTelegramAuth;
|
||||
};
|
||||
}, [onAuth]);
|
||||
|
||||
if (!TELEGRAM_BOT_USERNAME) {
|
||||
return (
|
||||
<p className="error">
|
||||
Не задан VITE_TELEGRAM_BOT_USERNAME — кнопка входа недоступна.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="tg-login" ref={containerRef} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { yandexAuthorizeUrl } from "./auth/yandexRedirect";
|
||||
|
||||
type Props = {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state?: string | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function YandexLoginButton({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const href = yandexAuthorizeUrl(clientId, redirectUri, state);
|
||||
const className = `app-btn app-btn--large app-btn--expanded btn--yandex${disabled ? " is-disabled" : ""}`;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={className} aria-disabled="true">
|
||||
<span className="btn--yandex__mark" aria-hidden="true">
|
||||
Я
|
||||
</span>
|
||||
Войти через Яндекс
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a className={className} href={href}>
|
||||
<span className="btn--yandex__mark" aria-hidden="true">
|
||||
Я
|
||||
</span>
|
||||
Войти через Яндекс
|
||||
</a>
|
||||
);
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
import { postTokenToNativeApp } from "./auth/telegramRedirect";
|
||||
import type {
|
||||
AuthProviders,
|
||||
AuthSession,
|
||||
AuthUser,
|
||||
BudgetStatus,
|
||||
BudgetsList,
|
||||
ExpensesPage,
|
||||
ExpensesRange,
|
||||
Job,
|
||||
JobsList,
|
||||
TelegramLoginPayload,
|
||||
} from "./types";
|
||||
|
||||
export const SESSION_KEY = "ppm_session_jwt";
|
||||
const USER_KEY = "ppm_session_user";
|
||||
|
||||
const API_BASE = (
|
||||
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const TELEGRAM_BOT_USERNAME = (
|
||||
import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined
|
||||
)?.replace(/^@/, "") ?? "";
|
||||
|
||||
export function getAccessToken(): string {
|
||||
return localStorage.getItem(SESSION_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function getStoredUser(): AuthUser | null {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as AuthUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession): void {
|
||||
localStorage.setItem(SESSION_KEY, session.access_token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(session.user));
|
||||
// Mobile WebView listens on this channel; no-op in a regular browser.
|
||||
postTokenToNativeApp(session.access_token);
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getAccessToken();
|
||||
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
if (!headers.has("Content-Type") && init.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
const raw = await response.text();
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (response.status === 401) {
|
||||
clearSession();
|
||||
throw new Error("Сессия истекла. Войдите снова.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { detail?: string };
|
||||
throw new Error(parsed.detail || raw || `HTTP ${response.status}`);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message !== raw) throw err;
|
||||
throw new Error(raw || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 204 || raw.trim() === "") {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error(
|
||||
`Ожидался JSON с API, а пришло не JSON (${contentType || "без content-type"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
throw new Error("Ответ API не удалось разобрать как JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithTelegram(
|
||||
payload: TelegramLoginPayload,
|
||||
): Promise<AuthSession> {
|
||||
const session = await apiFetch<AuthSession>("/api/auth/telegram", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function fetchAuthProviders(): Promise<AuthProviders> {
|
||||
return apiFetch<AuthProviders>("/api/auth/providers");
|
||||
}
|
||||
|
||||
export async function loginWithYandex(
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
): Promise<AuthSession> {
|
||||
const session = await apiFetch<AuthSession>("/api/auth/yandex", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code, redirect_uri: redirectUri }),
|
||||
});
|
||||
setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function completeTelegramLink(token: string): Promise<void> {
|
||||
await apiFetch<{ linked: boolean }>("/api/auth/telegram-link/complete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<AuthUser> {
|
||||
return apiFetch<AuthUser>("/api/me");
|
||||
}
|
||||
|
||||
export async function fetchMyBudget(budgetId?: number): Promise<BudgetStatus> {
|
||||
const params = new URLSearchParams();
|
||||
if (budgetId != null) params.set("budget_id", String(budgetId));
|
||||
const qs = params.toString();
|
||||
return apiFetch<BudgetStatus>(`/api/me/budget${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export async function fetchMyBudgets(): Promise<BudgetsList> {
|
||||
return apiFetch<BudgetsList>("/api/me/budgets");
|
||||
}
|
||||
|
||||
export async function fetchMyExpenses(
|
||||
page: number,
|
||||
pageSize = 20,
|
||||
budgetId?: number | null,
|
||||
options?: { all?: boolean },
|
||||
): Promise<ExpensesPage> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
if (options?.all) {
|
||||
params.set("all", "true");
|
||||
} else if (budgetId != null) {
|
||||
params.set("budget_id", String(budgetId));
|
||||
}
|
||||
return apiFetch<ExpensesPage>(`/api/me/expenses?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchMyExpensesRange(
|
||||
from: string,
|
||||
to: string,
|
||||
budgetId?: number,
|
||||
): Promise<ExpensesRange> {
|
||||
const params = new URLSearchParams({ from, to });
|
||||
if (budgetId != null) params.set("budget_id", String(budgetId));
|
||||
return apiFetch<ExpensesRange>(`/api/me/expenses/range?${params}`);
|
||||
}
|
||||
|
||||
export async function createMyExpense(input: {
|
||||
amount: number;
|
||||
note?: string;
|
||||
spent_at?: string;
|
||||
budget_id?: number;
|
||||
}): Promise<BudgetStatus> {
|
||||
return apiFetch<BudgetStatus>("/api/me/expenses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
amount: input.amount,
|
||||
note: input.note || null,
|
||||
spent_at: input.spent_at || null,
|
||||
budget_id: input.budget_id ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function undoMyLastExpense(budgetId?: number): Promise<{
|
||||
deleted_amount: number;
|
||||
status: BudgetStatus;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (budgetId != null) params.set("budget_id", String(budgetId));
|
||||
const qs = params.toString();
|
||||
return apiFetch(`/api/me/expenses/last${qs ? `?${qs}` : ""}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMyBudget(input: {
|
||||
name: string;
|
||||
total_amount: number;
|
||||
end_date: string;
|
||||
start_date?: string;
|
||||
is_active?: boolean;
|
||||
select?: boolean;
|
||||
}): Promise<BudgetStatus> {
|
||||
return apiFetch<BudgetStatus>("/api/me/budgets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
total_amount: input.total_amount,
|
||||
end_date: input.end_date,
|
||||
start_date: input.start_date || null,
|
||||
is_active: input.is_active ?? true,
|
||||
select: input.select ?? true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMyBudget(
|
||||
budgetId: number,
|
||||
input: {
|
||||
name?: string;
|
||||
total_amount?: number;
|
||||
end_date?: string;
|
||||
start_date?: string;
|
||||
reset_expenses?: boolean;
|
||||
},
|
||||
): Promise<BudgetStatus> {
|
||||
return apiFetch<BudgetStatus>(`/api/me/budgets/${budgetId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setMyBudgetActive(
|
||||
budgetId: number,
|
||||
isActive: boolean,
|
||||
): Promise<BudgetStatus> {
|
||||
return apiFetch<BudgetStatus>(`/api/me/budgets/${budgetId}/active`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ is_active: isActive }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMyBudget(budgetId: number): Promise<void> {
|
||||
await apiFetch<void>(`/api/me/budgets/${budgetId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function selectMyBudget(budgetId: number): Promise<BudgetStatus> {
|
||||
return apiFetch<BudgetStatus>(`/api/me/budgets/${budgetId}/select`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertMyBudget(input: {
|
||||
total_amount: number;
|
||||
end_date: string;
|
||||
reset_expenses: boolean;
|
||||
name?: string;
|
||||
budget_id?: number;
|
||||
}): Promise<BudgetStatus> {
|
||||
return apiFetch<BudgetStatus>("/api/me/budget", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMyJobs(): Promise<JobsList> {
|
||||
return apiFetch<JobsList>("/api/me/jobs");
|
||||
}
|
||||
|
||||
export async function createMyJob(input: {
|
||||
name: string;
|
||||
salary_amount: number;
|
||||
pay_days: number[];
|
||||
first_pay_percent: number;
|
||||
weekend_policy: "before_weekend" | "after_weekend";
|
||||
is_active?: boolean;
|
||||
}): Promise<Job> {
|
||||
return apiFetch<Job>("/api/me/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
salary_amount: input.salary_amount,
|
||||
pay_days: input.pay_days,
|
||||
first_pay_percent: input.first_pay_percent,
|
||||
weekend_policy: input.weekend_policy,
|
||||
is_active: input.is_active ?? true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMyJob(
|
||||
jobId: number,
|
||||
input: {
|
||||
name: string;
|
||||
salary_amount: number;
|
||||
pay_days: number[];
|
||||
first_pay_percent: number;
|
||||
weekend_policy: "before_weekend" | "after_weekend";
|
||||
is_active?: boolean;
|
||||
},
|
||||
): Promise<Job> {
|
||||
return apiFetch<Job>(`/api/me/jobs/${jobId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
salary_amount: input.salary_amount,
|
||||
pay_days: input.pay_days,
|
||||
first_pay_percent: input.first_pay_percent,
|
||||
weekend_policy: input.weekend_policy,
|
||||
is_active: input.is_active ?? true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMyJob(jobId: number): Promise<void> {
|
||||
await apiFetch<void>(`/api/me/jobs/${jobId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function formatMoney(amount: number, currency = "RUB"): string {
|
||||
const symbol = currency === "RUB" ? "₽" : currency;
|
||||
return `${amount.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${symbol}`;
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
const [y, m, d] = iso.slice(0, 10).split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
export function displayName(user: AuthUser | null): string {
|
||||
if (!user) return "Пользователь";
|
||||
if (user.username) return `@${user.username}`;
|
||||
const full = [user.first_name, user.last_name].filter(Boolean).join(" ");
|
||||
return full || `user ${user.user_id}`;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
clearSession,
|
||||
displayName,
|
||||
getAccessToken,
|
||||
getStoredUser,
|
||||
loginWithTelegram,
|
||||
loginWithYandex,
|
||||
} from "../api";
|
||||
import type { AuthUser, TelegramLoginPayload } from "../types";
|
||||
import { isYandexUserId } from "./yandexIdentity";
|
||||
|
||||
type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
login: (payload: TelegramLoginPayload) => Promise<void>;
|
||||
loginYandex: (code: string, redirectUri: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
userLabel: string;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||
if (!getAccessToken()) return null;
|
||||
const stored = getStoredUser();
|
||||
if (!stored || !isYandexUserId(stored.user_id)) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
return stored;
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const login = useCallback(async (payload: TelegramLoginPayload) => {
|
||||
try {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
await loginWithTelegram(payload);
|
||||
clearSession();
|
||||
throw new Error("Вход через Telegram отключён. Войдите через Яндекс.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка входа");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loginYandex = useCallback(async (code: string, redirectUri: string) => {
|
||||
try {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const session = await loginWithYandex(code, redirectUri);
|
||||
if (!isYandexUserId(session.user.user_id)) {
|
||||
clearSession();
|
||||
throw new Error("Войдите через Яндекс");
|
||||
}
|
||||
setUser(session.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка входа через Яндекс");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearSession();
|
||||
setUser(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
busy,
|
||||
error,
|
||||
login,
|
||||
loginYandex,
|
||||
logout,
|
||||
userLabel: user ? displayName(user) : "",
|
||||
}),
|
||||
[user, busy, error, login, loginYandex, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { AppShell } from "../components/layout/AppShell";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { yandexCodeFromQuery, yandexErrorFromQuery } from "./yandexRedirect";
|
||||
|
||||
export function RequireAuth() {
|
||||
const { user, logout, userLabel } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (!user) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const oauthCallback =
|
||||
yandexCodeFromQuery(location.search) || yandexErrorFromQuery(location.search);
|
||||
const keepQuery = Boolean(oauthCallback || params.get("tg_link"));
|
||||
return (
|
||||
<Navigate
|
||||
to={keepQuery ? `/login${location.search}` : "/login"}
|
||||
replace
|
||||
state={{ from: location }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell userLabel={userLabel} onLogout={logout}>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { completeTelegramLink } from "../api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import {
|
||||
captureTelegramLinkToken,
|
||||
clearTelegramLinkToken,
|
||||
peekTelegramLinkToken,
|
||||
} from "./telegramLink";
|
||||
|
||||
export function TelegramLinkBridge() {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
captureTelegramLinkToken(location.search);
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const token = peekTelegramLinkToken();
|
||||
if (!token) return;
|
||||
|
||||
let cancelled = false;
|
||||
void completeTelegramLink(token)
|
||||
.then(() => {
|
||||
if (!cancelled) clearTelegramLinkToken();
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the token: user can retry from the bot link.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const STORAGE_KEY = "ppm_tg_link";
|
||||
const TOKEN_RE = /^[a-f0-9]{64}$/i;
|
||||
|
||||
function readStored(): string | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY)?.trim() ?? "";
|
||||
return TOKEN_RE.test(raw) ? raw.toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function peekTelegramLinkToken(): string | null {
|
||||
return readStored();
|
||||
}
|
||||
|
||||
export function captureTelegramLinkToken(search: string): string | null {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
const fromQuery = params.get("tg_link")?.trim() ?? "";
|
||||
const fromState = params.get("state")?.trim() ?? "";
|
||||
const candidate = TOKEN_RE.test(fromQuery)
|
||||
? fromQuery
|
||||
: TOKEN_RE.test(fromState)
|
||||
? fromState
|
||||
: "";
|
||||
|
||||
if (candidate) {
|
||||
const token = candidate.toLowerCase();
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, token);
|
||||
} catch {
|
||||
// Private mode / blocked storage — still return the token for this render.
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
return readStored();
|
||||
}
|
||||
|
||||
export function clearTelegramLinkToken(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TelegramLoginPayload } from "../types";
|
||||
|
||||
/**
|
||||
* Telegram Login Widget in redirect mode (`data-auth-url`) appends the
|
||||
* signed user payload as query parameters. Used by the in-app WebView so
|
||||
* we never depend on a popup / iframe callback.
|
||||
*/
|
||||
export function telegramPayloadFromQuery(
|
||||
search: string,
|
||||
): TelegramLoginPayload | null {
|
||||
const params = new URLSearchParams(
|
||||
search.startsWith("?") ? search.slice(1) : search,
|
||||
);
|
||||
|
||||
const id = Number(params.get("id"));
|
||||
const hash = params.get("hash");
|
||||
const firstName = params.get("first_name");
|
||||
const authDate = Number(params.get("auth_date"));
|
||||
|
||||
if (!hash || !firstName || !Number.isFinite(id) || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
first_name: firstName,
|
||||
last_name: params.get("last_name") || undefined,
|
||||
username: params.get("username") || undefined,
|
||||
photo_url: params.get("photo_url") || undefined,
|
||||
auth_date: Number.isFinite(authDate) ? authDate : 0,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flutter injects this JavaScript channel into the cabinet WebView. */
|
||||
export function postTokenToNativeApp(token: string): void {
|
||||
const bridge = (
|
||||
window as Window & { PpmAuth?: { postMessage: (message: string) => void } }
|
||||
).PpmAuth;
|
||||
|
||||
if (!bridge) return;
|
||||
|
||||
try {
|
||||
bridge.postMessage(JSON.stringify({ token }));
|
||||
} catch {
|
||||
// The channel disappears when the WebView is closing — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativeAppWebView(): boolean {
|
||||
return typeof (window as Window & { PpmAuth?: unknown }).PpmAuth !== "undefined";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Same bit as `YandexIdentity.NamespaceBit` on the API (`1 << 50`). */
|
||||
const YANDEX_NAMESPACE_BIT = 1n << 50n;
|
||||
|
||||
export function isYandexUserId(userId: number): boolean {
|
||||
try {
|
||||
return (BigInt(Math.trunc(userId)) & YANDEX_NAMESPACE_BIT) === YANDEX_NAMESPACE_BIT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const YANDEX_AUTHORIZE = "https://oauth.yandex.ru/authorize";
|
||||
|
||||
export function yandexCodeFromQuery(search: string): string | null {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
const code = params.get("code")?.trim();
|
||||
if (!code) return null;
|
||||
// Telegram login also lands on /login with id/hash — never treat that as Yandex.
|
||||
if (params.has("hash") && params.has("id")) return null;
|
||||
return code;
|
||||
}
|
||||
|
||||
export function yandexErrorFromQuery(search: string): string | null {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
if (params.has("hash") && params.has("id")) return null;
|
||||
return params.get("error_description")?.trim() || params.get("error")?.trim() || null;
|
||||
}
|
||||
|
||||
export function yandexAuthorizeUrl(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
state?: string | null,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
force_confirm: "yes",
|
||||
});
|
||||
if (state) params.set("state", state);
|
||||
return `${YANDEX_AUTHORIZE}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Must match the Callback URL registered in the Yandex app (trailing slash). */
|
||||
export function yandexRedirectUri(
|
||||
configured?: string | null,
|
||||
origin = window.location.origin,
|
||||
): string {
|
||||
const local = `${origin.replace(/\/$/, "")}/`;
|
||||
if (!configured) return local;
|
||||
try {
|
||||
if (new URL(configured).origin === new URL(local).origin) return configured;
|
||||
} catch {
|
||||
// Fall through to the current origin.
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
/** @deprecated use yandexRedirectUri */
|
||||
export function cabinetLoginRedirectUri(origin = window.location.origin): string {
|
||||
return yandexRedirectUri(null, origin);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const APP_NAME = "Дожить до ЗП";
|
||||
|
||||
/** Served from `web/public`. Copy the release APK here before `npm run build`. */
|
||||
export const APK_DOWNLOAD_HREF = "/dozhit-do-zp.apk";
|
||||
export const APK_DOWNLOAD_NAME = "dozhit-do-zp.apk";
|
||||
@@ -0,0 +1,509 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
createMyBudget,
|
||||
createMyExpense,
|
||||
deleteMyBudget,
|
||||
fetchMyBudget,
|
||||
fetchMyBudgets,
|
||||
fetchMyExpenses,
|
||||
formatMoney,
|
||||
selectMyBudget,
|
||||
setMyBudgetActive,
|
||||
undoMyLastExpense,
|
||||
updateMyBudget,
|
||||
} from "../api";
|
||||
import { todayIso } from "../lib/date";
|
||||
import type { BudgetStatus, ExpensesPage } from "../types";
|
||||
|
||||
const SELECTED_KEY = "ppm_selected_budget_id";
|
||||
|
||||
export type JournalScope = "all" | "current";
|
||||
|
||||
type CabinetContextValue = {
|
||||
budgets: BudgetStatus[];
|
||||
status: BudgetStatus | null;
|
||||
expenses: ExpensesPage | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
noBudget: boolean;
|
||||
error: string | null;
|
||||
notice: string | null;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
journalScope: JournalScope;
|
||||
setJournalScope: (scope: JournalScope) => void;
|
||||
selectedBudgetId: number | null;
|
||||
selectBudget: (budgetId: number) => Promise<void>;
|
||||
toggleBudgetActive: (budgetId: number, isActive: boolean) => Promise<void>;
|
||||
deleteBudget: (budgetId: number) => Promise<void>;
|
||||
amount: string;
|
||||
setAmount: (v: string) => void;
|
||||
note: string;
|
||||
setNote: (v: string) => void;
|
||||
spentAt: string;
|
||||
setSpentAt: (v: string) => void;
|
||||
expenseBudgetId: number | null;
|
||||
setExpenseBudgetId: (id: number | null) => void;
|
||||
budgetName: string;
|
||||
setBudgetName: (v: string) => void;
|
||||
budgetAmount: string;
|
||||
setBudgetAmount: (v: string) => void;
|
||||
budgetStart: string;
|
||||
setBudgetStart: (v: string) => void;
|
||||
budgetEnd: string;
|
||||
setBudgetEnd: (v: string) => void;
|
||||
resetExpenses: boolean;
|
||||
setResetExpenses: (v: boolean) => void;
|
||||
createMode: boolean;
|
||||
setCreateMode: (v: boolean) => void;
|
||||
reload: () => Promise<void>;
|
||||
onAddExpense: (event: FormEvent) => Promise<void>;
|
||||
onUndo: () => Promise<void>;
|
||||
onSaveBudget: (event: FormEvent) => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
};
|
||||
|
||||
const CabinetContext = createContext<CabinetContextValue | null>(null);
|
||||
|
||||
function readStoredBudgetId(): number | null {
|
||||
const raw = localStorage.getItem(SELECTED_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function CabinetProvider({ children }: { children: ReactNode }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = Number(searchParams.get("page") ?? "0") || 0;
|
||||
const journalScope: JournalScope =
|
||||
searchParams.get("scope") === "current" ? "current" : "all";
|
||||
const journalScopeRef = useRef(journalScope);
|
||||
journalScopeRef.current = journalScope;
|
||||
|
||||
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
|
||||
const [status, setStatus] = useState<BudgetStatus | null>(null);
|
||||
const [expenses, setExpenses] = useState<ExpensesPage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [noBudget, setNoBudget] = useState(false);
|
||||
const [selectedBudgetId, setSelectedBudgetId] = useState<number | null>(
|
||||
readStoredBudgetId,
|
||||
);
|
||||
const selectedBudgetIdRef = useRef(selectedBudgetId);
|
||||
selectedBudgetIdRef.current = selectedBudgetId;
|
||||
|
||||
const [amount, setAmount] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [spentAt, setSpentAt] = useState(todayIso());
|
||||
const [expenseBudgetId, setExpenseBudgetId] = useState<number | null>(null);
|
||||
|
||||
const [budgetName, setBudgetName] = useState("Бюджет");
|
||||
const [budgetAmount, setBudgetAmount] = useState("");
|
||||
const [budgetStart, setBudgetStart] = useState(todayIso());
|
||||
const [budgetEnd, setBudgetEnd] = useState("");
|
||||
const [resetExpenses, setResetExpenses] = useState(false);
|
||||
const [createMode, setCreateMode] = useState(false);
|
||||
|
||||
const loadExpensesPage = useCallback(
|
||||
async (pageNum: number, budgetId: number | null | undefined) => {
|
||||
if (journalScopeRef.current === "all") {
|
||||
return fetchMyExpenses(pageNum, 20, null, { all: true });
|
||||
}
|
||||
return fetchMyExpenses(pageNum, 20, budgetId ?? undefined);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: number) => {
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
if (next > 0) params.set("page", String(next));
|
||||
else params.delete("page");
|
||||
return params;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const setJournalScope = useCallback(
|
||||
(scope: JournalScope) => {
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
if (scope === "current") params.set("scope", "current");
|
||||
else params.delete("scope");
|
||||
params.delete("page");
|
||||
return params;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const applyStatus = useCallback((next: BudgetStatus) => {
|
||||
setStatus(next);
|
||||
setSelectedBudgetId(next.budget.id);
|
||||
localStorage.setItem(SELECTED_KEY, String(next.budget.id));
|
||||
setExpenseBudgetId((prev) => prev ?? next.budget.id);
|
||||
setBudgetName(next.budget.name);
|
||||
setBudgetAmount(String(next.budget.total_amount));
|
||||
setBudgetStart(next.budget.start_date);
|
||||
setBudgetEnd(next.budget.end_date);
|
||||
setNoBudget(false);
|
||||
setCreateMode(false);
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await fetchMyBudgets();
|
||||
setBudgets(list.items);
|
||||
if (list.items.length === 0) {
|
||||
setNoBudget(true);
|
||||
setStatus(null);
|
||||
setExpenses(null);
|
||||
setCreateMode(true);
|
||||
setExpenseBudgetId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const preferred =
|
||||
list.items.find((item) => item.selected) ??
|
||||
list.items.find((item) => item.budget.id === selectedBudgetIdRef.current) ??
|
||||
list.items.find((item) => item.budget.is_active) ??
|
||||
list.items[0];
|
||||
|
||||
const budget = await fetchMyBudget(preferred.budget.id);
|
||||
applyStatus(budget);
|
||||
setExpenseBudgetId((prev) => {
|
||||
if (prev && list.items.some((i) => i.budget.id === prev)) return prev;
|
||||
return budget.budget.id;
|
||||
});
|
||||
setExpenses(await loadExpensesPage(page, budget.budget.id));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Ошибка загрузки";
|
||||
if (message.includes("Сначала задай бюджет")) {
|
||||
setNoBudget(true);
|
||||
setStatus(null);
|
||||
setExpenses(null);
|
||||
setBudgets([]);
|
||||
setCreateMode(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(message);
|
||||
setStatus(null);
|
||||
setExpenses(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyStatus, page, loadExpensesPage, journalScope]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const t = window.setTimeout(() => setNotice(null), 3200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [notice]);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}, []);
|
||||
|
||||
const selectBudget = useCallback(
|
||||
async (budgetId: number) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const next = await selectMyBudget(budgetId);
|
||||
applyStatus(next);
|
||||
setExpenseBudgetId(budgetId);
|
||||
setPage(0);
|
||||
const list = await fetchMyBudgets();
|
||||
setBudgets(list.items);
|
||||
setExpenses(await loadExpensesPage(0, next.budget.id));
|
||||
setNotice(`Текущий: ${next.budget.name}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось выбрать бюджет");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[applyStatus, setPage, loadExpensesPage],
|
||||
);
|
||||
|
||||
const toggleBudgetActive = useCallback(
|
||||
async (budgetId: number, isActive: boolean) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await setMyBudgetActive(budgetId, isActive);
|
||||
await reload();
|
||||
setNotice(isActive ? "Бюджет включён" : "Бюджет выключен");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось изменить статус");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[reload],
|
||||
);
|
||||
|
||||
const deleteBudget = useCallback(
|
||||
async (budgetId: number) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await deleteMyBudget(budgetId);
|
||||
localStorage.removeItem(SELECTED_KEY);
|
||||
setSelectedBudgetId(null);
|
||||
setPage(0);
|
||||
await reload();
|
||||
setNotice("Бюджет удалён");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось удалить бюджет");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[reload, setPage],
|
||||
);
|
||||
|
||||
const onAddExpense = useCallback(
|
||||
async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = Number(amount.replace(",", "."));
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
setError("Укажи сумму больше нуля");
|
||||
return;
|
||||
}
|
||||
const targetBudgetId = expenseBudgetId ?? selectedBudgetId;
|
||||
if (targetBudgetId == null) {
|
||||
setError("Выбери бюджет для траты");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
const next = await createMyExpense({
|
||||
amount: value,
|
||||
note: note.trim() || undefined,
|
||||
spent_at: spentAt || undefined,
|
||||
budget_id: targetBudgetId,
|
||||
});
|
||||
applyStatus(next);
|
||||
setAmount("");
|
||||
setNote("");
|
||||
setSpentAt(todayIso());
|
||||
setNotice(`Записал ${formatMoney(value)}`);
|
||||
setPage(0);
|
||||
setExpenses(await loadExpensesPage(0, next.budget.id));
|
||||
setBudgets((await fetchMyBudgets()).items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось сохранить трату");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
amount,
|
||||
note,
|
||||
spentAt,
|
||||
expenseBudgetId,
|
||||
selectedBudgetId,
|
||||
applyStatus,
|
||||
setPage,
|
||||
loadExpensesPage,
|
||||
],
|
||||
);
|
||||
|
||||
const onUndo = useCallback(async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
const targetBudgetId =
|
||||
journalScopeRef.current === "all"
|
||||
? undefined
|
||||
: (expenseBudgetId ?? selectedBudgetId ?? undefined);
|
||||
const result = await undoMyLastExpense(targetBudgetId);
|
||||
applyStatus(result.status);
|
||||
setNotice(`Удалил ${formatMoney(result.deleted_amount)}`);
|
||||
setExpenses(await loadExpensesPage(page, result.status.budget.id));
|
||||
setBudgets((await fetchMyBudgets()).items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Нечего отменять");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedBudgetId, expenseBudgetId, applyStatus, page, loadExpensesPage]);
|
||||
|
||||
const onSaveBudget = useCallback(
|
||||
async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = Number(budgetAmount.replace(",", "."));
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
setError("Сумма бюджета должна быть больше нуля");
|
||||
return;
|
||||
}
|
||||
if (!budgetEnd) {
|
||||
setError("Укажи дату окончания периода");
|
||||
return;
|
||||
}
|
||||
const start = budgetStart || todayIso();
|
||||
if (budgetEnd < start) {
|
||||
setError("Дата окончания не может быть раньше даты начала");
|
||||
return;
|
||||
}
|
||||
const name = budgetName.trim() || "Бюджет";
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
const next =
|
||||
createMode || !selectedBudgetId
|
||||
? await createMyBudget({
|
||||
name,
|
||||
total_amount: value,
|
||||
start_date: start,
|
||||
end_date: budgetEnd,
|
||||
is_active: true,
|
||||
select: true,
|
||||
})
|
||||
: await updateMyBudget(selectedBudgetId, {
|
||||
name,
|
||||
total_amount: value,
|
||||
start_date: start,
|
||||
end_date: budgetEnd,
|
||||
reset_expenses: resetExpenses,
|
||||
});
|
||||
applyStatus(next);
|
||||
setNotice(createMode ? "Бюджет создан" : "Бюджет сохранён");
|
||||
setPage(0);
|
||||
setExpenses(await loadExpensesPage(0, next.budget.id));
|
||||
setBudgets((await fetchMyBudgets()).items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось сохранить бюджет");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
budgetAmount,
|
||||
budgetStart,
|
||||
budgetEnd,
|
||||
budgetName,
|
||||
createMode,
|
||||
selectedBudgetId,
|
||||
resetExpenses,
|
||||
applyStatus,
|
||||
setPage,
|
||||
loadExpensesPage,
|
||||
],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
budgets,
|
||||
status,
|
||||
expenses,
|
||||
loading,
|
||||
saving,
|
||||
noBudget,
|
||||
error,
|
||||
notice,
|
||||
page,
|
||||
setPage,
|
||||
journalScope,
|
||||
setJournalScope,
|
||||
selectedBudgetId,
|
||||
selectBudget,
|
||||
toggleBudgetActive,
|
||||
deleteBudget,
|
||||
amount,
|
||||
setAmount,
|
||||
note,
|
||||
setNote,
|
||||
spentAt,
|
||||
setSpentAt,
|
||||
expenseBudgetId,
|
||||
setExpenseBudgetId,
|
||||
budgetName,
|
||||
setBudgetName,
|
||||
budgetAmount,
|
||||
setBudgetAmount,
|
||||
budgetStart,
|
||||
setBudgetStart,
|
||||
budgetEnd,
|
||||
setBudgetEnd,
|
||||
resetExpenses,
|
||||
setResetExpenses,
|
||||
createMode,
|
||||
setCreateMode,
|
||||
reload,
|
||||
onAddExpense,
|
||||
onUndo,
|
||||
onSaveBudget,
|
||||
clearMessages,
|
||||
}),
|
||||
[
|
||||
budgets,
|
||||
status,
|
||||
expenses,
|
||||
loading,
|
||||
saving,
|
||||
noBudget,
|
||||
error,
|
||||
notice,
|
||||
page,
|
||||
setPage,
|
||||
journalScope,
|
||||
setJournalScope,
|
||||
selectedBudgetId,
|
||||
selectBudget,
|
||||
toggleBudgetActive,
|
||||
deleteBudget,
|
||||
amount,
|
||||
note,
|
||||
spentAt,
|
||||
expenseBudgetId,
|
||||
budgetName,
|
||||
budgetAmount,
|
||||
budgetStart,
|
||||
budgetEnd,
|
||||
resetExpenses,
|
||||
createMode,
|
||||
reload,
|
||||
onAddExpense,
|
||||
onUndo,
|
||||
onSaveBudget,
|
||||
clearMessages,
|
||||
],
|
||||
);
|
||||
|
||||
return <CabinetContext.Provider value={value}>{children}</CabinetContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCabinet(): CabinetContextValue {
|
||||
const ctx = useContext(CabinetContext);
|
||||
if (!ctx) throw new Error("useCabinet must be used within CabinetProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Flash } from "../components/ui";
|
||||
import { CabinetProvider, useCabinet } from "./CabinetContext";
|
||||
|
||||
function CabinetFrame() {
|
||||
const { error, notice } = useCabinet();
|
||||
return (
|
||||
<div className="page">
|
||||
<Flash error={error} notice={notice} />
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Authenticated product area: shared cabinet state + page chrome. */
|
||||
export function CabinetLayout() {
|
||||
return (
|
||||
<CabinetProvider>
|
||||
<CabinetFrame />
|
||||
</CabinetProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { APP_NAME } from "../../brand";
|
||||
import { SiteFooter } from "../../legal/SiteFooter";
|
||||
import { AppAvatar, AppButton, AppText } from "../../ui";
|
||||
import { PRIMARY_NAV } from "./nav";
|
||||
|
||||
type Props = {
|
||||
brand?: string;
|
||||
userLabel: string;
|
||||
onLogout: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function initials(label: string): string {
|
||||
const parts = label.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "•";
|
||||
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
brand = APP_NAME,
|
||||
userLabel,
|
||||
onLogout,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-shell__header">
|
||||
<AppText variant="headline" tone="accent">
|
||||
{brand}
|
||||
</AppText>
|
||||
<div className="app-shell__user">
|
||||
<AppAvatar initials={initials(userLabel)} />
|
||||
<span className="app-shell__chip" title={userLabel}>
|
||||
{userLabel}
|
||||
</span>
|
||||
<AppButton buttonStyle="plain" size="small" expanded={false} onClick={onLogout}>
|
||||
Выйти
|
||||
</AppButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="app-shell__nav" aria-label="Основное меню">
|
||||
<p className="app-section-header">Продукт</p>
|
||||
<ul className="app-shell__nav-list">
|
||||
{PRIMARY_NAV.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.soon ? (
|
||||
<span className="app-shell__nav-link is-disabled" aria-disabled="true">
|
||||
{item.label}
|
||||
<span className="app-shell__badge">скоро</span>
|
||||
</span>
|
||||
) : (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`app-shell__nav-link${isActive ? " is-active" : ""}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<SiteFooter />
|
||||
</nav>
|
||||
|
||||
<div className="app-shell__main">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { APP_NAME } from "../../brand";
|
||||
import { AppText } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
brand?: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
cta: ReactNode;
|
||||
footer?: ReactNode;
|
||||
flash?: ReactNode;
|
||||
};
|
||||
|
||||
export function AuthLayout({
|
||||
brand = APP_NAME,
|
||||
title,
|
||||
lead,
|
||||
cta,
|
||||
footer,
|
||||
flash,
|
||||
}: Props) {
|
||||
return (
|
||||
<main className="auth-layout">
|
||||
<div className="auth-panel">
|
||||
<AppText variant="footnote" tone="accent">
|
||||
{brand}
|
||||
</AppText>
|
||||
<AppText variant="largeTitle" as="h1">
|
||||
{title}
|
||||
</AppText>
|
||||
<AppText variant="subhead">{lead}</AppText>
|
||||
<div className="auth-panel__cta">{cta}</div>
|
||||
{flash}
|
||||
{footer ? <div className="auth-panel__foot">{footer}</div> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type NavItem = {
|
||||
id: string;
|
||||
to: string;
|
||||
label: string;
|
||||
/** Future modules: visible but not routed yet */
|
||||
soon?: boolean;
|
||||
};
|
||||
|
||||
/** Primary product navigation — append items here as features land. */
|
||||
export const PRIMARY_NAV: NavItem[] = [
|
||||
{ id: "overview", to: "/", label: "Обзор" },
|
||||
{ id: "budgets", to: "/budgets", label: "Бюджеты" },
|
||||
{ id: "work", to: "/work", label: "Работа" },
|
||||
{ id: "calendar", to: "/calendar", label: "Календарь" },
|
||||
{ id: "operations", to: "/operations", label: "Операции" },
|
||||
{ id: "journal", to: "/journal", label: "Журнал" },
|
||||
{ id: "period", to: "/period", label: "Период" },
|
||||
{ id: "reports", to: "/reports", label: "Отчёты", soon: true },
|
||||
{ id: "settings", to: "/settings", label: "Настройки", soon: true },
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
tone?: "warn";
|
||||
};
|
||||
|
||||
export function Banner({ children, tone = "warn" }: Props) {
|
||||
return (
|
||||
<p className={`banner banner--${tone}`} role="status">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { AppButton, type AppButtonSize, type AppButtonStyle } from "../../ui";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "destructive";
|
||||
type Size = "md" | "sm";
|
||||
|
||||
const STYLE: Record<Variant, AppButtonStyle> = {
|
||||
primary: "filled",
|
||||
secondary: "gray",
|
||||
ghost: "plain",
|
||||
destructive: "destructive",
|
||||
};
|
||||
|
||||
const SIZE: Record<Size, AppButtonSize> = {
|
||||
md: "medium",
|
||||
sm: "small",
|
||||
};
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
expanded?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
expanded = false,
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<AppButton
|
||||
buttonStyle={STYLE[variant]}
|
||||
size={SIZE[size]}
|
||||
expanded={expanded}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</AppButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppEmptyView } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function EmptyState({ children }: Props) {
|
||||
return <AppEmptyView message={children} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { AppTextField } from "../../ui";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
id: string;
|
||||
hint?: ReactNode;
|
||||
};
|
||||
|
||||
export function Field({ label, id, hint, className = "", ...rest }: Props) {
|
||||
return <AppTextField id={id} label={label} hint={hint} className={className} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
type Props = {
|
||||
error?: string | null;
|
||||
notice?: string | null;
|
||||
};
|
||||
|
||||
export function Flash({ error = null, notice = null }: Props) {
|
||||
if (!error && !notice) return null;
|
||||
return (
|
||||
<div className="flash" aria-live="polite">
|
||||
{error ? <p className="flash__item flash__item--error">{error}</p> : null}
|
||||
{notice ? <p className="flash__item flash__item--ok">{notice}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppListSection, AppListTile } from "../../ui";
|
||||
|
||||
export type MetricItem = {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
warn?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: MetricItem[];
|
||||
};
|
||||
|
||||
export function MetricGrid({ items }: Props) {
|
||||
return (
|
||||
<AppListSection header="Сводка">
|
||||
{items.map((item) => (
|
||||
<AppListTile
|
||||
key={item.label}
|
||||
title={item.label}
|
||||
value={<span className={item.warn ? "is-warn" : undefined}>{item.value}</span>}
|
||||
/>
|
||||
))}
|
||||
</AppListSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppText } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({ eyebrow, title, description }: Props) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
{eyebrow ? <AppText variant="footnote">{eyebrow}</AppText> : null}
|
||||
<AppText variant="largeTitle" as="h1">
|
||||
{title}
|
||||
</AppText>
|
||||
{description ? <AppText variant="subhead">{description}</AppText> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppProgress, AppText } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
spent: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function ProgressBar({ spent, total, label }: Props) {
|
||||
const pct = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0;
|
||||
return (
|
||||
<div className="progress">
|
||||
<AppProgress spent={spent} total={total} label={label} />
|
||||
<AppText variant="footnote">{Math.round(pct)}% бюджета использовано</AppText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppSectionHeader } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
labelledBy?: string;
|
||||
};
|
||||
|
||||
export function Section({ title, description, actions, children, labelledBy }: Props) {
|
||||
const id = labelledBy ?? `section-${title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<section className="section" aria-labelledby={id}>
|
||||
<div className={`section__head${actions ? " section__head--row" : ""}`}>
|
||||
<div>
|
||||
<h2 id={id} className="section__title">
|
||||
<AppSectionHeader>{title}</AppSectionHeader>
|
||||
</h2>
|
||||
{description ? <p className="section__desc">{description}</p> : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { Button } from "./Button";
|
||||
export { Field } from "./Field";
|
||||
export { Flash } from "./Flash";
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export { MetricGrid } from "./MetricGrid";
|
||||
export { PageHeader } from "./PageHeader";
|
||||
export { Section } from "./Section";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export { Banner } from "./Banner";
|
||||
export * from "../../ui";
|
||||
@@ -0,0 +1,70 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
color: var(--label);
|
||||
background: var(--grouped-background);
|
||||
line-height: 1.3;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.is-warn {
|
||||
color: var(--color-warn) !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,989 @@
|
||||
/* —— Layout shell —— */
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"nav main";
|
||||
background: var(--grouped-background);
|
||||
}
|
||||
|
||||
.app-shell__header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
min-height: 44px;
|
||||
padding: 0 var(--gutter);
|
||||
border-bottom: var(--hairline) solid var(--separator);
|
||||
background: var(--bar-background);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.app-shell__brand {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-shell__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.app-shell__chip {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.app-shell__nav {
|
||||
grid-area: nav;
|
||||
padding: var(--space-2) 0 var(--space-4);
|
||||
position: sticky;
|
||||
top: 44px;
|
||||
height: calc(100vh - 44px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-shell__nav-label {
|
||||
margin: 0 var(--space-2) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.app-shell__nav-list {
|
||||
list-style: none;
|
||||
margin: 0 var(--gutter);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
background: var(--grouped-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell__nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--row-min-height);
|
||||
padding: 0 var(--gutter);
|
||||
color: var(--label);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
}
|
||||
|
||||
.app-shell__nav-link:hover {
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
|
||||
.app-shell__nav-link.is-active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-shell__nav-link.is-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-shell__badge {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
grid-area: main;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(var(--content-max), 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 0 var(--space-8);
|
||||
}
|
||||
|
||||
.page > .app-text,
|
||||
.page > .app-btn {
|
||||
margin-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-5);
|
||||
padding: 0 var(--gutter);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.page-header__eyebrow {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-header__desc {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-md);
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 0 0 var(--space-6);
|
||||
}
|
||||
|
||||
.section:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.section__head {
|
||||
margin-bottom: var(--space-2);
|
||||
padding-right: var(--gutter);
|
||||
}
|
||||
|
||||
.section__head--row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section__title .app-section-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.section__desc {
|
||||
margin: 0;
|
||||
padding: 0 var(--gutter);
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
/* —— Auth layout —— */
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-5);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(400px, 100%);
|
||||
padding: var(--space-6) var(--gutter);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.auth-panel__brand {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-panel__title {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.auth-panel__lead {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.auth-panel__cta {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-5);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-panel__foot {
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-line);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.auth-panel__foot code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.tg-login {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn--yandex {
|
||||
width: 100%;
|
||||
gap: var(--space-2);
|
||||
background: #000;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn--yandex:hover:not(:disabled) {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn--yandex__mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 8px;
|
||||
background: #fc3f1d;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* —— UI primitives —— */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--control-height-md);
|
||||
padding: 0 var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 0;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-body);
|
||||
cursor: pointer;
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
height: var(--control-height-sm);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.field__label {
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.field__control {
|
||||
width: 100%;
|
||||
height: var(--control-height-md);
|
||||
border: 0;
|
||||
background: var(--grouped-surface);
|
||||
color: var(--label);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.field__control:hover:not(:disabled) {
|
||||
border-color: var(--color-ink-secondary);
|
||||
}
|
||||
|
||||
.field__control:disabled {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-list__row .form__actions {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--text-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check input {
|
||||
margin-top: 0.15rem;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.flash {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.flash__item {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.flash__item--error {
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
border-color: #e2bcbc;
|
||||
}
|
||||
|
||||
.flash__item--ok {
|
||||
color: var(--color-ok);
|
||||
background: var(--color-ok-bg);
|
||||
border-color: #b9d8c7;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: var(--space-4) var(--gutter) 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.banner--warn {
|
||||
color: var(--color-warn);
|
||||
background: var(--color-warn-bg);
|
||||
border-color: #e2d4a8;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
margin: var(--space-5) 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.metric-grid > div {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-right: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.metric-grid > div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.metric-grid dt {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-grid dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: var(--space-4) var(--gutter) 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.progress__track {
|
||||
height: 4px;
|
||||
background: var(--color-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.progress__meta {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.hero-metric__label {
|
||||
margin-top: var(--space-4);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-metric__label,
|
||||
.hero-metric__value {
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.meta-row__dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-line-strong);
|
||||
}
|
||||
|
||||
.data-list {
|
||||
list-style: none;
|
||||
margin: 0 var(--gutter);
|
||||
padding: 0;
|
||||
background: var(--grouped-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-list__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: baseline;
|
||||
min-height: var(--row-min-height);
|
||||
padding: 10px var(--gutter);
|
||||
box-shadow: inset 0 var(--hairline) 0 var(--separator);
|
||||
}
|
||||
|
||||
.data-list__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.data-list__primary {
|
||||
display: block;
|
||||
font-size: var(--text-body);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.41px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.data-list__secondary {
|
||||
display: block;
|
||||
margin-top: 0.1rem;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.data-list__meta {
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0 var(--gutter);
|
||||
padding: var(--space-6) var(--gutter);
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-subhead);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
padding-inline: var(--gutter);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.panel__head {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.panel__body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.coming-soon__title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.coming-soon__text {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-md);
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.skel {
|
||||
background: var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.skel-line {
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.skel-title {
|
||||
height: 2rem;
|
||||
width: min(220px, 55%);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.w-20 {
|
||||
width: 4.5rem;
|
||||
}
|
||||
.w-24 {
|
||||
width: 5.5rem;
|
||||
}
|
||||
.w-32 {
|
||||
width: 7rem;
|
||||
}
|
||||
.w-56 {
|
||||
width: 12rem;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.day-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.day-chip {
|
||||
min-width: 2.5rem;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 0;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
font-size: var(--text-subhead);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.day-chip:hover:not(:disabled) {
|
||||
border-color: var(--color-ink-secondary);
|
||||
}
|
||||
|
||||
.day-chip.is-on {
|
||||
background: rgb(18 136 90 / 15%);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.day-chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.choice-set {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.choice-set legend {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.split-block {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"nav"
|
||||
"main";
|
||||
}
|
||||
|
||||
.app-shell__nav {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.app-shell__nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell__nav-list {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
background: transparent;
|
||||
margin: 0 var(--gutter);
|
||||
}
|
||||
|
||||
.app-shell__nav-link {
|
||||
white-space: nowrap;
|
||||
background: var(--system-gray5);
|
||||
border-radius: var(--radius-capsule);
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.app-shell__nav-link.is-active {
|
||||
background: var(--grouped-surface);
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(var(--content-max), calc(100% - 2 * var(--space-4)));
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric-grid > div:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.metric-grid > div:nth-child(3),
|
||||
.metric-grid > div:nth-child(4) {
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.form__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section__head--row {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cal__day {
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— Calendar —— */
|
||||
.cal {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cal__weekdays,
|
||||
.cal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal__weekday {
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-muted);
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.cal__day {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
min-height: 4.25rem;
|
||||
padding: var(--space-2) var(--space-2) var(--space-1);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--grouped-surface);
|
||||
color: var(--label);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.cal__day:hover {
|
||||
border-color: var(--color-line-strong);
|
||||
}
|
||||
|
||||
.cal__day--out {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.cal__day--today {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.cal__day--selected {
|
||||
outline: 2px solid var(--color-ink);
|
||||
outline-offset: -1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cal__day-num {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cal__day-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.cal__spend-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal__spend-sum {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-secondary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.cal__day--spent .cal__spend-sum {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.cal-day-block + .cal-day-block {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.cal-day-block__title {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.cal-legend__swatch--spend {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ink);
|
||||
margin-top: 5px;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.cal__stripes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cal__stripe {
|
||||
display: block;
|
||||
height: 5px;
|
||||
border-radius: 1px;
|
||||
flex: 1;
|
||||
min-height: 4px;
|
||||
max-height: 8px;
|
||||
}
|
||||
|
||||
.cal__pays {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
.cal__pay {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--color-surface);
|
||||
box-shadow: 0 0 0 1px rgba(18, 23, 20, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cal-legend__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
|
||||
.cal-legend__swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cal-legend__swatch--budget {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.cal-legend__swatch--pay {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.cal-legend__meta {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "./tokens.css";
|
||||
@import "./base.css";
|
||||
@import "../ui/kit.css";
|
||||
@import "./components.css";
|
||||
@import "../legal/legal.css";
|
||||
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
/* 4pt grid — same as mobile/lib/theme/tokens.dart */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 28px;
|
||||
--space-7: 40px;
|
||||
--space-8: 56px;
|
||||
--gutter: 16px;
|
||||
|
||||
/* SF Pro scale (Apple HIG), rendered in Inter */
|
||||
--text-caption2: 11px;
|
||||
--text-caption1: 12px;
|
||||
--text-footnote: 13px;
|
||||
--text-subhead: 15px;
|
||||
--text-callout: 16px;
|
||||
--text-body: 17px;
|
||||
--text-headline: 17px;
|
||||
--text-title3: 20px;
|
||||
--text-title2: 22px;
|
||||
--text-title1: 28px;
|
||||
--text-large-title: 34px;
|
||||
|
||||
/* Legacy aliases used by older product CSS */
|
||||
--text-xs: var(--text-caption2);
|
||||
--text-sm: var(--text-footnote);
|
||||
--text-md: var(--text-subhead);
|
||||
--text-base: var(--text-body);
|
||||
--text-lg: var(--text-title3);
|
||||
--text-xl: var(--text-title2);
|
||||
--text-2xl: var(--text-large-title);
|
||||
|
||||
/* Light — CupertinoDynamicColor.color */
|
||||
--accent: #12885a;
|
||||
--accent-soft: #e4f4ec;
|
||||
--system-red: #ff3b30;
|
||||
--system-orange: #ff9500;
|
||||
--system-green: #34c759;
|
||||
--system-gray: #8e8e93;
|
||||
--system-gray3: #c7c7cc;
|
||||
--system-gray5: #e5e5ea;
|
||||
--system-gray6: #f2f2f7;
|
||||
--label: #000000;
|
||||
--secondary-label: rgb(60 60 67 / 60%);
|
||||
--tertiary-label: rgb(60 60 67 / 30%);
|
||||
--separator: rgb(60 60 67 / 29%);
|
||||
--opaque-separator: #c6c6c8;
|
||||
--grouped-background: #f2f2f7;
|
||||
--grouped-surface: #ffffff;
|
||||
--bar-background: rgb(249 249 249 / 94%);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-capsule: 999px;
|
||||
|
||||
--control-height: 50px;
|
||||
--control-height-md: 44px;
|
||||
--control-height-sm: 34px;
|
||||
--row-min-height: 44px;
|
||||
--hairline: 0.5px;
|
||||
|
||||
--shell-max: 960px;
|
||||
--content-max: 720px;
|
||||
--sidebar-width: 240px;
|
||||
|
||||
--font-sans: "Inter", "SF Pro Text", "Segoe UI", sans-serif;
|
||||
--font-mono: "Inter", ui-monospace, monospace;
|
||||
|
||||
--shadow-none: none;
|
||||
--z-header: 20;
|
||||
--z-sidebar: 15;
|
||||
--z-toast: 40;
|
||||
|
||||
/* Backward-compatible names from the previous system UI */
|
||||
--color-ink: var(--label);
|
||||
--color-ink-secondary: var(--secondary-label);
|
||||
--color-muted: var(--secondary-label);
|
||||
--color-line: var(--separator);
|
||||
--color-line-strong: var(--opaque-separator);
|
||||
--color-bg: var(--grouped-background);
|
||||
--color-surface: var(--grouped-surface);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: #0e6f4a;
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-warn: var(--system-orange);
|
||||
--color-warn-bg: rgb(255 149 0 / 12%);
|
||||
--color-danger: var(--system-red);
|
||||
--color-danger-bg: rgb(255 59 48 / 12%);
|
||||
--color-ok: var(--system-green);
|
||||
--color-ok-bg: rgb(52 199 89 / 12%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--accent: #3cd68c;
|
||||
--accent-soft: #14301f;
|
||||
--system-red: #ff453a;
|
||||
--system-orange: #ff9f0a;
|
||||
--system-green: #30d158;
|
||||
--system-gray3: #48484a;
|
||||
--system-gray5: #2c2c2e;
|
||||
--system-gray6: #1c1c1e;
|
||||
--label: #ffffff;
|
||||
--secondary-label: rgb(235 235 245 / 60%);
|
||||
--tertiary-label: rgb(235 235 245 / 30%);
|
||||
--separator: rgb(84 84 88 / 65%);
|
||||
--opaque-separator: #38383a;
|
||||
--grouped-background: #000000;
|
||||
--grouped-surface: #1c1c1e;
|
||||
--bar-background: rgb(29 29 29 / 94%);
|
||||
--color-accent-hover: #5ee0a2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AppButton, AppText } from "../ui";
|
||||
import {
|
||||
readCookieChoice,
|
||||
resetCookieChoice,
|
||||
subscribeCookieChoice,
|
||||
writeCookieChoice,
|
||||
} from "./cookieConsent";
|
||||
import { LEGAL_PATHS } from "./operator";
|
||||
|
||||
export function CookieBanner() {
|
||||
const [choice, setChoice] = useState(() => readCookieChoice());
|
||||
|
||||
useEffect(() => subscribeCookieChoice(() => setChoice(readCookieChoice())), []);
|
||||
|
||||
if (choice) return null;
|
||||
|
||||
return (
|
||||
<div className="cookie-banner" role="dialog" aria-label="Использование cookie">
|
||||
<AppText variant="footnote">
|
||||
Мы используем технические cookie, чтобы вы оставались в кабинете. Аналитические cookie
|
||||
(Яндекс.Метрика) включаются только с вашего согласия.{" "}
|
||||
<Link className="legal-link" to={LEGAL_PATHS.cookies}>
|
||||
Подробнее
|
||||
</Link>
|
||||
</AppText>
|
||||
<div className="cookie-banner__actions">
|
||||
<AppButton
|
||||
size="small"
|
||||
buttonStyle="gray"
|
||||
expanded={false}
|
||||
onClick={() => writeCookieChoice("necessary")}
|
||||
>
|
||||
Только необходимые
|
||||
</AppButton>
|
||||
<AppButton size="small" expanded={false} onClick={() => writeCookieChoice("all")}>
|
||||
Принять все
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CookieSettings() {
|
||||
return (
|
||||
<div className="cookie-banner__actions">
|
||||
<AppButton
|
||||
size="small"
|
||||
buttonStyle="gray"
|
||||
expanded={false}
|
||||
onClick={() => {
|
||||
resetCookieChoice();
|
||||
}}
|
||||
>
|
||||
Изменить выбор cookie
|
||||
</AppButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { LEGAL_PATHS } from "./operator";
|
||||
|
||||
type Props = {
|
||||
offer: boolean;
|
||||
consent: boolean;
|
||||
onOffer: (value: boolean) => void;
|
||||
onConsent: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export function LegalCheckboxes({ offer, consent, onOffer, onConsent }: Props) {
|
||||
return (
|
||||
<div className="legal-checks">
|
||||
<label className="legal-check">
|
||||
<input type="checkbox" checked={offer} onChange={(e) => onOffer(e.target.checked)} />
|
||||
<span>
|
||||
Я принимаю условия{" "}
|
||||
<Link className="legal-link" to={LEGAL_PATHS.offer} target="_blank" rel="noreferrer">
|
||||
Пользовательского соглашения
|
||||
</Link>{" "}
|
||||
(публичной оферты).
|
||||
</span>
|
||||
</label>
|
||||
<label className="legal-check">
|
||||
<input type="checkbox" checked={consent} onChange={(e) => onConsent(e.target.checked)} />
|
||||
<span>
|
||||
Я даю согласие на обработку моих персональных данных (email, аватар, данные о
|
||||
транзакциях), полученных от сервиса Яндекс и введённых мной, в целях предоставления
|
||||
доступа к Сервису. Согласие действует до его отзыва.{" "}
|
||||
<Link className="legal-link" to={LEGAL_PATHS.consent} target="_blank" rel="noreferrer">
|
||||
Текст согласия
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link, Navigate, useParams } from "react-router-dom";
|
||||
import { AppText } from "../ui";
|
||||
import { CookieSettings } from "./CookieBanner";
|
||||
import { legalDocumentBySlug } from "./documents";
|
||||
import { SiteFooter } from "./SiteFooter";
|
||||
|
||||
export function LegalPage() {
|
||||
const { slug } = useParams();
|
||||
const doc = legalDocumentBySlug(slug);
|
||||
|
||||
if (!doc) {
|
||||
return <Navigate to="/legal/offer" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="legal-page">
|
||||
<article className="legal-page__inner">
|
||||
<Link className="legal-page__back" to="/">
|
||||
← Назад
|
||||
</Link>
|
||||
<header className="legal-page__meta">
|
||||
<AppText variant="largeTitle" as="h1">
|
||||
{doc.title}
|
||||
</AppText>
|
||||
<AppText variant="subhead">{doc.lead}</AppText>
|
||||
</header>
|
||||
{doc.sections.map((section) => (
|
||||
<section key={section.heading} className="legal-section">
|
||||
<h2>{section.heading}</h2>
|
||||
{section.blocks.map((block, index) =>
|
||||
block.type === "ul" ? (
|
||||
<ul key={index}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p key={index}>{block.text}</p>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{doc.slug === "cookies" ? <CookieSettings /> : null}
|
||||
</article>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { APK_DOWNLOAD_HREF, APK_DOWNLOAD_NAME } from "../brand";
|
||||
import { LEGAL_PATHS, OPERATOR } from "./operator";
|
||||
|
||||
const LINKS = [
|
||||
{ to: LEGAL_PATHS.offer, label: "Оферта" },
|
||||
{ to: LEGAL_PATHS.privacy, label: "Конфиденциальность" },
|
||||
{ to: LEGAL_PATHS.consent, label: "Согласие" },
|
||||
{ to: LEGAL_PATHS.cookies, label: "Cookie" },
|
||||
] as const;
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer__inner">
|
||||
<nav className="site-footer__links" aria-label="Правовая информация">
|
||||
{LINKS.map((item) => (
|
||||
<Link key={item.to} to={item.to}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<a href={APK_DOWNLOAD_HREF} download={APK_DOWNLOAD_NAME}>
|
||||
Приложение Android
|
||||
</a>
|
||||
</nav>
|
||||
<p className="site-footer__copy">
|
||||
{OPERATOR.shortName} · ИНН {OPERATOR.inn} · {OPERATOR.email}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export const COOKIE_CONSENT_KEY = "ppm_cookie_consent";
|
||||
export type CookieChoice = "necessary" | "all";
|
||||
|
||||
const EVENT = "ppm-cookie-consent";
|
||||
|
||||
export function readCookieChoice(): CookieChoice | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(COOKIE_CONSENT_KEY);
|
||||
if (raw === "necessary" || raw === "all") return raw;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeCookieChoice(choice: CookieChoice): void {
|
||||
try {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, choice);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
export function resetCookieChoice(): void {
|
||||
try {
|
||||
localStorage.removeItem(COOKIE_CONSENT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
export function subscribeCookieChoice(listener: () => void): () => void {
|
||||
window.addEventListener(EVENT, listener);
|
||||
window.addEventListener("storage", listener);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, listener);
|
||||
window.removeEventListener("storage", listener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { LEGAL_PATHS, OPERATOR } from "./operator";
|
||||
|
||||
export type LegalBlock =
|
||||
| { type: "p"; text: string }
|
||||
| { type: "ul"; items: string[] };
|
||||
|
||||
export type LegalSection = {
|
||||
heading: string;
|
||||
blocks: LegalBlock[];
|
||||
};
|
||||
|
||||
export type LegalDocument = {
|
||||
slug: keyof typeof LEGAL_PATHS;
|
||||
title: string;
|
||||
lead: string;
|
||||
sections: LegalSection[];
|
||||
};
|
||||
|
||||
const headerLines = [
|
||||
OPERATOR.name,
|
||||
`ИНН: ${OPERATOR.inn}`,
|
||||
`ОГРНИП: ${OPERATOR.ogrnip}`,
|
||||
`Адрес: ${OPERATOR.address}`,
|
||||
`Email для обращений: ${OPERATOR.email}`,
|
||||
];
|
||||
|
||||
export const legalDocuments: LegalDocument[] = [
|
||||
{
|
||||
slug: "offer",
|
||||
title: "Пользовательское соглашение (публичная оферта)",
|
||||
lead: `Настоящий документ является официальным предложением (${OPERATOR.shortName}) заключить договор на изложенных ниже условиях. Дата публикации: ${OPERATOR.updated}.`,
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты исполнителя",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "1. Термины",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Пользователь — дееспособное физическое лицо, принявшее условия настоящей Оферты и использующее Сервис.",
|
||||
`Сервис — веб-кабинет и связанные программные интерфейсы «${OPERATOR.serviceName}» по адресу ${OPERATOR.site}, предназначенные для учёта личных финансов.`,
|
||||
"Приложение — мобильное приложение «Дожить до ЗП» для устройств на iOS и Android, предоставляющее доступ к тому же Сервису.",
|
||||
"Тариф — выбранный Пользователем объём доступа: базовый функционал и, при наличии, платный расширенный доступ (PRO).",
|
||||
"Оферта — настоящий документ.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Предмет",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Исполнитель предоставляет Пользователю доступ к функционалу Сервиса и Приложения для самостоятельного ведения учёта личных финансов: бюджетов, расходов, графика выплат и связанных записей.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Исполнитель не является кредитной, страховой, инвестиционной, платёжной или иной финансовой организацией, не привлекает денежные средства Пользователя, не открывает счета и не осуществляет переводы в пользу третьих лиц. Сервис носит исключительно информационно-учётный характер.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Исполнитель не даёт инвестиционных, налоговых, бухгалтерских или иных профессиональных советов и не несёт ответственности за финансовые решения Пользователя, принятые на основании данных, расчётов или подсказок Сервиса.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "3. Акцепт",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Регистрируясь или оплачивая доступ, Пользователь принимает условия настоящей Оферты. Акцептом также считается вход в Сервис или Приложение через авторизацию (в том числе через Яндекс ID) и начало использования функционала.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Используя Сервис, Пользователь подтверждает, что ознакомился с Политикой конфиденциальности и дал согласие на обработку персональных данных в необходимом объёме.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "4. Порядок оплаты",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Базовый функционал может предоставляться без оплаты. Оплата через ЮKassa (ООО НКО «ЮМани» и связанные платёжные сервисы) является платой за доступ к расширенному функционалу (Тариф PRO), если такой Тариф предложен в Сервисе или Приложении.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Стоимость, срок и состав PRO указываются на экране оплаты до списания. Платёж считается совершённым после подтверждения ЮKassa. Исполнитель не получает и не хранит данные банковских карт: их обрабатывает ЮKassa.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "5. Возврат",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Цифровая услуга по предоставлению доступа считается оказанной с момента активации соответствующего Тарифа в учётной записи Пользователя.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Это не ограничивает права потребителя по Закону РФ от 07.02.1992 № 2300-1 «О защите прав потребителей». Пользователь вправе направить требование о возврате на email Исполнителя. Если доступ PRO не был активирован либо Пользователь не приступил к использованию оплаченного функционала, Исполнитель возвращает уплаченную сумму в полном объёме в срок, предусмотренный законом.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Если доступ активирован и функционал использовался, Исполнитель рассматривает обращение индивидуально и может предложить возврат неиспользованной части периода либо отказать в возврате за фактически оказанную услугу — с указанием мотивов и с сохранением права Пользователя обратиться в уполномоченные органы.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "6. Ответственность",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Сервис предоставляется «как есть». Исполнитель не гарантирует бесперебойную работу и абсолютную точность расчётов.",
|
||||
"Исполнитель не отвечает за сохранность данных на устройстве Пользователя, утрату доступа к устройству, действия вредоносного ПО и последствия разглашения Пользователем своего токена или пароля Яндекс ID.",
|
||||
"Исполнитель не отвечает за решения Пользователя о тратах, накоплениях, кредитах и инвестициях.",
|
||||
"Совокупная ответственность Исполнителя по требованиям, связанным с платным доступом, ограничивается суммой, уплаченной Пользователем за соответствующий расчётный период, кроме случаев, когда иное прямо предусмотрено законом.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "7. Прочие условия",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Исполнитель вправе изменять Оферту, публикуя новую редакцию на этой странице. Продолжение использования Сервиса после публикации означает принятие новой редакции, если иное не требуется законом.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: `Споры решаются путём переговоров, а при недостижении согласия — в порядке, установленном законодательством РФ. Контакт: ${OPERATOR.email}.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "privacy",
|
||||
title: "Политика конфиденциальности",
|
||||
lead: `Настоящая Политика описывает, какие персональные данные обрабатывает ${OPERATOR.shortName} при работе Сервиса «${OPERATOR.serviceName}». Дата публикации: ${OPERATOR.updated}.`,
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты оператора",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "1. Оператор",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: `${OPERATOR.name} (ИНН ${OPERATOR.inn}, ОГРНИП ${OPERATOR.ogrnip}), адрес: ${OPERATOR.address}, email: ${OPERATOR.email}, является оператором персональных данных Пользователей Сервиса и Приложения.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Какие данные собираются",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"От Яндекса (OAuth / Яндекс ID): адрес электронной почты, отображаемое имя (если передано), ссылка на аватар (изображение).",
|
||||
"Введённые Пользователем: данные о транзакциях и бюджетах (суммы, категории, комментарии, даты, параметры выплат).",
|
||||
"Автоматически: файлы cookie, IP-адрес, тип и версия браузера или приложения, данные об устройстве, технические журналы запросов.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "3. Цели обработки",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Исполнение договора: предоставление доступа к Сервису, синхронизация данных между кабинетом и Приложением.",
|
||||
"Связь с Пользователем: уведомления, ответы на обращения в поддержку.",
|
||||
"Улучшение работы Сервиса: обезличенная аналитика сбоев и использования (при наличии согласия на аналитические cookie).",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "4. Передача третьим лицам",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"ЮKassa — для обработки платежей (идентификатор/email и сумма платежа).",
|
||||
"Яндекс — для авторизации через Яндекс ID; Исполнитель получает от Яндекса указанные выше сведения профиля.",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Данные банковских карт Исполнитель не получает и не хранит. Платёжные реквизиты обрабатывает ЮKassa.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "5. Права Пользователя и сроки",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: `Пользователь вправе запросить доступ к своим данным, их уточнение, ограничение обработки, удаление аккаунта и отзыв согласия, направив письмо на ${OPERATOR.email}.`,
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "После удаления аккаунта персональные данные хранятся не дольше 3 лет — в объёме, необходимом для исполнения требований законодательства (в том числе о бухгалтерском учёте и защите прав потребителей), затем уничтожаются либо обезличиваются.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "6. Cookie",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Сервис использует технические cookie для входа и сохранения сессии, а также — только после согласия — аналитические cookie (Яндекс.Метрика). Подробности и отказ от аналитики: Политика использования cookie.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "consent",
|
||||
title: "Согласие на обработку персональных данных",
|
||||
lead: "Текст согласия, которое Пользователь даёт, отмечая соответствующий чек-бокс при регистрации (входе) в Сервисе или Приложении.",
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты оператора",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "Текст согласия",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: `Я, Пользователь Сервиса «${OPERATOR.serviceName}» (ФИО или адрес электронной почты, полученные при авторизации через Яндекс ID либо указанные мной), даю согласие ${OPERATOR.shortName} (ИНН ${OPERATOR.inn}) на обработку моих персональных данных: адрес электронной почты, аватар (ссылка на изображение), данные о транзакциях и иных записях учёта, которые я ввожу.`,
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Цели: предоставление доступа к Сервису, связь со мной, улучшение Сервиса.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Способы обработки: сбор, запись, систематизация, хранение, уточнение, использование, передача (в случаях, указанных ниже), удаление — в том числе автоматизированно.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Передача: ЮKassa (для платежей), Яндекс (для авторизации).",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: `Согласие действует до его отзыва. Отзыв направляется по email ${OPERATOR.email}. Отзыв не влияет на законность обработки, осуществлённой до его получения, и может сделать невозможным дальнейшее использование Сервиса.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "cookies",
|
||||
title: "Политика использования cookie",
|
||||
lead: `Документ описывает, какие cookie использует Сервис «${OPERATOR.serviceName}» и как отказаться от аналитических. Дата публикации: ${OPERATOR.updated}.`,
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты оператора",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "1. Что такое cookie",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Cookie — небольшие файлы, которые сайт сохраняет в браузере. Они помогают запомнить вход и понять, как пользуются Сервисом.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Какие cookie мы используем",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Технические — обязательны для входа и работы кабинета (токен сессии, выбранная тема, выбор cookie). Без них авторизация невозможна.",
|
||||
"Аналитические — Яндекс.Метрика: посещения страниц, источник перехода, тип устройства. Ставятся только после согласия «Принять все».",
|
||||
"Рекламные cookie сейчас не используются.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "3. Как отказаться",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "При первом заходе показывается баннер. Можно выбрать «Только необходимые» — аналитические cookie не устанавливаются. Выбор можно изменить кнопкой ниже или очистив данные сайта в браузере.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: `Вопросы: ${OPERATOR.email}.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function legalDocumentBySlug(slug: string | undefined): LegalDocument | undefined {
|
||||
return legalDocuments.find((doc) => doc.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
.legal-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--grouped-background);
|
||||
}
|
||||
|
||||
.legal-page__inner {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) var(--gutter) var(--space-8);
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.legal-page__back {
|
||||
color: var(--accent);
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.legal-page__meta {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.legal-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.legal-section h2 {
|
||||
font-size: var(--text-headline);
|
||||
}
|
||||
|
||||
.legal-section p,
|
||||
.legal-section li {
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.45;
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.legal-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
padding: var(--space-4) var(--gutter) calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
|
||||
border-top: var(--hairline) solid var(--separator);
|
||||
background: var(--bar-background);
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.site-footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.site-footer__links a {
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.site-footer__links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.site-footer__copy {
|
||||
margin: 0;
|
||||
color: var(--tertiary-label);
|
||||
font-size: var(--text-caption1);
|
||||
}
|
||||
|
||||
.app-shell .site-footer {
|
||||
margin-top: var(--space-4);
|
||||
border-top: 0;
|
||||
background: transparent;
|
||||
padding: var(--space-3) var(--gutter) 0;
|
||||
}
|
||||
|
||||
.auth-panel__foot .site-footer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.auth-panel__foot .site-footer__inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-shell .site-footer__inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.legal-check {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
gap: var(--space-2);
|
||||
align-items: start;
|
||||
margin: 0;
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legal-check input {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.legal-checks {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cookie-banner {
|
||||
position: fixed;
|
||||
left: var(--gutter);
|
||||
right: var(--gutter);
|
||||
bottom: calc(var(--gutter) + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 50;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--grouped-surface);
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 18%);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cookie-banner__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn--yandex.is-disabled,
|
||||
.btn--yandex[aria-disabled="true"] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell .site-footer {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.app-shell .site-footer__links {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const OPERATOR = {
|
||||
name: "Индивидуальный предприниматель Архангельский Владимир Александрович",
|
||||
shortName: "ИП Архангельский В.А.",
|
||||
inn: "772270222393",
|
||||
ogrnip: "323774600438338",
|
||||
address: "105037, г. Москва, ул. 2-я Парковая, д. 16, кв. 8",
|
||||
email: "hohnergold@yandex.ru",
|
||||
serviceName: "Дожить до ЗП",
|
||||
site: "https://please-pay-me.ru",
|
||||
updated: "20 сентября 2026 г.",
|
||||
} as const;
|
||||
|
||||
export const LEGAL_PATHS = {
|
||||
offer: "/legal/offer",
|
||||
privacy: "/legal/privacy",
|
||||
consent: "/legal/consent",
|
||||
cookies: "/legal/cookies",
|
||||
} as const;
|
||||
|
||||
export type LegalSlug = keyof typeof LEGAL_PATHS;
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Stable pastel palette for calendar layers (budgets / jobs). */
|
||||
|
||||
const BUDGET_PALETTE = [
|
||||
{ fill: "rgba(14, 107, 69, 0.28)", solid: "#0e6b45", label: "зелёный" },
|
||||
{ fill: "rgba(30, 90, 140, 0.28)", solid: "#1e5a8c", label: "синий" },
|
||||
{ fill: "rgba(168, 90, 28, 0.28)", solid: "#a85a1c", label: "янтарный" },
|
||||
{ fill: "rgba(140, 50, 70, 0.28)", solid: "#8c3246", label: "бордовый" },
|
||||
{ fill: "rgba(70, 110, 40, 0.28)", solid: "#466e28", label: "оливковый" },
|
||||
{ fill: "rgba(40, 120, 120, 0.28)", solid: "#287878", label: "бирюза" },
|
||||
{ fill: "rgba(110, 70, 130, 0.22)", solid: "#6e4682", label: "слива" },
|
||||
{ fill: "rgba(90, 90, 50, 0.28)", solid: "#5a5a32", label: "хаки" },
|
||||
] as const;
|
||||
|
||||
const JOB_PALETTE = [
|
||||
{ solid: "#c45c00", soft: "rgba(196, 92, 0, 0.15)" },
|
||||
{ solid: "#b00040", soft: "rgba(176, 0, 64, 0.12)" },
|
||||
{ solid: "#005f8a", soft: "rgba(0, 95, 138, 0.12)" },
|
||||
{ solid: "#5a3d00", soft: "rgba(90, 61, 0, 0.12)" },
|
||||
{ solid: "#00664d", soft: "rgba(0, 102, 77, 0.12)" },
|
||||
] as const;
|
||||
|
||||
function hashId(id: number): number {
|
||||
let x = id | 0;
|
||||
x = ((x >>> 16) ^ x) * 0x45d9f3b;
|
||||
x = ((x >>> 16) ^ x) * 0x45d9f3b;
|
||||
x = (x >>> 16) ^ x;
|
||||
return Math.abs(x);
|
||||
}
|
||||
|
||||
export function budgetColor(id: number) {
|
||||
return BUDGET_PALETTE[hashId(id) % BUDGET_PALETTE.length]!;
|
||||
}
|
||||
|
||||
export function jobColor(id: number) {
|
||||
return JOB_PALETTE[hashId(id) % JOB_PALETTE.length]!;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function todayIso(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Mirrors PleasePayMe.Application.Jobs.PaySchedule for calendar rendering. */
|
||||
|
||||
export type WeekendPolicy = "before_weekend" | "after_weekend";
|
||||
|
||||
export type PayOccurrence = {
|
||||
date: string;
|
||||
jobId: number;
|
||||
jobName: string;
|
||||
scheduledDay: number;
|
||||
percent: number;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function toIso(year: number, month: number, day: number): string {
|
||||
return `${year}-${pad2(month)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
export function parseIso(iso: string): { year: number; month: number; day: number } {
|
||||
const [year, month, day] = iso.slice(0, 10).split("-").map(Number);
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
export function addDaysIso(iso: string, delta: number): string {
|
||||
const { year, month, day } = parseIso(iso);
|
||||
const dt = new Date(Date.UTC(year, month - 1, day + delta));
|
||||
return toIso(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate());
|
||||
}
|
||||
|
||||
export function weekdayMon0(iso: string): number {
|
||||
const { year, month, day } = parseIso(iso);
|
||||
// 0 = Mon … 6 = Sun
|
||||
const js = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
||||
return (js + 6) % 7;
|
||||
}
|
||||
|
||||
export function adjustForWeekend(iso: string, policy: WeekendPolicy): string {
|
||||
const wd = weekdayMon0(iso);
|
||||
// Mon=0 … Sat=5, Sun=6
|
||||
if (wd === 5) {
|
||||
return policy === "before_weekend" ? addDaysIso(iso, -1) : addDaysIso(iso, 2);
|
||||
}
|
||||
if (wd === 6) {
|
||||
return policy === "before_weekend" ? addDaysIso(iso, -2) : addDaysIso(iso, 1);
|
||||
}
|
||||
return iso;
|
||||
}
|
||||
|
||||
export function nominalDate(year: number, month: number, dayOfMonth: number): string {
|
||||
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const actualDay = Math.min(dayOfMonth, daysInMonth);
|
||||
return toIso(year, month, actualDay);
|
||||
}
|
||||
|
||||
export function paysInRange(
|
||||
jobs: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
salary_amount: number;
|
||||
pay_days: number[];
|
||||
first_pay_percent: number;
|
||||
weekend_policy: WeekendPolicy;
|
||||
is_active: boolean;
|
||||
}>,
|
||||
rangeStart: string,
|
||||
rangeEnd: string,
|
||||
): PayOccurrence[] {
|
||||
const start = parseIso(rangeStart);
|
||||
const end = parseIso(rangeEnd);
|
||||
const result: PayOccurrence[] = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!job.is_active || job.pay_days.length === 0) continue;
|
||||
|
||||
const ordered = [...job.pay_days].sort((a, b) => a - b).slice(0, 2);
|
||||
const firstPercent = ordered.length <= 1 ? 100 : job.first_pay_percent;
|
||||
const secondPercent = ordered.length === 1 ? 0 : 100 - firstPercent;
|
||||
|
||||
// Extra months: weekend shift can move pay across month boundary.
|
||||
let cursor = new Date(Date.UTC(start.year, start.month - 2, 1));
|
||||
const last = new Date(Date.UTC(end.year, end.month, 1));
|
||||
|
||||
while (cursor <= last) {
|
||||
const year = cursor.getUTCFullYear();
|
||||
const month = cursor.getUTCMonth() + 1;
|
||||
|
||||
ordered.forEach((scheduledDay, i) => {
|
||||
const nominal = nominalDate(year, month, scheduledDay);
|
||||
const actual = adjustForWeekend(nominal, job.weekend_policy);
|
||||
if (actual < rangeStart || actual > rangeEnd) return;
|
||||
|
||||
const percent = i === 0 ? firstPercent : secondPercent;
|
||||
const amount = Math.round((job.salary_amount * percent) / 100 * 100) / 100;
|
||||
result.push({
|
||||
date: actual,
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
scheduledDay,
|
||||
percent,
|
||||
amount,
|
||||
});
|
||||
});
|
||||
|
||||
cursor = new Date(Date.UTC(year, month, 1));
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) =>
|
||||
a.date === b.date
|
||||
? a.jobId - b.jobId || a.scheduledDay - b.scheduledDay
|
||||
: a.date < b.date
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
export function monthGrid(year: number, month: number): string[] {
|
||||
const first = toIso(year, month, 1);
|
||||
const startOffset = weekdayMon0(first);
|
||||
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const cells: string[] = [];
|
||||
|
||||
for (let i = 0; i < startOffset; i++) {
|
||||
cells.push(addDaysIso(first, i - startOffset));
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push(toIso(year, month, d));
|
||||
}
|
||||
while (cells.length % 7 !== 0 || cells.length < 35) {
|
||||
const last = cells[cells.length - 1]!;
|
||||
cells.push(addDaysIso(last, 1));
|
||||
if (cells.length >= 42) break;
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useId } from "react";
|
||||
import { useCabinet } from "../cabinet/CabinetContext";
|
||||
import { formatDate, formatMoney } from "../api";
|
||||
import { todayIso } from "../lib/date";
|
||||
import { Button, Field, PageHeader, Section } from "../components/ui";
|
||||
|
||||
export function BudgetsPage() {
|
||||
const nameId = useId();
|
||||
const amountId = useId();
|
||||
const startId = useId();
|
||||
const endId = useId();
|
||||
const {
|
||||
budgets,
|
||||
saving,
|
||||
noBudget,
|
||||
selectBudget,
|
||||
toggleBudgetActive,
|
||||
deleteBudget,
|
||||
budgetName,
|
||||
setBudgetName,
|
||||
budgetAmount,
|
||||
setBudgetAmount,
|
||||
budgetStart,
|
||||
setBudgetStart,
|
||||
budgetEnd,
|
||||
setBudgetEnd,
|
||||
resetExpenses,
|
||||
setResetExpenses,
|
||||
createMode,
|
||||
setCreateMode,
|
||||
onSaveBudget,
|
||||
selectedBudgetId,
|
||||
} = useCabinet();
|
||||
|
||||
const beginCreate = () => {
|
||||
setCreateMode(true);
|
||||
setBudgetName("Бюджет");
|
||||
setBudgetAmount("");
|
||||
setBudgetStart(todayIso());
|
||||
setBudgetEnd("");
|
||||
setResetExpenses(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Бюджеты"
|
||||
description="Несколько параллельных бюджетов. Активные принимают траты; текущий — куда пишутся операции по умолчанию."
|
||||
/>
|
||||
|
||||
<Section
|
||||
title="Список"
|
||||
description={noBudget ? "Пока пусто" : `${budgets.length} шт.`}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={beginCreate}
|
||||
>
|
||||
Новый
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{budgets.length === 0 ? (
|
||||
<p className="empty-state">Создай первый бюджет формой ниже.</p>
|
||||
) : (
|
||||
<ul className="data-list">
|
||||
{budgets.map((item) => {
|
||||
const b = item.budget;
|
||||
const isSelected = b.id === selectedBudgetId || item.selected;
|
||||
return (
|
||||
<li key={b.id} className="data-list__row">
|
||||
<div>
|
||||
<span className="data-list__primary">
|
||||
{b.name}
|
||||
{isSelected ? " · текущий" : ""}
|
||||
{!b.is_active ? " · выкл" : ""}
|
||||
</span>
|
||||
<span className="data-list__secondary">
|
||||
{formatMoney(b.total_amount)} · {formatDate(b.start_date)}–
|
||||
{formatDate(b.end_date)} · остаток {formatMoney(item.remaining)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form__actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={saving || isSelected}
|
||||
onClick={() => void selectBudget(b.id)}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() => void toggleBudgetActive(b.id, !b.is_active)}
|
||||
>
|
||||
{b.is_active ? "Выкл" : "Вкл"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
const ok = window.confirm(
|
||||
`Удалить бюджет «${b.name}» вместе со всеми тратами?`,
|
||||
);
|
||||
if (ok) void deleteBudget(b.id);
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={createMode || noBudget ? "Новый бюджет" : "Параметры текущего"}
|
||||
description="Имя · сумма · даты периода"
|
||||
>
|
||||
<form className="form" onSubmit={(e) => void onSaveBudget(e)}>
|
||||
<Field
|
||||
id={nameId}
|
||||
label="Название"
|
||||
value={budgetName}
|
||||
onChange={(e) => setBudgetName(e.target.value)}
|
||||
placeholder="Зарплата"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Field
|
||||
id={amountId}
|
||||
label="Сумма"
|
||||
inputMode="decimal"
|
||||
value={budgetAmount}
|
||||
onChange={(e) => setBudgetAmount(e.target.value)}
|
||||
placeholder="25000"
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="form__row">
|
||||
<Field
|
||||
id={startId}
|
||||
label="С даты"
|
||||
type="date"
|
||||
value={budgetStart}
|
||||
onChange={(e) => setBudgetStart(e.target.value)}
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<Field
|
||||
id={endId}
|
||||
label="До даты"
|
||||
type="date"
|
||||
value={budgetEnd}
|
||||
onChange={(e) => setBudgetEnd(e.target.value)}
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
{!createMode && !noBudget ? (
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resetExpenses}
|
||||
onChange={(e) => setResetExpenses(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>Сбросить траты этого бюджета при сохранении</span>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="form__actions">
|
||||
<Button type="submit" variant="primary" disabled={saving}>
|
||||
{saving
|
||||
? "Сохранение…"
|
||||
: createMode || noBudget
|
||||
? "Создать"
|
||||
: "Сохранить"}
|
||||
</Button>
|
||||
{!createMode && !noBudget ? (
|
||||
<Button variant="ghost" disabled={saving} onClick={beginCreate}>
|
||||
Создать ещё
|
||||
</Button>
|
||||
) : null}
|
||||
{createMode && !noBudget ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={() => setCreateMode(false)}
|
||||
>
|
||||
К текущему
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchMyExpensesRange,
|
||||
fetchMyJobs,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
} from "../api";
|
||||
import { useCabinet } from "../cabinet/CabinetContext";
|
||||
import { Button, EmptyState, Flash, PageHeader, Section } from "../components/ui";
|
||||
import { budgetColor, jobColor } from "../lib/calendarColors";
|
||||
import { todayIso } from "../lib/date";
|
||||
import {
|
||||
monthGrid,
|
||||
parseIso,
|
||||
paysInRange,
|
||||
type PayOccurrence,
|
||||
} from "../lib/paySchedule";
|
||||
import type { Expense, Job } from "../types";
|
||||
|
||||
const WEEKDAYS = ["пн", "вт", "ср", "чт", "пт", "сб", "вс"];
|
||||
|
||||
function monthTitle(year: number, month: number): string {
|
||||
const raw = new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString("ru-RU", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
}
|
||||
|
||||
function shiftMonth(year: number, month: number, delta: number): { year: number; month: number } {
|
||||
const d = new Date(Date.UTC(year, month - 1 + delta, 1));
|
||||
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 };
|
||||
}
|
||||
|
||||
/** Compact sum for calendar cells: 350 / 1,2 тыс / 12 тыс */
|
||||
function compactSpend(amount: number): string {
|
||||
if (amount < 1000) {
|
||||
return amount % 1 === 0
|
||||
? String(amount)
|
||||
: amount.toLocaleString("ru-RU", { maximumFractionDigits: 0 });
|
||||
}
|
||||
const thousands = amount / 1000;
|
||||
const formatted =
|
||||
thousands >= 10
|
||||
? thousands.toLocaleString("ru-RU", { maximumFractionDigits: 0 })
|
||||
: thousands.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return `${formatted} тыс`;
|
||||
}
|
||||
|
||||
type DaySpend = { total: number; count: number; items: Expense[] };
|
||||
|
||||
export function CalendarPage() {
|
||||
const { budgets, loading: budgetsLoading } = useCabinet();
|
||||
const today = todayIso();
|
||||
const initial = parseIso(today);
|
||||
|
||||
const [year, setYear] = useState(initial.year);
|
||||
const [month, setMonth] = useState(initial.month);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [expenses, setExpenses] = useState<Expense[]>([]);
|
||||
const [jobsLoading, setJobsLoading] = useState(true);
|
||||
const [expensesLoading, setExpensesLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(today);
|
||||
|
||||
const cells = useMemo(() => monthGrid(year, month), [year, month]);
|
||||
const rangeStart = cells[0]!;
|
||||
const rangeEnd = cells[cells.length - 1]!;
|
||||
|
||||
const budgetNameById = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const item of budgets) {
|
||||
map.set(item.budget.id, item.budget.name);
|
||||
}
|
||||
return map;
|
||||
}, [budgets]);
|
||||
|
||||
const reloadJobs = useCallback(async () => {
|
||||
setJobsLoading(true);
|
||||
try {
|
||||
const data = await fetchMyJobs();
|
||||
setJobs(data.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось загрузить работы");
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadExpenses = useCallback(async () => {
|
||||
setExpensesLoading(true);
|
||||
try {
|
||||
const data = await fetchMyExpensesRange(rangeStart, rangeEnd);
|
||||
setExpenses(data.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось загрузить траты");
|
||||
setExpenses([]);
|
||||
} finally {
|
||||
setExpensesLoading(false);
|
||||
}
|
||||
}, [rangeStart, rangeEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadJobs();
|
||||
}, [reloadJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadExpenses();
|
||||
}, [reloadExpenses]);
|
||||
|
||||
const pays = useMemo(
|
||||
() => paysInRange(jobs, rangeStart, rangeEnd),
|
||||
[jobs, rangeStart, rangeEnd],
|
||||
);
|
||||
|
||||
const paysByDate = useMemo(() => {
|
||||
const map = new Map<string, PayOccurrence[]>();
|
||||
for (const pay of pays) {
|
||||
const list = map.get(pay.date) ?? [];
|
||||
list.push(pay);
|
||||
map.set(pay.date, list);
|
||||
}
|
||||
return map;
|
||||
}, [pays]);
|
||||
|
||||
const spendByDate = useMemo(() => {
|
||||
const map = new Map<string, DaySpend>();
|
||||
for (const exp of expenses) {
|
||||
const key = exp.spent_at.slice(0, 10);
|
||||
const cur = map.get(key) ?? { total: 0, count: 0, items: [] };
|
||||
cur.total += exp.amount;
|
||||
cur.count += 1;
|
||||
cur.items.push(exp);
|
||||
map.set(key, cur);
|
||||
}
|
||||
return map;
|
||||
}, [expenses]);
|
||||
|
||||
const selectedBudgets = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return budgets.filter(
|
||||
(item) =>
|
||||
item.budget.start_date <= selected && selected <= item.budget.end_date,
|
||||
);
|
||||
}, [budgets, selected]);
|
||||
|
||||
const selectedPays = selected ? (paysByDate.get(selected) ?? []) : [];
|
||||
const selectedSpend = selected ? spendByDate.get(selected) : undefined;
|
||||
const selectedExpenses = selectedSpend?.items ?? [];
|
||||
|
||||
const loading = budgetsLoading || jobsLoading || expensesLoading;
|
||||
const empty =
|
||||
!loading &&
|
||||
budgets.length === 0 &&
|
||||
jobs.filter((j) => j.is_active).length === 0 &&
|
||||
expenses.length === 0;
|
||||
|
||||
const dayHasContent =
|
||||
selectedBudgets.length > 0 ||
|
||||
selectedPays.length > 0 ||
|
||||
selectedExpenses.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Календарь"
|
||||
description="Бюджеты, выплаты и траты по дням. Кликни день — увидишь операции."
|
||||
/>
|
||||
|
||||
<Flash error={error} notice={null} />
|
||||
|
||||
<Section
|
||||
title={monthTitle(year, month)}
|
||||
actions={
|
||||
<div className="form__actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const next = shiftMonth(year, month, -1);
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
}}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const t = parseIso(todayIso());
|
||||
setYear(t.year);
|
||||
setMonth(t.month);
|
||||
setSelected(todayIso());
|
||||
}}
|
||||
>
|
||||
Сегодня
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const next = shiftMonth(year, month, 1);
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
}}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="empty-state">Загрузка…</p>
|
||||
) : empty ? (
|
||||
<EmptyState>
|
||||
Пока нечего показывать. Задай <Link to="/budgets">бюджет</Link>,{" "}
|
||||
<Link to="/work">работу</Link> или{" "}
|
||||
<Link to="/operations">трату</Link>.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="cal">
|
||||
<div className="cal__weekdays" aria-hidden>
|
||||
{WEEKDAYS.map((d) => (
|
||||
<span key={d} className="cal__weekday">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="cal__grid" role="grid" aria-label={monthTitle(year, month)}>
|
||||
{cells.map((iso) => {
|
||||
const { month: cellMonth, day } = parseIso(iso);
|
||||
const inMonth = cellMonth === month;
|
||||
const isToday = iso === today;
|
||||
const isSelected = iso === selected;
|
||||
const dayBudgets = budgets.filter(
|
||||
(item) =>
|
||||
item.budget.start_date <= iso && iso <= item.budget.end_date,
|
||||
);
|
||||
const dayPays = paysByDate.get(iso) ?? [];
|
||||
const daySpend = spendByDate.get(iso);
|
||||
const titleParts = [
|
||||
...dayBudgets.map((b) => `Бюджет: ${b.budget.name}`),
|
||||
...dayPays.map(
|
||||
(p) =>
|
||||
`ЗП ${p.jobName}: ${formatMoney(p.amount)} (${p.percent}%)`,
|
||||
),
|
||||
];
|
||||
if (daySpend) {
|
||||
titleParts.push(
|
||||
`Траты: ${formatMoney(daySpend.total)} · ${daySpend.count} шт.`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={iso}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={[
|
||||
"cal__day",
|
||||
inMonth ? "" : "cal__day--out",
|
||||
isToday ? "cal__day--today" : "",
|
||||
isSelected ? "cal__day--selected" : "",
|
||||
daySpend ? "cal__day--spent" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
title={titleParts.join("\n") || formatDate(iso)}
|
||||
onClick={() => setSelected(iso)}
|
||||
>
|
||||
<span className="cal__day-top">
|
||||
<span className="cal__day-num">{day}</span>
|
||||
{daySpend ? (
|
||||
<span className="cal__spend-dot" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
{daySpend ? (
|
||||
<span className="cal__spend-sum">
|
||||
{compactSpend(daySpend.total)}
|
||||
</span>
|
||||
) : null}
|
||||
{dayBudgets.length > 0 ? (
|
||||
<span className="cal__stripes" aria-hidden>
|
||||
{dayBudgets.slice(0, 4).map((item) => {
|
||||
const c = budgetColor(item.budget.id);
|
||||
return (
|
||||
<span
|
||||
key={item.budget.id}
|
||||
className="cal__stripe"
|
||||
style={{
|
||||
background: c.fill,
|
||||
boxShadow: `inset 0 0 0 1px ${c.solid}33`,
|
||||
opacity: item.budget.is_active ? 1 : 0.45,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
{dayPays.length > 0 ? (
|
||||
<span className="cal__pays" aria-hidden>
|
||||
{dayPays.slice(0, 3).map((p) => {
|
||||
const c = jobColor(p.jobId);
|
||||
return (
|
||||
<span
|
||||
key={`${p.jobId}-${p.scheduledDay}`}
|
||||
className="cal__pay"
|
||||
style={{ background: c.solid }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{!loading && !empty ? (
|
||||
<>
|
||||
<Section title="Легенда" description="полоски · кружки ЗП · точка и сумма трат">
|
||||
<ul className="cal-legend">
|
||||
{budgets.map((item) => {
|
||||
const c = budgetColor(item.budget.id);
|
||||
return (
|
||||
<li key={item.budget.id} className="cal-legend__item">
|
||||
<span
|
||||
className="cal-legend__swatch cal-legend__swatch--budget"
|
||||
style={{ background: c.fill, borderColor: c.solid }}
|
||||
/>
|
||||
<span>
|
||||
{item.budget.name}
|
||||
{!item.budget.is_active ? " · выкл" : ""}
|
||||
<span className="cal-legend__meta">
|
||||
{" "}
|
||||
· {formatDate(item.budget.start_date)}–
|
||||
{formatDate(item.budget.end_date)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{jobs
|
||||
.filter((j) => j.is_active)
|
||||
.map((job) => {
|
||||
const c = jobColor(job.id);
|
||||
return (
|
||||
<li key={`job-${job.id}`} className="cal-legend__item">
|
||||
<span
|
||||
className="cal-legend__swatch cal-legend__swatch--pay"
|
||||
style={{ background: c.solid }}
|
||||
/>
|
||||
<span>
|
||||
ЗП · {job.name}
|
||||
<span className="cal-legend__meta">
|
||||
{" "}
|
||||
· дни {job.pay_days.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className="cal-legend__item">
|
||||
<span className="cal-legend__swatch cal-legend__swatch--spend" />
|
||||
<span>
|
||||
Траты
|
||||
<span className="cal-legend__meta"> · точка и сумма в ячейке</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
{selected ? (
|
||||
<Section
|
||||
title={formatDate(selected)}
|
||||
description={
|
||||
selected === today
|
||||
? selectedSpend
|
||||
? `сегодня · ${formatMoney(selectedSpend.total)}`
|
||||
: "сегодня"
|
||||
: selectedSpend
|
||||
? formatMoney(selectedSpend.total)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{!dayHasContent ? (
|
||||
<p className="empty-state">Нет бюджетов, выплат и трат в этот день.</p>
|
||||
) : (
|
||||
<>
|
||||
{selectedExpenses.length > 0 ? (
|
||||
<div className="cal-day-block">
|
||||
<h3 className="cal-day-block__title">
|
||||
Операции · {selectedExpenses.length}
|
||||
</h3>
|
||||
<ul className="data-list">
|
||||
{selectedExpenses.map((exp) => {
|
||||
const c = budgetColor(exp.budget_id);
|
||||
const name =
|
||||
budgetNameById.get(exp.budget_id) ?? `бюджет #${exp.budget_id}`;
|
||||
return (
|
||||
<li key={exp.id} className="data-list__row">
|
||||
<div>
|
||||
<span className="data-list__primary">
|
||||
{formatMoney(exp.amount)}
|
||||
{exp.note ? ` · ${exp.note}` : ""}
|
||||
</span>
|
||||
<span className="data-list__secondary">
|
||||
<span
|
||||
className="cal-legend__swatch cal-legend__swatch--budget"
|
||||
style={{
|
||||
background: c.fill,
|
||||
borderColor: c.solid,
|
||||
verticalAlign: "middle",
|
||||
marginRight: "0.35rem",
|
||||
}}
|
||||
/>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBudgets.length > 0 || selectedPays.length > 0 ? (
|
||||
<div className="cal-day-block">
|
||||
{(selectedBudgets.length > 0 || selectedPays.length > 0) &&
|
||||
selectedExpenses.length > 0 ? (
|
||||
<h3 className="cal-day-block__title">Контекст дня</h3>
|
||||
) : null}
|
||||
<ul className="data-list">
|
||||
{selectedBudgets.map((item) => {
|
||||
const c = budgetColor(item.budget.id);
|
||||
return (
|
||||
<li key={item.budget.id} className="data-list__row">
|
||||
<div>
|
||||
<span className="data-list__primary">
|
||||
<span
|
||||
className="cal-legend__swatch cal-legend__swatch--budget"
|
||||
style={{
|
||||
background: c.fill,
|
||||
borderColor: c.solid,
|
||||
verticalAlign: "middle",
|
||||
marginRight: "0.4rem",
|
||||
}}
|
||||
/>
|
||||
{item.budget.name}
|
||||
</span>
|
||||
<span className="data-list__secondary">
|
||||
Бюджет · остаток {formatMoney(item.remaining)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{selectedPays.map((p) => {
|
||||
const c = jobColor(p.jobId);
|
||||
return (
|
||||
<li
|
||||
key={`${p.jobId}-${p.scheduledDay}-${p.date}`}
|
||||
className="data-list__row"
|
||||
>
|
||||
<div>
|
||||
<span className="data-list__primary">
|
||||
<span
|
||||
className="cal-legend__swatch cal-legend__swatch--pay"
|
||||
style={{
|
||||
background: c.solid,
|
||||
verticalAlign: "middle",
|
||||
marginRight: "0.4rem",
|
||||
}}
|
||||
/>
|
||||
{p.jobName}
|
||||
</span>
|
||||
<span className="data-list__secondary">
|
||||
Выплата · {formatMoney(p.amount)} · {p.percent}% · день{" "}
|
||||
{p.scheduledDay}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { PageHeader } from "../components/ui";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/** Placeholder route for modules not yet shipped — keeps IA expandable. */
|
||||
export function ComingSoonPage({ title, description }: Props) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={title} description={description} />
|
||||
<div className="coming-soon">
|
||||
<h2 className="coming-soon__title">Скоро</h2>
|
||||
<p className="coming-soon__text">
|
||||
Раздел зарезервирован в навигации. Когда появится функционал — сюда
|
||||
подключится страница без перестройки оболочки.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { formatDate, formatMoney } from "../api";
|
||||
import { useCabinet } from "../cabinet/CabinetContext";
|
||||
import { Button, EmptyState, PageHeader } from "../components/ui";
|
||||
import { AppListSection, AppListTile, AppSegmented } from "../ui";
|
||||
|
||||
export function JournalPage() {
|
||||
const {
|
||||
budgets,
|
||||
expenses,
|
||||
noBudget,
|
||||
page,
|
||||
setPage,
|
||||
loading,
|
||||
journalScope,
|
||||
setJournalScope,
|
||||
status,
|
||||
} = useCabinet();
|
||||
|
||||
const budgetName = (id: number) =>
|
||||
budgets.find((b) => b.budget.id === id)?.budget.name ?? `бюджет #${id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Журнал"
|
||||
description="История расходов — по всем бюджетам или только по текущему."
|
||||
/>
|
||||
|
||||
{noBudget ? (
|
||||
<EmptyState>
|
||||
Нет бюджетов — создай в разделе <Link to="/budgets">Бюджеты</Link>.
|
||||
</EmptyState>
|
||||
) : !expenses && loading ? (
|
||||
<EmptyState>Загрузка…</EmptyState>
|
||||
) : !expenses ? (
|
||||
<EmptyState>Не удалось загрузить журнал.</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "0 16px 12px" }}>
|
||||
<AppSegmented
|
||||
ariaLabel="Область журнала"
|
||||
value={journalScope}
|
||||
onChange={setJournalScope}
|
||||
options={[
|
||||
{ value: "all", label: "Все" },
|
||||
{ value: "current", label: status?.budget.name ?? "Текущий" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expenses.items.length === 0 ? (
|
||||
<EmptyState>
|
||||
Записей нет. Добавь расход в <Link to="/operations">Операциях</Link>.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<AppListSection
|
||||
header="Записи"
|
||||
footer={`${expenses.total_count} · ${formatMoney(expenses.total_sum)}`}
|
||||
>
|
||||
{expenses.items.map((item) => (
|
||||
<AppListTile
|
||||
key={item.id}
|
||||
title={formatMoney(item.amount)}
|
||||
subtitle={
|
||||
(item.note || "Без заметки") +
|
||||
(journalScope === "all" ? ` · ${budgetName(item.budget_id)}` : "")
|
||||
}
|
||||
value={formatDate(item.spent_at)}
|
||||
/>
|
||||
))}
|
||||
</AppListSection>
|
||||
)}
|
||||
|
||||
{expenses.total_pages > 1 && (
|
||||
<div className="pager">
|
||||
<Button variant="ghost" size="sm" disabled={page <= 0} onClick={() => setPage(page - 1)}>
|
||||
Назад
|
||||
</Button>
|
||||
<span className="muted">
|
||||
{page + 1} / {expenses.total_pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page + 1 >= expenses.total_pages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Вперёд
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { AuthLayout } from "../components/layout/AuthLayout";
|
||||
import { Flash } from "../components/ui";
|
||||
import { APK_DOWNLOAD_HREF, APK_DOWNLOAD_NAME } from "../brand";
|
||||
import { LegalCheckboxes } from "../legal/LegalCheckboxes";
|
||||
import { SiteFooter } from "../legal/SiteFooter";
|
||||
import { YandexLoginButton } from "../YandexLoginButton";
|
||||
import { yandexCodeFromQuery, yandexErrorFromQuery, yandexRedirectUri } from "../auth/yandexRedirect";
|
||||
import { captureTelegramLinkToken, peekTelegramLinkToken } from "../auth/telegramLink";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { fetchAuthProviders } from "../api";
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, busy, error, loginYandex } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [yandexClientId, setYandexClientId] = useState<string | null>(null);
|
||||
const [configuredRedirect, setConfiguredRedirect] = useState<string | null>(null);
|
||||
const [oauthError, setOauthError] = useState<string | null>(null);
|
||||
const [offerAccepted, setOfferAccepted] = useState(false);
|
||||
const [consentAccepted, setConsentAccepted] = useState(false);
|
||||
const redirectUri = yandexRedirectUri(configuredRedirect);
|
||||
const accepted = offerAccepted && consentAccepted;
|
||||
const tgLink = peekTelegramLinkToken() ?? captureTelegramLinkToken(location.search);
|
||||
|
||||
useEffect(() => {
|
||||
captureTelegramLinkToken(location.search);
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchAuthProviders()
|
||||
.then((providers) => {
|
||||
if (cancelled) return;
|
||||
const yandex = providers.yandex;
|
||||
const id = yandex?.enabled ? yandex.client_id : null;
|
||||
setYandexClientId(id && id.length > 0 ? id : null);
|
||||
setConfiguredRedirect(yandex?.redirect_uri ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setYandexClientId(null);
|
||||
setConfiguredRedirect(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const denied = yandexErrorFromQuery(location.search);
|
||||
if (denied) {
|
||||
setOauthError(denied);
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const code = yandexCodeFromQuery(location.search);
|
||||
if (!code) return;
|
||||
|
||||
void loginYandex(code, redirectUri).finally(() => {
|
||||
navigate("/login", { replace: true });
|
||||
});
|
||||
}, [location.search, loginYandex, navigate, redirectUri]);
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Личный кабинет бюджета"
|
||||
lead={
|
||||
tgLink
|
||||
? "Войдите через Яндекс, чтобы связать Telegram с кабинетом и приложением. Бюджеты из бота останутся на этом аккаунте."
|
||||
: "Вход через Яндекс. Остаток, дневной лимит и учёт трат в одном месте."
|
||||
}
|
||||
cta={
|
||||
busy ? (
|
||||
<p className="muted">Вход…</p>
|
||||
) : (
|
||||
<>
|
||||
{yandexClientId ? (
|
||||
<>
|
||||
<LegalCheckboxes
|
||||
offer={offerAccepted}
|
||||
consent={consentAccepted}
|
||||
onOffer={setOfferAccepted}
|
||||
onConsent={setConsentAccepted}
|
||||
/>
|
||||
<YandexLoginButton
|
||||
clientId={yandexClientId}
|
||||
redirectUri={redirectUri}
|
||||
state={tgLink}
|
||||
disabled={!accepted}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<a
|
||||
className="app-btn app-btn--large app-btn--expanded app-btn--gray"
|
||||
href={APK_DOWNLOAD_HREF}
|
||||
download={APK_DOWNLOAD_NAME}
|
||||
>
|
||||
Скачать для Android
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
flash={<Flash error={error ?? oauthError} notice={null} />}
|
||||
footer={<SiteFooter />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useId, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCabinet } from "../cabinet/CabinetContext";
|
||||
import { Button, Field, PageHeader, Section } from "../components/ui";
|
||||
|
||||
export function OperationsPage() {
|
||||
const amountId = useId();
|
||||
const noteId = useId();
|
||||
const dateId = useId();
|
||||
const budgetId = useId();
|
||||
const {
|
||||
budgets,
|
||||
amount,
|
||||
setAmount,
|
||||
note,
|
||||
setNote,
|
||||
spentAt,
|
||||
setSpentAt,
|
||||
expenseBudgetId,
|
||||
setExpenseBudgetId,
|
||||
saving,
|
||||
noBudget,
|
||||
onAddExpense,
|
||||
onUndo,
|
||||
} = useCabinet();
|
||||
|
||||
const selectedExpenseBudget = useMemo(
|
||||
() => budgets.find((b) => b.budget.id === expenseBudgetId)?.budget,
|
||||
[budgets, expenseBudgetId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Операции"
|
||||
description="Быстрый ввод расхода. Можно писать и на выключенный бюджет."
|
||||
/>
|
||||
|
||||
{noBudget ? (
|
||||
<p className="empty-state">
|
||||
Сначала создай бюджет на странице{" "}
|
||||
<Link to="/budgets">Бюджеты</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<Section title="Расход" description="Сумма спишется с выбранного бюджета.">
|
||||
<form className="form" onSubmit={(e) => void onAddExpense(e)}>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={budgetId}>
|
||||
Бюджет
|
||||
</label>
|
||||
<select
|
||||
id={budgetId}
|
||||
className="field__control"
|
||||
value={expenseBudgetId ?? ""}
|
||||
onChange={(e) =>
|
||||
setExpenseBudgetId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
disabled={saving}
|
||||
required
|
||||
>
|
||||
{budgets.map((item) => (
|
||||
<option key={item.budget.id} value={item.budget.id}>
|
||||
{item.budget.name}
|
||||
{item.budget.is_active ? "" : " · выкл"}
|
||||
{item.selected ? " · текущий" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedExpenseBudget && !selectedExpenseBudget.is_active ? (
|
||||
<p className="field__hint">
|
||||
Бюджет выключен — трата всё равно запишется (даты периода не
|
||||
ограничивают).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="form__row">
|
||||
<Field
|
||||
id={amountId}
|
||||
label="Сумма"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="250"
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<Field
|
||||
id={dateId}
|
||||
label="Дата"
|
||||
type="date"
|
||||
value={spentAt}
|
||||
onChange={(e) => setSpentAt(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
id={noteId}
|
||||
label="Заметка"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="кофе, обед, такси…"
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="form__actions">
|
||||
<Button type="submit" variant="primary" disabled={saving}>
|
||||
{saving ? "Сохранение…" : "Сохранить"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={saving} onClick={() => void onUndo()}>
|
||||
Отменить последнюю
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { formatDate, formatMoney } from "../api";
|
||||
import { useCabinet } from "../cabinet/CabinetContext";
|
||||
import { Banner, PageHeader } from "../components/ui";
|
||||
import {
|
||||
AppListSection,
|
||||
AppListTile,
|
||||
AppProgress,
|
||||
AppSkeleton,
|
||||
AppText,
|
||||
} from "../ui";
|
||||
|
||||
function SkeletonOverview() {
|
||||
return (
|
||||
<section aria-busy="true" aria-label="Загрузка">
|
||||
<AppSkeleton width="8rem" />
|
||||
<div style={{ height: 16 }} />
|
||||
<AppSkeleton width="16rem" height={34} />
|
||||
<div style={{ height: 20 }} />
|
||||
<AppListSection header="Сводка">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<AppListTile key={i} title={<AppSkeleton width="40%" />} value={<AppSkeleton width="4rem" />} />
|
||||
))}
|
||||
</AppListSection>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewPage() {
|
||||
const { status, loading, noBudget } = useCabinet();
|
||||
|
||||
if (loading && !status && !noBudget) {
|
||||
return <SkeletonOverview />;
|
||||
}
|
||||
|
||||
if (noBudget) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Задай бюджет"
|
||||
description="Укажи сумму и дату зарплаты — посчитаем дневной лимит."
|
||||
/>
|
||||
<Link to="/budgets" className="app-btn app-btn--filled app-btn--large app-btn--expanded">
|
||||
Перейти к бюджетам
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<PageHeader
|
||||
title="Обзор"
|
||||
description="Не удалось загрузить статус. Обнови страницу или проверь API."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={`${status.budget.name} · ${formatDate(status.budget.start_date)} — ${formatDate(status.budget.end_date)} · ещё ${status.days_left} дн.${status.budget.is_active ? "" : " · выкл"}`}
|
||||
title="Обзор"
|
||||
description={
|
||||
<>
|
||||
Лимит на сегодня {formatMoney(status.daily_limit)}
|
||||
{status.spent_today > 0
|
||||
? ` · израсходовано ${formatMoney(status.spent_today)}`
|
||||
: ""}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppText variant="footnote">Остаток</AppText>
|
||||
<AppText variant="largeTitle" as="p">
|
||||
{formatMoney(status.remaining)}
|
||||
</AppText>
|
||||
|
||||
<div className="progress">
|
||||
<AppProgress
|
||||
spent={status.total_spent}
|
||||
total={status.budget.total_amount}
|
||||
label="Использование бюджета"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppListSection header="Сводка">
|
||||
<AppListTile title="Бюджет" value={formatMoney(status.budget.total_amount)} />
|
||||
<AppListTile title="Потрачено" value={formatMoney(status.total_spent)} />
|
||||
<AppListTile title="Сегодня" value={formatMoney(status.spent_today)} />
|
||||
<AppListTile
|
||||
title="Ещё сегодня"
|
||||
value={
|
||||
<span className={status.remaining_today < 0 ? "is-warn" : undefined}>
|
||||
{formatMoney(status.remaining_today)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</AppListSection>
|
||||
|
||||
{(status.is_over_budget || status.is_over_daily || status.is_expired) && (
|
||||
<Banner>
|
||||
{status.is_expired
|
||||
? "Период закончился — задай новый бюджет."
|
||||
: status.is_over_budget
|
||||
? "Бюджет превышен."
|
||||
: "Сегодняшний лимит превышен — завтра пересчитается."}
|
||||
</Banner>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useId } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCabinet } from "../cabinet/CabinetContext";
|
||||
import { Button, Field, PageHeader, Section } from "../components/ui";
|
||||
|
||||
/** Period editor for the current budget. */
|
||||
export function PeriodPage() {
|
||||
const nameId = useId();
|
||||
const budgetAmountId = useId();
|
||||
const budgetStartId = useId();
|
||||
const budgetEndId = useId();
|
||||
const {
|
||||
budgetName,
|
||||
setBudgetName,
|
||||
budgetAmount,
|
||||
setBudgetAmount,
|
||||
budgetStart,
|
||||
setBudgetStart,
|
||||
budgetEnd,
|
||||
setBudgetEnd,
|
||||
resetExpenses,
|
||||
setResetExpenses,
|
||||
saving,
|
||||
noBudget,
|
||||
createMode,
|
||||
setCreateMode,
|
||||
onSaveBudget,
|
||||
status,
|
||||
} = useCabinet();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={status?.budget.name}
|
||||
title="Период"
|
||||
description={
|
||||
<>
|
||||
Редактирование текущего бюджета. Список и переключение — в разделе{" "}
|
||||
<Link to="/budgets">Бюджеты</Link>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Section title="Параметры" description="имя · сумма · даты периода">
|
||||
<form className="form" onSubmit={(e) => void onSaveBudget(e)}>
|
||||
<Field
|
||||
id={nameId}
|
||||
label="Название"
|
||||
value={budgetName}
|
||||
onChange={(e) => setBudgetName(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Field
|
||||
id={budgetAmountId}
|
||||
label="Сумма бюджета"
|
||||
inputMode="decimal"
|
||||
value={budgetAmount}
|
||||
onChange={(e) => setBudgetAmount(e.target.value)}
|
||||
placeholder="25000"
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="form__row">
|
||||
<Field
|
||||
id={budgetStartId}
|
||||
label="С даты"
|
||||
type="date"
|
||||
value={budgetStart}
|
||||
onChange={(e) => setBudgetStart(e.target.value)}
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<Field
|
||||
id={budgetEndId}
|
||||
label="До даты"
|
||||
type="date"
|
||||
value={budgetEnd}
|
||||
onChange={(e) => setBudgetEnd(e.target.value)}
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
{!createMode && !noBudget ? (
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resetExpenses}
|
||||
onChange={(e) => setResetExpenses(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>Сбросить старые траты при сохранении</span>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="form__actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
if (noBudget) setCreateMode(true);
|
||||
}}
|
||||
>
|
||||
{saving
|
||||
? "Сохранение…"
|
||||
: noBudget || createMode
|
||||
? "Создать"
|
||||
: "Применить"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
createMyJob,
|
||||
deleteMyJob,
|
||||
fetchMyJobs,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
updateMyJob,
|
||||
} from "../api";
|
||||
import { Button, EmptyState, Field, Flash, PageHeader, Section } from "../components/ui";
|
||||
import type { Job } from "../types";
|
||||
|
||||
const QUICK_DAYS = [1, 5, 10, 15, 20, 25, 28];
|
||||
|
||||
function parsePayDays(raw: string): number[] {
|
||||
const parts = raw
|
||||
.split(/[,;\s]+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const days = parts
|
||||
.map((p) => Number(p))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 31);
|
||||
return [...new Set(days)].sort((a, b) => a - b).slice(0, 2);
|
||||
}
|
||||
|
||||
function formatPayDays(days: number[]): string {
|
||||
return days.join(", ");
|
||||
}
|
||||
|
||||
function weekendLabel(policy: Job["weekend_policy"]): string {
|
||||
return policy === "after_weekend" ? "после выходных" : "до выходных";
|
||||
}
|
||||
|
||||
export function WorkPage() {
|
||||
const nameId = useId();
|
||||
const salaryId = useId();
|
||||
const daysId = useId();
|
||||
const percentId = useId();
|
||||
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [salary, setSalary] = useState("");
|
||||
const [payDaysRaw, setPayDaysRaw] = useState("5, 20");
|
||||
const [firstPercent, setFirstPercent] = useState("40");
|
||||
const [weekendPolicy, setWeekendPolicy] = useState<"before_weekend" | "after_weekend">(
|
||||
"before_weekend",
|
||||
);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const selectedDays = useMemo(() => parsePayDays(payDaysRaw), [payDaysRaw]);
|
||||
const secondPercent = Math.max(0, 100 - Number(firstPercent.replace(",", ".") || 0));
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchMyJobs();
|
||||
setJobs(data.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка загрузки");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const t = window.setTimeout(() => setNotice(null), 3200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [notice]);
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null);
|
||||
setName("");
|
||||
setSalary("");
|
||||
setPayDaysRaw("5, 20");
|
||||
setFirstPercent("40");
|
||||
setWeekendPolicy("before_weekend");
|
||||
setIsActive(true);
|
||||
}
|
||||
|
||||
function startEdit(job: Job) {
|
||||
setEditingId(job.id);
|
||||
setName(job.name);
|
||||
setSalary(String(job.salary_amount));
|
||||
setPayDaysRaw(formatPayDays(job.pay_days));
|
||||
setFirstPercent(String(job.first_pay_percent));
|
||||
setWeekendPolicy(job.weekend_policy);
|
||||
setIsActive(job.is_active);
|
||||
}
|
||||
|
||||
function toggleQuickDay(day: number) {
|
||||
const current = parsePayDays(payDaysRaw);
|
||||
let next: number[];
|
||||
if (current.includes(day)) {
|
||||
next = current.filter((d) => d !== day);
|
||||
} else if (current.length >= 2) {
|
||||
setError("Можно выбрать максимум 2 дня выплаты");
|
||||
return;
|
||||
} else {
|
||||
next = [...current, day].sort((a, b) => a - b);
|
||||
}
|
||||
setError(null);
|
||||
setPayDaysRaw(formatPayDays(next));
|
||||
}
|
||||
|
||||
async function onSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const amount = Number(salary.replace(",", "."));
|
||||
const payDays = parsePayDays(payDaysRaw);
|
||||
const percent = Number(firstPercent.replace(",", "."));
|
||||
if (!name.trim()) {
|
||||
setError("Укажи название работы");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setError("Зарплата должна быть больше нуля");
|
||||
return;
|
||||
}
|
||||
if (payDays.length === 0) {
|
||||
setError("Укажи 1 или 2 дня выплат, например: 5, 20");
|
||||
return;
|
||||
}
|
||||
if (payDays.length > 2) {
|
||||
setError("Можно выбрать максимум 2 дня выплаты");
|
||||
return;
|
||||
}
|
||||
if (payDays.length === 2 && (!Number.isFinite(percent) || percent < 0 || percent > 100)) {
|
||||
setError("Процент первой выплаты — от 0 до 100");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
salary_amount: amount,
|
||||
pay_days: payDays,
|
||||
first_pay_percent: payDays.length === 1 ? 100 : percent,
|
||||
weekend_policy: weekendPolicy,
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
if (editingId == null) {
|
||||
await createMyJob(payload);
|
||||
setNotice("Работа добавлена");
|
||||
} else {
|
||||
await updateMyJob(editingId, payload);
|
||||
setNotice("Работа сохранена");
|
||||
}
|
||||
resetForm();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось сохранить");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(job: Job) {
|
||||
const ok = window.confirm(`Удалить работу «${job.name}»?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await deleteMyJob(job.id);
|
||||
if (editingId === job.id) resetForm();
|
||||
setNotice("Работа удалена");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось удалить");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Работа"
|
||||
description="До двух дней выплат в месяце, доля суммы на каждый день и правило сдвига с субботы/воскресенья."
|
||||
/>
|
||||
|
||||
<Flash error={error} notice={notice} />
|
||||
|
||||
<Section
|
||||
title="Список"
|
||||
description={loading ? "Загрузка…" : `${jobs.length} шт.`}
|
||||
actions={
|
||||
editingId != null ? (
|
||||
<Button variant="ghost" size="sm" disabled={saving} onClick={resetForm}>
|
||||
Новый
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{!loading && jobs.length === 0 ? (
|
||||
<EmptyState>Пока нет работ — добавь первой формой ниже.</EmptyState>
|
||||
) : (
|
||||
<ul className="data-list">
|
||||
{jobs.map((job) => {
|
||||
const days = [...job.pay_days].sort((a, b) => a - b);
|
||||
const p1 = job.first_pay_percent;
|
||||
const p2 = Math.max(0, 100 - p1);
|
||||
const split =
|
||||
days.length === 1
|
||||
? `${days[0]} · 100%`
|
||||
: `${days[0]} · ${p1}% / ${days[1]} · ${p2}%`;
|
||||
const next = job.next_pays?.[0];
|
||||
return (
|
||||
<li key={job.id} className="data-list__row">
|
||||
<div>
|
||||
<span className="data-list__primary">
|
||||
{job.name}
|
||||
{!job.is_active ? " · выкл" : ""}
|
||||
</span>
|
||||
<span className="data-list__secondary">
|
||||
{formatMoney(job.salary_amount, job.currency)} · {split} ·{" "}
|
||||
{weekendLabel(job.weekend_policy)}
|
||||
{next
|
||||
? ` · ближайшая ${formatDate(next.date)} (${formatMoney(next.amount)})`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form__actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() => startEdit(job)}
|
||||
>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() => void onDelete(job)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={editingId == null ? "Новая работа" : "Редактирование"}
|
||||
description="название · зарплата · до 2 дней · распределение · выходные"
|
||||
>
|
||||
<form className="form" onSubmit={(e) => void onSubmit(e)}>
|
||||
<Field
|
||||
id={nameId}
|
||||
label="Название"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Основная работа"
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<Field
|
||||
id={salaryId}
|
||||
label="Зарплата"
|
||||
inputMode="decimal"
|
||||
value={salary}
|
||||
onChange={(e) => setSalary(e.target.value)}
|
||||
placeholder="80000"
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<Field
|
||||
id={daysId}
|
||||
label="Дни выплат"
|
||||
value={payDaysRaw}
|
||||
onChange={(e) => {
|
||||
const typed = e.target.value;
|
||||
const parsed = parsePayDays(typed);
|
||||
const tokens = typed.split(/[,;\s]+/).filter(Boolean);
|
||||
if (tokens.length > 2) {
|
||||
setPayDaysRaw(formatPayDays(parsed));
|
||||
setError("Можно выбрать максимум 2 дня выплаты");
|
||||
} else {
|
||||
setPayDaysRaw(typed);
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="5, 20"
|
||||
required
|
||||
disabled={saving}
|
||||
hint={
|
||||
<p className="field__hint muted">
|
||||
Максимум два числа месяца. Если дня нет в месяце — последний день месяца.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="day-chips" role="group" aria-label="Быстрый выбор дней">
|
||||
{QUICK_DAYS.map((day) => {
|
||||
const on = selectedDays.includes(day);
|
||||
const blocked = !on && selectedDays.length >= 2;
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`day-chip${on ? " is-on" : ""}`}
|
||||
disabled={saving || blocked}
|
||||
onClick={() => toggleQuickDay(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedDays.length === 2 ? (
|
||||
<div className="split-block">
|
||||
<Field
|
||||
id={percentId}
|
||||
label={`Доля на ${selectedDays[0]} число, %`}
|
||||
inputMode="decimal"
|
||||
value={firstPercent}
|
||||
onChange={(e) => setFirstPercent(e.target.value)}
|
||||
disabled={saving}
|
||||
hint={
|
||||
<p className="field__hint muted">
|
||||
На {selectedDays[0]} — {Number.isFinite(Number(firstPercent)) ? firstPercent : "?"}%,
|
||||
на {selectedDays[1]} —{" "}
|
||||
{Number.isFinite(secondPercent) ? secondPercent.toFixed(0) : "?"}%
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<fieldset className="choice-set">
|
||||
<legend className="field__label">Если день выпал на сб/вс</legend>
|
||||
<label className="check">
|
||||
<input
|
||||
type="radio"
|
||||
name="weekend"
|
||||
checked={weekendPolicy === "before_weekend"}
|
||||
onChange={() => setWeekendPolicy("before_weekend")}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>Перед выходными (пт) — сб→пт, вс→пт</span>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
type="radio"
|
||||
name="weekend"
|
||||
checked={weekendPolicy === "after_weekend"}
|
||||
onChange={() => setWeekendPolicy("after_weekend")}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>После выходных (пн) — сб→пн, вс→пн</span>
|
||||
</label>
|
||||
<p className="field__hint muted">
|
||||
Пример: 20 число — суббота → выплата 19 (до) или 22 (после).
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>Активна</span>
|
||||
</label>
|
||||
|
||||
<div className="form__actions">
|
||||
<Button type="submit" variant="primary" disabled={saving}>
|
||||
{saving
|
||||
? "Сохранение…"
|
||||
: editingId == null
|
||||
? "Добавить"
|
||||
: "Сохранить"}
|
||||
</Button>
|
||||
{editingId != null ? (
|
||||
<Button variant="ghost" disabled={saving} onClick={resetForm}>
|
||||
Отмена
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Design system entry — tokens, base, product components. */
|
||||
@import "./design/index.css";
|
||||
@@ -0,0 +1,109 @@
|
||||
export type Budget = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
total_amount: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
currency: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type BudgetStatus = {
|
||||
budget: Budget;
|
||||
today: string;
|
||||
days_left: number;
|
||||
total_spent: number;
|
||||
remaining: number;
|
||||
daily_limit: number;
|
||||
spent_today: number;
|
||||
remaining_today: number;
|
||||
is_over_daily: boolean;
|
||||
is_over_budget: boolean;
|
||||
is_expired: boolean;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type Expense = {
|
||||
id: number;
|
||||
budget_id: number;
|
||||
amount: number;
|
||||
note: string | null;
|
||||
spent_at: string;
|
||||
};
|
||||
|
||||
export type ExpensesPage = {
|
||||
page: number;
|
||||
total_pages: number;
|
||||
total_count: number;
|
||||
page_size: number;
|
||||
total_sum: number;
|
||||
budget_id: number | null;
|
||||
items: Expense[];
|
||||
};
|
||||
|
||||
export type ExpensesRange = {
|
||||
items: Expense[];
|
||||
};
|
||||
|
||||
export type BudgetsList = {
|
||||
items: BudgetStatus[];
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
salary_amount: number;
|
||||
currency: string;
|
||||
pay_days: number[];
|
||||
first_pay_percent: number;
|
||||
weekend_policy: "before_weekend" | "after_weekend";
|
||||
is_active: boolean;
|
||||
next_pays: UpcomingPay[];
|
||||
};
|
||||
|
||||
export type UpcomingPay = {
|
||||
date: string;
|
||||
scheduled_day: number;
|
||||
percent: number;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type JobsList = {
|
||||
items: Job[];
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
user_id: number;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
username?: string | null;
|
||||
photo_url?: string | null;
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export type TelegramLoginPayload = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
export type YandexProvider = {
|
||||
enabled: boolean;
|
||||
client_id?: string | null;
|
||||
redirect_uri?: string | null;
|
||||
};
|
||||
|
||||
export type AuthProviders = {
|
||||
yandex: YandexProvider;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type AppButtonStyle = "filled" | "tinted" | "gray" | "plain" | "destructive";
|
||||
export type AppButtonSize = "large" | "medium" | "small";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
label?: ReactNode;
|
||||
children?: ReactNode;
|
||||
buttonStyle?: AppButtonStyle;
|
||||
size?: AppButtonSize;
|
||||
expanded?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function AppButton({
|
||||
label,
|
||||
children,
|
||||
buttonStyle = "filled",
|
||||
size = "large",
|
||||
expanded = true,
|
||||
loading = false,
|
||||
className = "",
|
||||
disabled,
|
||||
type = "button",
|
||||
...rest
|
||||
}: Props) {
|
||||
const classes = [
|
||||
"app-btn",
|
||||
`app-btn--${buttonStyle}`,
|
||||
`app-btn--${size}`,
|
||||
expanded ? "app-btn--expanded" : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button type={type} className={classes} disabled={disabled || loading} {...rest}>
|
||||
{loading ? <span className="app-spinner" aria-hidden="true" /> : (label ?? children)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppButton } from "./AppButton";
|
||||
import { AppText } from "./AppText";
|
||||
|
||||
export function AppSpinner() {
|
||||
return <span className="app-spinner" role="status" aria-label="Загрузка" />;
|
||||
}
|
||||
|
||||
export function AppSkeleton({ width = "100%", height = 12 }: { width?: string | number; height?: number }) {
|
||||
return <span className="app-skeleton" style={{ width, height }} />;
|
||||
}
|
||||
|
||||
export function AppProgress({
|
||||
spent,
|
||||
total,
|
||||
label,
|
||||
}: {
|
||||
spent: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0;
|
||||
const tone = pct >= 100 ? "is-danger" : pct >= 85 ? "is-warn" : "";
|
||||
return (
|
||||
<div className="app-progress">
|
||||
<div
|
||||
className="app-progress__track"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label ?? "Потрачено от бюджета"}
|
||||
>
|
||||
<span className={`app-progress__bar ${tone}`.trim()} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppEmptyView({
|
||||
title = "Пусто",
|
||||
message,
|
||||
action,
|
||||
}: {
|
||||
title?: string;
|
||||
message?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="app-empty">
|
||||
<AppText variant="headline">{title}</AppText>
|
||||
{message ? <AppText variant="subhead">{message}</AppText> : null}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppErrorView({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="app-error">
|
||||
<AppText variant="headline">Не удалось загрузить</AppText>
|
||||
<AppText variant="subhead">{message}</AppText>
|
||||
{onRetry ? (
|
||||
<AppButton label="Повторить" buttonStyle="tinted" size="medium" expanded={false} onClick={onRetry} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppChip({
|
||||
label,
|
||||
selected = false,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`app-chip${selected ? " is-on" : ""}`} onClick={onClick} disabled={disabled}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppAvatar({ initials }: { initials: string }) {
|
||||
return <span className="app-avatar">{initials}</span>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppSectionHeader } from "./AppText";
|
||||
|
||||
type SectionProps = {
|
||||
header?: string;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
flush?: boolean;
|
||||
};
|
||||
|
||||
export function AppListSection({ header, footer, children, flush = false }: SectionProps) {
|
||||
return (
|
||||
<section>
|
||||
{header ? <AppSectionHeader>{header}</AppSectionHeader> : null}
|
||||
<div className={`app-list${flush ? " app-list--flush" : ""}`}>{children}</div>
|
||||
{footer ? <p className="app-section-footer">{footer}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type TileProps = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
value?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
chevron?: boolean;
|
||||
destructive?: boolean;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export function AppListTile({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
leading,
|
||||
trailing,
|
||||
chevron = false,
|
||||
destructive = false,
|
||||
onClick,
|
||||
href,
|
||||
}: TileProps) {
|
||||
const interactive = Boolean(onClick || href);
|
||||
const className = `app-tile${destructive ? " app-tile--destructive" : ""}`;
|
||||
const body = (
|
||||
<>
|
||||
{leading}
|
||||
<div className="app-tile__body">
|
||||
<p className="app-tile__title">{title}</p>
|
||||
{subtitle ? <p className="app-tile__subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
{trailing ?? (value != null ? <p className="app-tile__value">{value}</p> : null)}
|
||||
{chevron ? <span className="app-tile__chevron" aria-hidden="true">›</span> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a className={className} href={href}>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={className}>{body}</div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
type Option<T extends string> = { value: T; label: string };
|
||||
|
||||
type Props<T extends string> = {
|
||||
value: T;
|
||||
options: Option<T>[];
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function AppSegmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="app-segmented" role="tablist" aria-label={ariaLabel}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={option.value === value}
|
||||
className={`app-segmented__item${option.value === value ? " is-on" : ""}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
|
||||
export type AppTextVariant =
|
||||
| "largeTitle"
|
||||
| "title1"
|
||||
| "title2"
|
||||
| "title3"
|
||||
| "headline"
|
||||
| "body"
|
||||
| "callout"
|
||||
| "subhead"
|
||||
| "footnote"
|
||||
| "caption";
|
||||
|
||||
export type AppTextTone = "label" | "secondary" | "tertiary" | "accent" | "danger";
|
||||
|
||||
const DEFAULT_TONE: Record<AppTextVariant, AppTextTone> = {
|
||||
largeTitle: "label",
|
||||
title1: "label",
|
||||
title2: "label",
|
||||
title3: "label",
|
||||
headline: "label",
|
||||
body: "label",
|
||||
callout: "secondary",
|
||||
subhead: "secondary",
|
||||
footnote: "secondary",
|
||||
caption: "tertiary",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
variant?: AppTextVariant;
|
||||
tone?: AppTextTone;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppText({
|
||||
children,
|
||||
variant = "body",
|
||||
tone,
|
||||
as: Tag = "p",
|
||||
className = "",
|
||||
}: Props) {
|
||||
const color = tone ?? DEFAULT_TONE[variant];
|
||||
return (
|
||||
<Tag className={`app-text app-text--${variant} app-text--${color} ${className}`.trim()}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSectionHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="app-section-header">
|
||||
{typeof children === "string" ? children.toUpperCase() : children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||
import { AppSectionHeader } from "./AppText";
|
||||
|
||||
type FieldProps = {
|
||||
label?: string;
|
||||
hint?: ReactNode;
|
||||
error?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppTextField({
|
||||
label,
|
||||
id,
|
||||
hint,
|
||||
error,
|
||||
className = "",
|
||||
...rest
|
||||
}: InputHTMLAttributes<HTMLInputElement> & FieldProps & { id: string }) {
|
||||
return (
|
||||
<div className={`app-field ${error ? "app-field--error" : ""} ${className}`.trim()}>
|
||||
{label ? (
|
||||
<label htmlFor={id}>
|
||||
<AppSectionHeader>{label}</AppSectionHeader>
|
||||
</label>
|
||||
) : null}
|
||||
<input id={id} className="app-field__control" {...rest} />
|
||||
{error ? <p className="app-field__error">{error}</p> : hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSelect({
|
||||
label,
|
||||
id,
|
||||
hint,
|
||||
error,
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: SelectHTMLAttributes<HTMLSelectElement> & FieldProps & { id: string }) {
|
||||
return (
|
||||
<div className={`app-field ${error ? "app-field--error" : ""} ${className}`.trim()}>
|
||||
{label ? (
|
||||
<label htmlFor={id}>
|
||||
<AppSectionHeader>{label}</AppSectionHeader>
|
||||
</label>
|
||||
) : null}
|
||||
<select id={id} className="app-field__control" {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
{error ? <p className="app-field__error">{error}</p> : hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { AppText, AppSectionHeader } from "./AppText";
|
||||
export { AppButton } from "./AppButton";
|
||||
export type { AppButtonSize, AppButtonStyle } from "./AppButton";
|
||||
export { AppTextField, AppSelect } from "./AppTextField";
|
||||
export { AppListSection, AppListTile } from "./AppList";
|
||||
export { AppSegmented } from "./AppSegmented";
|
||||
export {
|
||||
AppSpinner,
|
||||
AppSkeleton,
|
||||
AppProgress,
|
||||
AppEmptyView,
|
||||
AppErrorView,
|
||||
AppChip,
|
||||
AppAvatar,
|
||||
} from "./AppFeedback";
|
||||
@@ -0,0 +1,346 @@
|
||||
/* iOS kit — mirrors mobile/lib/ui */
|
||||
|
||||
.app-text {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.app-text--largeTitle {
|
||||
font-size: var(--text-large-title);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.37px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.app-text--title1 {
|
||||
font-size: var(--text-title1);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.36px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.app-text--title2 {
|
||||
font-size: var(--text-title2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.35px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.app-text--title3 {
|
||||
font-size: var(--text-title3);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.38px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.app-text--headline {
|
||||
font-size: var(--text-headline);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.41px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--body {
|
||||
font-size: var(--text-body);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.41px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--callout {
|
||||
font-size: var(--text-callout);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.32px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--subhead {
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--footnote {
|
||||
font-size: var(--text-footnote);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.08px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--caption {
|
||||
font-size: var(--text-caption1);
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.app-text--label { color: var(--label); }
|
||||
.app-text--secondary { color: var(--secondary-label); }
|
||||
.app-text--tertiary { color: var(--tertiary-label); }
|
||||
.app-text--accent { color: var(--accent); }
|
||||
.app-text--danger { color: var(--system-red); }
|
||||
|
||||
.app-section-header {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: var(--space-4) var(--gutter) var(--space-2);
|
||||
font-size: var(--text-footnote);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.app-section-footer {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--gutter) 0;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.app-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.41px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: filter 140ms ease, opacity 140ms ease;
|
||||
}
|
||||
|
||||
.app-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-btn--expanded { width: 100%; }
|
||||
.app-btn--large {
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-headline);
|
||||
}
|
||||
.app-btn--medium {
|
||||
height: var(--control-height-md);
|
||||
padding: 0 var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
.app-btn--small {
|
||||
height: var(--control-height-sm);
|
||||
padding: 0 var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.app-btn--filled { background: var(--accent); color: #fff; }
|
||||
.app-btn--tinted { background: rgb(18 136 90 / 15%); color: var(--accent); }
|
||||
.app-btn--gray { background: var(--system-gray5); color: var(--label); }
|
||||
.app-btn--plain { background: transparent; color: var(--accent); }
|
||||
.app-btn--destructive { background: rgb(255 59 48 / 15%); color: var(--system-red); }
|
||||
|
||||
.app-btn:hover:not(:disabled) { filter: brightness(1.04); }
|
||||
|
||||
.app-field { display: grid; gap: var(--space-2); }
|
||||
.app-field .app-section-header {
|
||||
padding: 0;
|
||||
}
|
||||
.app-field__control {
|
||||
width: 100%;
|
||||
height: var(--control-height-md);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--grouped-surface);
|
||||
color: var(--label);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
}
|
||||
.app-field__control:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.app-field--error .app-field__control {
|
||||
box-shadow: inset 0 0 0 1px var(--system-red);
|
||||
}
|
||||
.app-field__error {
|
||||
margin: 0;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--system-red);
|
||||
}
|
||||
|
||||
.app-list {
|
||||
margin: 0 var(--gutter);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--grouped-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-list--flush {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.app-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
min-height: var(--row-min-height);
|
||||
padding: 10px var(--gutter);
|
||||
border: 0;
|
||||
background: var(--grouped-surface);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
button.app-tile,
|
||||
a.app-tile {
|
||||
cursor: pointer;
|
||||
}
|
||||
button.app-tile:hover,
|
||||
a.app-tile:hover {
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
.app-tile + .app-tile {
|
||||
box-shadow: inset 0 var(--hairline) 0 var(--separator);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.app-tile__body { flex: 1; min-width: 0; }
|
||||
.app-tile__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
color: var(--label);
|
||||
}
|
||||
.app-tile--destructive .app-tile__title { color: var(--system-red); }
|
||||
.app-tile__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
.app-tile__value {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
color: var(--secondary-label);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.app-tile__chevron {
|
||||
color: var(--tertiary-label);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
font-size: var(--text-subhead);
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-chip.is-on {
|
||||
background: rgb(18 136 90 / 15%);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-progress__track {
|
||||
height: 4px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--system-gray5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-progress__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.app-progress__bar.is-warn { background: var(--system-orange); }
|
||||
.app-progress__bar.is-danger { background: var(--system-red); }
|
||||
|
||||
.app-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--system-gray3);
|
||||
border-top-color: var(--secondary-label);
|
||||
border-radius: 50%;
|
||||
animation: app-spin 700ms linear infinite;
|
||||
}
|
||||
@keyframes app-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.app-skeleton {
|
||||
display: block;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
|
||||
.app-empty,
|
||||
.app-error {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-7) var(--gutter);
|
||||
text-align: center;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.app-segmented {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
border-radius: 9px;
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
.app-segmented__item {
|
||||
flex: 1;
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--label);
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-segmented__item.is-on {
|
||||
background: var(--grouped-surface);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: rgb(28 28 30 / 88%);
|
||||
color: #fff;
|
||||
font-size: var(--text-subhead);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.app-btn--tinted { background: rgb(60 214 140 / 18%); }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user