Author SHA1 Message Date
lauadminandClaude Opus 5.5 8fffdc3ce6 Add main menu page with role-based modules (#7)
- `/` is now the main menu; orders/receipts page moved to `/receipts`
- Module registry (features/menu/modules.ts) drives both the menu tiles
  and route guards (ModuleRoute); admin sees every module
- Menu: module search with Ctrl+K, profile with logout, responsive grid,
  palette matching the login page
- «← Головне меню» link on receipts and cash registers pages;
  «Каси» moved from the receipts toolbar into the menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:11:57 +03:00
lauadmin 0ba2ca01c4 Merge pull request 'Редизайн страницы входа и перевод интерфейса на украинский (#5)' (#6) from feature/login-page-redesign into main 2026-09-25 13:46:25 +00:00
lauadminandClaude Opus 5.5 b8f2fe6b6e Translate UI and user-facing messages to Ukrainian (#5)
- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format
- Brand "Assistant System" in the top bar and page title
- Backend error details returned to the UI (auth, orders, receipts,
  cash registers, Checkbox/CRM/NP errors) and CLI output
- Tests updated for the new messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:27:39 +03:00
lauadminandClaude Opus 5.5 47df3a78dc Redesign login page (#5)
- Rename header to "Assistant System", subtitle to "Вхід у систему"
- Glassmorphism card over animated gradient backdrop, logo mark
- Password visibility toggle, larger inputs, focus rings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:21:11 +03:00
47 changed files with 1199 additions and 284 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
_UNAUTHORIZED = HTTPException( _UNAUTHORIZED = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация", detail="Потрібна автентифікація",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
@@ -76,7 +76,7 @@ def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
if user.role not in allowed: if user.role not in allowed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для этого действия", detail="Недостатньо прав для цієї дії",
) )
return user return user
+1 -1
View File
@@ -70,7 +70,7 @@ async def change_password(
) -> Response: ) -> Response:
if not verify_password(payload.current_password, user.password_hash): if not verify_password(payload.current_password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Текущий пароль указан неверно" status_code=status.HTTP_400_BAD_REQUEST, detail="Поточний пароль вказано неправильно"
) )
user.password_hash = hash_password(payload.new_password) user.password_hash = hash_password(payload.new_password)
+3 -3
View File
@@ -44,7 +44,7 @@ async def _ensure_np_key_unique(
if same: if same:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Этот ключ Новой Почты уже привязан к кассе «{register.name}»", detail=f"Цей ключ Нової Пошти вже прив'язаний до каси «{register.name}»",
) )
@@ -101,7 +101,7 @@ async def update_cash_register(
) -> CashRegisterOut: ) -> CashRegisterOut:
register = await session.get(CashRegister, register_id) register = await session.get(CashRegister, register_id)
if register is None: if register is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касу не знайдено")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "license_key" in changes: if "license_key" in changes:
@@ -139,7 +139,7 @@ async def check_cash_register(
"""Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN.""" """Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN."""
register = await session.get(CashRegister, register_id) register = await session.get(CashRegister, register_id)
if register is None: if register is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касу не знайдено")
try: try:
await checkbox.sign_in(credentials(register)) await checkbox.sign_in(credentials(register))
except (CheckboxError, crypto.DecryptionError) as exc: except (CheckboxError, crypto.DecryptionError) as exc:
+2 -2
View File
@@ -50,7 +50,7 @@ async def update_order(
except orders_service.OrderEditError as exc: except orders_service.OrderEditError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
if result is None: if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
order, changed = result order, changed = result
if changed: if changed:
@@ -75,7 +75,7 @@ async def delete_order(
) -> None: ) -> None:
order = await orders_service.delete_order(session, order_id) order = await orders_service.delete_order(session, order_id)
if order is None: if order is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
await audit.record( await audit.record(
session, session,
+2 -2
View File
@@ -60,7 +60,7 @@ async def list_receipts(session: SessionDep, order_id: str | None = None) -> lis
async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut: async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut:
receipt = await session.get(Receipt, receipt_id) receipt = await session.get(Receipt, receipt_id)
if receipt is None: if receipt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
return ReceiptOut.from_receipt(receipt) return ReceiptOut.from_receipt(receipt)
@@ -83,5 +83,5 @@ async def cancel_receipt(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}" status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}"
) from exc ) from exc
if receipt is None: if receipt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
return ReceiptOut.from_receipt(receipt) return ReceiptOut.from_receipt(receipt)
+4 -4
View File
@@ -71,7 +71,7 @@ async def create_user(
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует", detail="Користувач із таким email уже існує",
) from exc ) from exc
return UserOut.model_validate(user) return UserOut.model_validate(user)
@@ -87,7 +87,7 @@ async def update_user(
) -> UserOut: ) -> UserOut:
user = await session.get(User, user_id) user = await session.get(User, user_id)
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Користувача не знайдено")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
@@ -97,12 +97,12 @@ async def update_user(
if changes.get("is_active") is False: if changes.get("is_active") is False:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отключить собственную учётную запись", detail="Не можна вимкнути власний обліковий запис",
) )
if "role" in changes and changes["role"] != UserRole.ADMIN: if "role" in changes and changes["role"] != UserRole.ADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя снять с себя роль администратора", detail="Не можна зняти із себе роль адміністратора",
) )
if (new_password := changes.pop("password", None)) is not None: if (new_password := changes.pop("password", None)) is not None:
+5 -5
View File
@@ -25,7 +25,7 @@ async def bootstrap() -> int:
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает. Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
""" """
if not settings.first_admin_password: if not settings.first_admin_password:
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr) print("FIRST_ADMIN_PASSWORD не задано в .env", file=sys.stderr)
return 1 return 1
email = settings.first_admin_email.strip().lower() email = settings.first_admin_email.strip().lower()
@@ -33,7 +33,7 @@ async def bootstrap() -> int:
async with session_scope() as session: async with session_scope() as session:
existing = await session.scalar(select(User).where(User.email == email)) existing = await session.scalar(select(User).where(User.email == email))
if existing is not None: if existing is not None:
print(f"Пользователь {email} уже существует — ничего не изменено.") print(f"Користувач {email} уже існує — нічого не змінено.")
return 0 return 0
session.add( session.add(
@@ -45,8 +45,8 @@ async def bootstrap() -> int:
) )
) )
print(f"Администратор {email} создан.") print(f"Адміністратора {email} створено.")
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.") print("Змініть пароль після першого входу та приберіть FIRST_ADMIN_PASSWORD з .env.")
return 0 return 0
@@ -59,7 +59,7 @@ def gen_keys() -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(prog="app.cli", description="Служебные команды lux_fiscal") parser = argparse.ArgumentParser(prog="app.cli", description="Службові команди lux_fiscal")
parser.add_argument("command", choices=["bootstrap", "gen-keys"]) parser.add_argument("command", choices=["bootstrap", "gen-keys"])
args = parser.parse_args() args = parser.parse_args()
+1 -1
View File
@@ -53,7 +53,7 @@ class Settings(BaseSettings):
# --- Первый администратор (только для команды bootstrap) --- # --- Первый администратор (только для команды bootstrap) ---
first_admin_email: str = "admin@example.com" first_admin_email: str = "admin@example.com"
first_admin_password: str = "" first_admin_password: str = ""
first_admin_name: str = "Администратор" first_admin_name: str = "Адміністратор"
# --- CRM --- # --- CRM ---
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/" crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
+3 -3
View File
@@ -26,7 +26,7 @@ def _fernet() -> Fernet:
return Fernet(settings.encryption_key.encode()) return Fernet(settings.encryption_key.encode())
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
raise RuntimeError( raise RuntimeError(
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: " "ENCRYPTION_KEY некоректний. Згенеруйте валідний ключ: "
"python -c \"from cryptography.fernet import Fernet; " "python -c \"from cryptography.fernet import Fernet; "
'print(Fernet.generate_key().decode())"' 'print(Fernet.generate_key().decode())"'
) from exc ) from exc
@@ -41,8 +41,8 @@ def decrypt(value: str) -> str:
return _fernet().decrypt(value.encode()).decode() return _fernet().decrypt(value.encode()).decode()
except InvalidToken as exc: except InvalidToken as exc:
raise DecryptionError( raise DecryptionError(
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY " "Не вдалося розшифрувати значення. Імовірна причина — ENCRYPTION_KEY "
"изменился с момента сохранения. Секрет нужно ввести заново." "змінився з моменту збереження. Секрет потрібно ввести заново."
) from exc ) from exc
+3 -3
View File
@@ -67,13 +67,13 @@ def decode_access_token(token: str) -> dict[str, Any]:
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
raise TokenError("Срок действия токена истёк") from exc raise TokenError("Термін дії токена минув") from exc
except jwt.InvalidTokenError as exc: except jwt.InvalidTokenError as exc:
raise TokenError("Некорректный токен") from exc raise TokenError("Некоректний токен") from exc
# Без этой проверки refresh-токен можно было бы предъявить как access. # Без этой проверки refresh-токен можно было бы предъявить как access.
if payload.get("type") != "access": if payload.get("type") != "access":
raise TokenError("Ожидался access-токен") raise TokenError("Очікувався access-токен")
return payload return payload
+1 -1
View File
@@ -78,7 +78,7 @@ def create_app() -> FastAPI:
app = FastAPI( app = FastAPI(
title=settings.project_name, title=settings.project_name,
version="0.1.0", version="0.1.0",
description="Фискализация заказов через Checkbox", description="Фіскалізація замовлень через Checkbox",
lifespan=lifespan, lifespan=lifespan,
# В проде интерактивная документация закрыта: схема API — лишняя # В проде интерактивная документация закрыта: схема API — лишняя
# подсказка для того, кто ищет незащищённый эндпоинт. # подсказка для того, кто ищет незащищённый эндпоинт.
+1 -1
View File
@@ -22,7 +22,7 @@ class TokenPair(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
expires_in: int = Field(description="Время жизни access-токена в секундах") expires_in: int = Field(description="Час життя access-токена в секундах")
class UserOut(BaseModel): class UserOut(BaseModel):
+1 -1
View File
@@ -74,7 +74,7 @@ def _masked(encrypted: str) -> str:
try: try:
return crypto.mask(crypto.decrypt(encrypted)) return crypto.mask(crypto.decrypt(encrypted))
except crypto.DecryptionError: except crypto.DecryptionError:
return "не расшифровывается — введите заново" return "не розшифровується — введіть заново"
class CashRegisterOut(BaseModel): class CashRegisterOut(BaseModel):
+6 -6
View File
@@ -53,7 +53,7 @@ async def authenticate(
request=request, request=request,
) )
await session.commit() await session.commit()
raise AuthError("Неверный email или пароль") raise AuthError("Невірний email або пароль")
if not user.is_active: if not user.is_active:
await audit.record( await audit.record(
@@ -64,7 +64,7 @@ async def authenticate(
request=request, request=request,
) )
await session.commit() await session.commit()
raise AuthError("Учётная запись отключена") raise AuthError("Обліковий запис вимкнено")
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле. # Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
if password_needs_rehash(user.password_hash): if password_needs_rehash(user.password_hash):
@@ -117,7 +117,7 @@ async def rotate_refresh_token(
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if stored is None: if stored is None:
raise AuthError("Некорректный refresh-токен") raise AuthError("Некоректний refresh-токен")
if stored.revoked_at is not None: if stored.revoked_at is not None:
await revoke_all_for_user(session, stored.user_id) await revoke_all_for_user(session, stored.user_id)
@@ -131,14 +131,14 @@ async def rotate_refresh_token(
) )
await session.commit() await session.commit()
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id)) log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
raise AuthError("Сессия отозвана, требуется повторный вход") raise AuthError("Сесію відкликано, потрібен повторний вхід")
if stored.expires_at <= datetime.now(UTC): if stored.expires_at <= datetime.now(UTC):
raise AuthError("Срок действия refresh-токена истёк") raise AuthError("Термін дії refresh-токена минув")
user = await session.get(User, stored.user_id) user = await session.get(User, stored.user_id)
if user is None or not user.is_active: if user is None or not user.is_active:
raise AuthError("Учётная запись недоступна") raise AuthError("Обліковий запис недоступний")
pair = await issue_token_pair(session, user, request=request, replaces=stored) pair = await issue_token_pair(session, user, request=request, replaces=stored)
await audit.record( await audit.record(
+1 -1
View File
@@ -63,7 +63,7 @@ def get_checkbox_client() -> CheckboxClient:
"""Один экземпляр на процесс: внутри — кэш токенов кассиров.""" """Один экземпляр на процесс: внутри — кэш токенов кассиров."""
if settings.checkbox_use_stub: if settings.checkbox_use_stub:
if settings.is_production: if settings.is_production:
raise RuntimeError("CHECKBOX_USE_STUB=true запрещён в production") raise RuntimeError("CHECKBOX_USE_STUB=true заборонено в production")
from app.services.checkbox.stub_client import StubCheckboxClient from app.services.checkbox.stub_client import StubCheckboxClient
return StubCheckboxClient(auto_complete_after=2) return StubCheckboxClient(auto_complete_after=2)
+3 -3
View File
@@ -57,7 +57,7 @@ def _error_message(response: httpx.Response) -> str:
# {"code": "third_party.*", "message": "Internal Server Error"} — # {"code": "third_party.*", "message": "Internal Server Error"} —
# без кода кассир видит только бесполезное «Internal Server Error». # без кода кассир видит только бесполезное «Internal Server Error».
if code := data.get("code"): if code := data.get("code"):
message = f"{message or 'Ошибка'} ({code})" message = f"{message or 'Помилка'} ({code})"
if isinstance(detail, list) and detail: if isinstance(detail, list) and detail:
parts = [ parts = [
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}" f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}"
@@ -130,7 +130,7 @@ class HttpCheckboxClient:
method, path, headers=headers, json=json, params=params method, path, headers=headers, json=json, params=params
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc raise CheckboxUnavailableError(f"Checkbox недоступний: {exc!r}") from exc
if error := _transient_error(response): if error := _transient_error(response):
raise error raise error
return response return response
@@ -155,7 +155,7 @@ class HttpCheckboxClient:
json={"pin_code": creds.pin_code}, json={"pin_code": creds.pin_code},
) )
if response.status_code != 200: if response.status_code != 200:
raise CheckboxError(f"Вход кассира не удался: {_error_message(response)}") raise CheckboxError(f"Вхід касира не вдався: {_error_message(response)}")
token = response.json()["access_token"] token = response.json()["access_token"]
self._tokens[creds.cash_register_id] = token self._tokens[creds.cash_register_id] = token
return token return token
+3 -3
View File
@@ -32,12 +32,12 @@ class StubCheckboxClient:
async def sign_in(self, creds: CheckboxCredentials) -> None: async def sign_in(self, creds: CheckboxCredentials) -> None:
if not creds.license_key or not creds.pin_code: if not creds.license_key or not creds.pin_code:
raise CheckboxError("Вход кассира не удался: пустой ключ или PIN") raise CheckboxError("Вхід касира не вдався: порожній ключ або PIN")
async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut: async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut:
waybill = body["receipt_body"]["payments"][0]["ettn"] waybill = body["receipt_body"]["payments"][0]["ettn"]
if await self.find_ettn(creds, waybill) is not None: if await self.find_ettn(creds, waybill) is not None:
raise CheckboxError(f"ЕТТН {waybill} уже привязана к чеку") raise CheckboxError(f"ЕТТН {waybill} вже прив'язана до чека")
ettn = EttnOut( ettn = EttnOut(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
status=EttnStatus.CREATED, status=EttnStatus.CREATED,
@@ -50,7 +50,7 @@ class StubCheckboxClient:
async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut:
if ettn_id not in self.orders: if ettn_id not in self.orders:
raise CheckboxError(f"ЕТТН-чек {ettn_id} не найден") raise CheckboxError(f"ЕТТН-чек {ettn_id} не знайдено")
self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1 self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1
if ( if (
self.auto_complete_after is not None self.auto_complete_after is not None
+5 -5
View File
@@ -39,10 +39,10 @@ class ExoCrmClient:
data = response.json() data = response.json()
except ValueError as exc: except ValueError as exc:
# PHP-notice'ы CRM перед JSON — признак неверных параметров. # PHP-notice'ы CRM перед JSON — признак неверных параметров.
raise CrmError(f"CRM вернула не JSON: {response.text[:300]}") from exc raise CrmError(f"CRM повернула не JSON: {response.text[:300]}") from exc
if not isinstance(data, dict): if not isinstance(data, dict):
raise CrmError(f"Неожиданный ответ CRM: {str(data)[:300]}") raise CrmError(f"Неочікувана відповідь CRM: {str(data)[:300]}")
return data return data
async def get_orders(self, *, status: str) -> list[OrderOut]: async def get_orders(self, *, status: str) -> list[OrderOut]:
@@ -57,7 +57,7 @@ class ExoCrmClient:
}, },
) )
if data.get("status") != "OK": if data.get("status") != "OK":
raise CrmError(f"CRM вернула ошибку: {_errors_text(data) or 'неизвестная ошибка'}") raise CrmError(f"CRM повернула помилку: {_errors_text(data) or 'невідома помилка'}")
return [OrderOut.model_validate(order) for order in data.get("result") or []] return [OrderOut.model_validate(order) for order in data.get("result") or []]
async def set_status(self, *, order_id: str, status: str) -> None: async def set_status(self, *, order_id: str, status: str) -> None:
@@ -73,6 +73,6 @@ class ExoCrmClient:
if isinstance(result, dict) and result.get("Status") == "Success": if isinstance(result, dict) and result.get("Status") == "Success":
return return
details = _errors_text(data) or ( details = _errors_text(data) or (
str(result) if result is not None else "нет ответа по заказу" str(result) if result is not None else "немає відповіді щодо замовлення"
) )
raise CrmError(f"CRM не сменила статус заказа {order_id} на {status}: {details}") raise CrmError(f"CRM не змінила статус замовлення {order_id} на {status}: {details}")
+1 -1
View File
@@ -12,7 +12,7 @@ _FIXTURE_ORDERS: list[dict] = [
"RecipientPhone": "+380501112233", "RecipientPhone": "+380501112233",
"RecipientEmail": None, "RecipientEmail": None,
"Waybill_Number": "20450123456789", "Waybill_Number": "20450123456789",
"Notes": "Тестовий заказ", "Notes": "Тестове замовлення",
"Total": { "Total": {
"Cost": "0.00", "Cost": "0.00",
"Quantity": "2", "Quantity": "2",
@@ -21,7 +21,7 @@ class NpTrackingClient:
return [] return []
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST: if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
raise NovaPoshtaError( raise NovaPoshtaError(
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} " f"Забагато ТТН за один запит: {len(waybill_numbers)} "
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})" f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
) )
@@ -42,6 +42,6 @@ class NpTrackingClient:
if not data.get("success"): if not data.get("success"):
errors = data.get("errors") or [] errors = data.get("errors") or []
message = "; ".join(str(error) for error in errors) message = "; ".join(str(error) for error in errors)
raise NovaPoshtaError(f"NP вернул ошибку: {message or 'неизвестная ошибка'}") raise NovaPoshtaError(f"NP повернув помилку: {message or 'невідома помилка'}")
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])] return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
+4 -4
View File
@@ -216,7 +216,7 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
""" """
accounts = await _np_accounts(session) accounts = await _np_accounts(session)
if not accounts: if not accounts:
log.warning("np_no_api_keys", hint="Укажите ключ API Новой Почты у кассы") log.warning("np_no_api_keys", hint="Вкажіть ключ API Нової Пошти в касі")
return return
orders = await session.scalars( orders = await session.scalars(
@@ -327,7 +327,7 @@ def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]:
gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP)) gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
net = gross - discount net = gross - discount
if net < 0: if net < 0:
raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки") raise OrderEditError(f"Товар «{good.name}»: знижка більша за суму рядка")
goods.append( goods.append(
{ {
"id": good.id or f"new-{uuid.uuid4().hex[:8]}", "id": good.id or f"new-{uuid.uuid4().hex[:8]}",
@@ -357,13 +357,13 @@ async def update_order(
if order is None or order.is_deleted: if order is None or order.is_deleted:
return None return None
if order.receipt_created_at is not None: if order.receipt_created_at is not None:
raise OrderEditError("По заказу уже создан чек — редактирование недоступно") raise OrderEditError("За замовленням уже створено чек — редагування недоступне")
goods, goods_total = _build_goods(data) goods, goods_total = _build_goods(data)
total = _to_kopecks(str(data.total_amount)) total = _to_kopecks(str(data.total_amount))
if total > goods_total: if total > goods_total:
raise OrderEditError( raise OrderEditError(
f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴" f"Сума замовлення {total / 100:.2f} ₴ більша за суму товарів {goods_total / 100:.2f} ₴"
) )
changed = [ changed = [
+19 -19
View File
@@ -133,7 +133,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
price = to_kopecks(good["price"]) price = to_kopecks(good["price"])
quantity = to_thousandths(good["quantity"]) quantity = to_thousandths(good["quantity"])
if quantity <= 0: if quantity <= 0:
raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0") raise ReceiptValidationError(f"Товар «{good['name']}»: кількість має бути більшою за 0")
gross = _line_sum(price, quantity) gross = _line_sum(price, quantity)
# `amount` — сумма строки после скидки CRM; скидку выводим из неё, # `amount` — сумма строки после скидки CRM; скидку выводим из неё,
# а не из discount_amount/percent, чтобы не расходиться с итогом CRM. # а не из discount_amount/percent, чтобы не расходиться с итогом CRM.
@@ -141,7 +141,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
discount = gross - net discount = gross - net
if discount < 0: if discount < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Товар «{good['name']}»: сумма строки больше цены × количество" f"Товар «{good['name']}»: сума рядка більша за ціну × кількість"
) )
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -168,7 +168,7 @@ def build_ettn_body(
order_discount = goods_total - amounts.total_kopecks order_discount = goods_total - amounts.total_kopecks
if order_discount < 0: if order_discount < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM" "Сума товарів менша за суму замовлення — перевірте замовлення в CRM"
) )
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из # Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает # портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
@@ -185,7 +185,7 @@ def build_ettn_body(
receipt_total = goods_total - order_discount - amounts.prepayment_kopecks receipt_total = goods_total - order_discount - amounts.prepayment_kopecks
if receipt_total != amounts.cod_kopecks: if receipt_total != amounts.cod_kopecks:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Сумма чека {receipt_total / 100:.2f} ₴ ≠ наложка {amounts.cod_kopecks / 100:.2f} ₴" f"Сума чека {receipt_total / 100:.2f} ₴ ≠ післяплата {amounts.cod_kopecks / 100:.2f} ₴"
) )
receipt_body: dict[str, Any] = { receipt_body: dict[str, Any] = {
@@ -222,30 +222,30 @@ def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmou
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой. `prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
""" """
if order.is_deleted: if order.is_deleted:
raise ReceiptValidationError("Заказ удалён") raise ReceiptValidationError("Замовлення видалено")
if not order.waybill_number: if not order.waybill_number:
raise ReceiptValidationError("У заказа нет ТТН") raise ReceiptValidationError("У замовлення немає ТТН")
if not order.np_cod_amount_kopecks: if not order.np_cod_amount_kopecks:
raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)") raise ReceiptValidationError("За ТТН немає суми контролю оплати (післяплати)")
if order.np_status_code in NP_FINAL_STATUS_CODES: if order.np_status_code in NP_FINAL_STATUS_CODES:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — " f"Посилка вже не в дорозі ({order.np_status or order.np_status_code}) — "
"ЕТТН-чек создать нельзя" "ЕТТН-чек створити неможливо"
) )
if not order.goods: if not order.goods:
raise ReceiptValidationError("В заказе нет товаров") raise ReceiptValidationError("У замовленні немає товарів")
total = order.total_amount_kopecks total = order.total_amount_kopecks
cod = order.np_cod_amount_kopecks cod = order.np_cod_amount_kopecks
prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks
if prepayment < 0: if prepayment < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Наложка {cod / 100:.2f} ₴ больше суммы заказа {total / 100:.2f} ₴" f"Післяплата {cod / 100:.2f} ₴ більша за суму замовлення {total / 100:.2f} ₴"
) )
if total - prepayment != cod: if total - prepayment != cod:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ " f"Сума замовлення {total / 100:.2f} ₴ − передоплата {prepayment / 100:.2f} ₴ "
f"≠ наложка {cod / 100:.2f} ₴" f"≠ післяплата {cod / 100:.2f} ₴"
) )
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod) return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
@@ -306,7 +306,7 @@ async def request_receipts(
registers = await _active_registers(session) registers = await _active_registers(session)
if not registers: if not registers:
for order_id, _ in items: for order_id, _ in items:
result.errors[order_id] = "Не настроена касса Checkbox" result.errors[order_id] = "Не налаштовано касу Checkbox"
return result return result
order_ids = [order_id for order_id, _ in items] order_ids = [order_id for order_id, _ in items]
@@ -319,16 +319,16 @@ async def request_receipts(
for order_id, prepayment in items: for order_id, prepayment in items:
order = orders.get(order_id) order = orders.get(order_id)
if order is None: if order is None:
result.errors[order_id] = "Заказ не найден" result.errors[order_id] = "Замовлення не знайдено"
continue continue
if order_id in busy: if order_id in busy:
result.errors[order_id] = "По заказу уже есть чек" result.errors[order_id] = "За замовленням уже є чек"
continue continue
register = registers.get(order.cash_register_id) if order.cash_register_id else None register = registers.get(order.cash_register_id) if order.cash_register_id else None
if register is None: if register is None:
result.errors[order_id] = ( result.errors[order_id] = (
"Касса не определена: ТТН не найдена ни одним ключом Новой Почты " "Касу не визначено: ТТН не знайдено жодним ключем Нової Пошти "
"активных касс" "активних кас"
) )
continue continue
try: try:
@@ -485,7 +485,7 @@ async def cancel_receipt(
if receipt is None: if receipt is None:
return None return None
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR): if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR):
raise ReceiptStateError(f"Чек в статусе «{receipt.status}» отменить нельзя") raise ReceiptStateError(f"Чек у статусі «{receipt.status}» скасувати неможливо")
if receipt.checkbox_ettn_id: if receipt.checkbox_ettn_id:
register = await session.get(CashRegister, receipt.cash_register_id) register = await session.get(CashRegister, receipt.cash_register_id)
+1 -1
View File
@@ -110,7 +110,7 @@ class TestNpTrackingClientGetStatuses:
async def test_rejects_too_many_documents(self) -> None: async def test_rejects_too_many_documents(self) -> None:
client = NpTrackingClient() client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Слишком много"): with pytest.raises(NovaPoshtaError, match="Забагато"):
await client.get_statuses( await client.get_statuses(
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)] api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
) )
+1 -1
View File
@@ -88,7 +88,7 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
session: object, order_id: str, data: object session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None: ) -> tuple[Order, list[str]] | None:
if order_id == "locked": if order_id == "locked":
raise orders_router.orders_service.OrderEditError("По заказу уже создан чек") raise orders_router.orders_service.OrderEditError("За замовленням уже створено чек")
if order_id != "1": if order_id != "1":
return None return None
order = _order(order_id) order = _order(order_id)
+3 -3
View File
@@ -140,16 +140,16 @@ class TestUpdateOrder:
assert order.goods == [stored] assert order.goods == [stored]
def test_total_above_goods_sum_is_rejected(self) -> None: def test_total_above_goods_sum_is_rejected(self) -> None:
with pytest.raises(OrderEditError, match="больше суммы товаров"): with pytest.raises(OrderEditError, match="більша за суму товарів"):
_update(_order(), _payload(total_amount="1200.00")) _update(_order(), _payload(total_amount="1200.00"))
def test_discount_above_line_sum_is_rejected(self) -> None: def test_discount_above_line_sum_is_rejected(self) -> None:
good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"} good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"}
with pytest.raises(OrderEditError, match="скидка больше"): with pytest.raises(OrderEditError, match="знижка більша"):
_update(_order(), _payload(goods=[good], total_amount="0")) _update(_order(), _payload(goods=[good], total_amount="0"))
def test_order_with_receipt_is_locked(self) -> None: def test_order_with_receipt_is_locked(self) -> None:
with pytest.raises(OrderEditError, match="уже создан чек"): with pytest.raises(OrderEditError, match="уже створено чек"):
_update(_order(receipt_created_at=datetime.now(UTC)), _payload()) _update(_order(receipt_created_at=datetime.now(UTC)), _payload())
def test_missing_or_deleted_order_returns_none(self) -> None: def test_missing_or_deleted_order_returns_none(self) -> None:
+4 -2
View File
@@ -45,7 +45,9 @@ def queue(monkeypatch: pytest.MonkeyPatch) -> FakeQueue:
) )
async def fake_request(session: Any, items: Any, *, user: Any, request: Any) -> Any: async def fake_request(session: Any, items: Any, *, user: Any, request: Any) -> Any:
return receipts_service.RequestResult(created=[receipt], errors={"2": "У заказа нет ТТН"}) return receipts_service.RequestResult(
created=[receipt], errors={"2": "У замовлення немає ТТН"}
)
async def fake_commit(self: AsyncSession) -> None: async def fake_commit(self: AsyncSession) -> None:
return None return None
@@ -74,5 +76,5 @@ def test_cashier_creates_and_enqueues(queue: FakeQueue) -> None:
assert response.status_code == 202 assert response.status_code == 202
body = response.json() body = response.json()
assert [r["order_id"] for r in body["created"]] == ["1"] assert [r["order_id"] for r in body["created"]] == ["1"]
assert body["errors"] == {"2": "У заказа нет ТТН"} assert body["errors"] == {"2": "У замовлення немає ТТН"}
assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))] assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))]
+8 -8
View File
@@ -77,17 +77,17 @@ class TestResolveAmounts:
assert amounts.cod_kopecks == 100000 assert amounts.cod_kopecks == 100000
def test_explicit_prepayment_must_match_cod(self) -> None: def test_explicit_prepayment_must_match_cod(self) -> None:
with pytest.raises(svc.ReceiptValidationError, match="≠ наложка"): with pytest.raises(svc.ReceiptValidationError, match="≠ післяплата"):
svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000) svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("overrides", "message"), ("overrides", "message"),
[ [
({"waybill_number": None}, "нет ТТН"), ({"waybill_number": None}, "немає ТТН"),
({"np_cod_amount_kopecks": None}, "контроля оплаты"), ({"np_cod_amount_kopecks": None}, "контролю оплати"),
({"np_status_code": "9"}, "не в пути"), ({"np_status_code": "9"}, "не в дорозі"),
({"np_cod_amount_kopecks": 130000}, "больше суммы заказа"), ({"np_cod_amount_kopecks": 130000}, "більша за суму замовлення"),
({"is_deleted": True}, "удалён"), ({"is_deleted": True}, "видалено"),
], ],
) )
def test_rejects(self, overrides: dict[str, Any], message: str) -> None: def test_rejects(self, overrides: dict[str, Any], message: str) -> None:
@@ -226,7 +226,7 @@ class TestRequestReceipts:
) )
assert result.created == [] assert result.created == []
assert "Касса не определена" in result.errors[order.id] assert "Касу не визначено" in result.errors[order.id]
class FakeSession: class FakeSession:
@@ -289,7 +289,7 @@ class TestCreateEttn:
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id) await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.FAILED assert receipt.status == ReceiptStatus.FAILED
assert "уже привязана" in (receipt.error or "") assert "вже прив'язана" in (receipt.error or "")
assert order.receipt_created_at is None assert order.receipt_created_at is None
async def test_unavailable_keeps_pending_then_reconciles(self) -> None: async def test_unavailable_keeps_pending_then_reconciles(self) -> None:
+8 -2
View File
@@ -1,10 +1,16 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="uk">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lux_fiscal</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Unbounded:wght@500;600&display=swap"
/>
<title>Assistant System</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+5 -5
View File
@@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise<string> {
// Тело не JSON или пустое — используем сообщение по статусу ниже. // Тело не JSON или пустое — используем сообщение по статусу ниже.
} }
if (response.status === 401) return 'Требуется вход в систему' if (response.status === 401) return 'Потрібно увійти в систему'
if (response.status === 403) return 'Недостаточно прав для этого действия' if (response.status === 403) return 'Недостатньо прав для цієї дії'
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже' if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
return `Ошибка запроса (${response.status})` return `Помилка запиту (${response.status})`
} }
// Параллельные 401 не должны порождать несколько запросов на refresh — // Параллельные 401 не должны порождать несколько запросов на refresh —
@@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken() const refreshToken = getRefreshToken()
if (!refreshToken) { if (!refreshToken) {
throw new ApiError(401, 'Сессия истекла, войдите снова') throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
} }
refreshPromise = (async () => { refreshPromise = (async () => {
+8 -1
View File
@@ -2,8 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/features/auth/LoginPage' import { LoginPage } from '@/features/auth/LoginPage'
import { ProtectedRoute } from '@/features/auth/ProtectedRoute' import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
import { ModuleRoute } from '@/features/menu/ModuleRoute'
import { CashRegistersPage } from '@/pages/CashRegistersPage' import { CashRegistersPage } from '@/pages/CashRegistersPage'
import { DashboardPage } from '@/pages/DashboardPage' import { DashboardPage } from '@/pages/DashboardPage'
import { MainMenuPage } from '@/pages/MainMenuPage'
export function AppRoutes() { export function AppRoutes() {
return ( return (
@@ -11,9 +13,14 @@ export function AppRoutes() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<MainMenuPage />} />
<Route element={<ModuleRoute moduleId="receipts" />}>
<Route path="/receipts" element={<DashboardPage />} />
</Route>
<Route element={<ModuleRoute moduleId="cash-registers" />}>
<Route path="/cash-registers" element={<CashRegistersPage />} /> <Route path="/cash-registers" element={<CashRegistersPage />} />
</Route> </Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+1 -1
View File
@@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(result.user) setUser(result.user)
setStatus('authenticated') setStatus('authenticated')
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setError(message) setError(message)
throw err throw err
} }
+207 -28
View File
@@ -1,68 +1,230 @@
.login-page { .login-page {
--login-accent: #7c3aed;
--login-glass: rgba(255, 255, 255, 0.72);
--login-glass-border: rgba(255, 255, 255, 0.6);
--login-input-bg: rgba(255, 255, 255, 0.85);
position: relative;
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px 16px;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
var(--color-bg);
}
@media (prefers-color-scheme: dark) {
.login-page {
--login-accent: #a78bfa;
--login-glass: rgba(23, 26, 33, 0.7);
--login-glass-border: rgba(255, 255, 255, 0.08);
--login-input-bg: rgba(15, 17, 21, 0.7);
}
}
/* Декоративные размытые пятна на фоне */
.login-backdrop {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.login-blob {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.55;
animation: login-float 18s ease-in-out infinite alternate;
}
.login-blob--a {
width: 380px;
height: 380px;
top: -80px;
left: -60px;
background: var(--color-primary);
}
.login-blob--b {
width: 320px;
height: 320px;
bottom: -90px;
right: -40px;
background: var(--login-accent);
animation-delay: -6s;
}
.login-blob--c {
width: 220px;
height: 220px;
top: 55%;
left: 60%;
background: #06b6d4;
opacity: 0.3;
animation-delay: -12s;
}
@keyframes login-float {
from {
transform: translate3d(0, 0, 0) scale(1);
}
to {
transform: translate3d(40px, 30px, 0) scale(1.1);
}
}
@media (prefers-reduced-motion: reduce) {
.login-blob {
animation: none;
}
} }
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 380px; max-width: 400px;
background: var(--color-surface); background: var(--login-glass);
border: 1px solid var(--color-border); border: 1px solid var(--login-glass-border);
border-radius: 12px; border-radius: 20px;
box-shadow: var(--shadow-card); box-shadow:
padding: 32px; 0 20px 50px -12px rgba(16, 24, 40, 0.25),
0 1px 2px rgba(16, 24, 40, 0.06);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
padding: 40px 32px 32px;
animation: login-enter 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes login-enter {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
}
@media (max-width: 420px) {
.login-card {
padding: 32px 20px 24px;
border-radius: 16px;
}
} }
.login-header { .login-header {
margin-bottom: 24px; margin-bottom: 28px;
text-align: center; text-align: center;
} }
.login-logo {
width: 56px;
height: 56px;
margin: 0 auto 16px;
display: grid;
place-items: center;
border-radius: 16px;
color: #fff;
background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
box-shadow: 0 10px 24px -8px var(--color-primary);
}
.login-header h1 { .login-header h1 {
font-size: 22px; font-size: 26px;
margin-bottom: 4px; font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 6px;
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.login-header p { .login-header p {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 14px; font-size: 15px;
} }
.login-field { .login-field {
margin-bottom: 16px; margin-bottom: 18px;
} }
.login-field label { .login-field label {
display: block; display: block;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
margin-bottom: 6px; margin-bottom: 8px;
color: var(--color-text); color: var(--color-text);
} }
.login-field input { .login-field input {
width: 100%; width: 100%;
padding: 10px 12px; height: 46px;
padding: 0 14px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 12px;
background: var(--color-bg); background: var(--login-input-bg);
color: var(--color-text); color: var(--color-text);
font-size: 14px; font-size: 15px;
outline: none; outline: none;
transition: border-color 0.15s ease; transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.login-field input::placeholder {
color: var(--color-text-muted);
opacity: 0.7;
} }
.login-field input:focus { .login-field input:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
} }
.login-field input:disabled { .login-field input:disabled {
opacity: 0.6; opacity: 0.6;
} }
.login-password {
position: relative;
}
.login-password input {
padding-right: 46px;
}
.login-toggle {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.login-toggle:hover {
color: var(--color-text);
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
.login-toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.login-error { .login-error {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -70,23 +232,29 @@
background: var(--color-danger-bg); background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border); border: 1px solid var(--color-danger-border);
color: var(--color-danger); color: var(--color-danger);
border-radius: 8px; border-radius: 12px;
padding: 10px 12px; padding: 10px 14px;
font-size: 13px; font-size: 13px;
margin-bottom: 16px; margin-bottom: 18px;
} }
.login-submit { .login-submit {
width: 100%; width: 100%;
padding: 11px 16px; height: 48px;
margin-top: 8px;
padding: 0 16px;
border: none; border: none;
border-radius: 8px; border-radius: 12px;
background: var(--color-primary); background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
color: #fff; color: #fff;
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; box-shadow: 0 10px 24px -10px var(--color-primary);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
filter 0.15s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -94,7 +262,18 @@
} }
.login-submit:hover:not(:disabled) { .login-submit:hover:not(:disabled) {
background: var(--color-primary-hover); transform: translateY(-1px);
filter: brightness(1.06);
box-shadow: 0 14px 28px -10px var(--color-primary);
}
.login-submit:active:not(:disabled) {
transform: translateY(0);
}
.login-submit:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
} }
.login-submit:disabled { .login-submit:disabled {
+33 -5
View File
@@ -19,6 +19,7 @@ export function LoginPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState<string | null>(null) const [formError, setFormError] = useState<string | null>(null)
@@ -36,7 +37,7 @@ export function LoginPage() {
try { try {
await login(email, password) await login(email, password)
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setFormError(message) setFormError(message)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
@@ -45,10 +46,22 @@ export function LoginPage() {
return ( return (
<div className="login-page"> <div className="login-page">
<div className="login-backdrop" aria-hidden="true">
<span className="login-blob login-blob--a" />
<span className="login-blob login-blob--b" />
<span className="login-blob login-blob--c" />
</div>
<div className="login-card"> <div className="login-card">
<div className="login-header"> <div className="login-header">
<h1>lux_fiscal</h1> <div className="login-logo" aria-hidden="true">
<p>Вход в систему фискализации заказов</p> <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l7 4v5c0 4.5-3 8-7 9-4-1-7-4.5-7-9V7l7-4z" />
<path d="M9 12l2 2 4-4" />
</svg>
</div>
<h1>Assistant System</h1>
<p>Вхід у систему</p>
</div> </div>
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
@@ -74,9 +87,10 @@ export function LoginPage() {
<div className="login-field"> <div className="login-field">
<label htmlFor={passwordId}>Пароль</label> <label htmlFor={passwordId}>Пароль</label>
<div className="login-password">
<input <input
id={passwordId} id={passwordId}
type="password" type={showPassword ? 'text' : 'password'}
autoComplete="current-password" autoComplete="current-password"
required required
value={password} value={password}
@@ -84,11 +98,25 @@ export function LoginPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
/> />
<button
type="button"
className="login-toggle"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Приховати пароль' : 'Показати пароль'}
aria-pressed={showPassword}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
{showPassword && <path d="M3 3l18 18" />}
</svg>
</button>
</div>
</div> </div>
<button type="submit" className="login-submit" disabled={submitting}> <button type="submit" className="login-submit" disabled={submitting}>
{submitting && <span className="spinner" aria-hidden="true" />} {submitting && <span className="spinner" aria-hidden="true" />}
{submitting ? 'Входим…' : 'Войти'} {submitting ? 'Входимо…' : 'Увійти'}
</button> </button>
</form> </form>
</div> </div>
@@ -10,7 +10,7 @@ export function ProtectedRoute() {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="page-center"> <div className="page-center">
<span className="spinner" aria-label="Загрузка" /> <span className="spinner" aria-label="Завантаження" />
</div> </div>
) )
} }
+7
View File
@@ -0,0 +1,7 @@
import type { UserRole } from '@/api/types'
export const ROLE_LABEL: Record<UserRole, string> = {
admin: 'Адміністратор',
cashier: 'Касир',
viewer: 'Спостерігач',
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext'
export function useAuth() { export function useAuth() {
const ctx = useContext(AuthContext) const ctx = useContext(AuthContext)
if (!ctx) { if (!ctx) {
throw new Error('useAuth должен вызываться внутри <AuthProvider>') throw new Error('useAuth має викликатися всередині <AuthProvider>')
} }
return ctx return ctx
} }
@@ -0,0 +1,11 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '@/features/auth/useAuth'
import { canAccess, getModule } from '@/features/menu/modules'
/** Пускает в модуль только роли из реестра; остальных возвращает в главное меню. */
export function ModuleRoute({ moduleId }: { moduleId: string }) {
const { user } = useAuth()
if (!canAccess(user, getModule(moduleId).roles)) return <Navigate to="/" replace />
return <Outlet />
}
+46
View File
@@ -0,0 +1,46 @@
import type { User, UserRole } from '@/api/types'
/**
* Реестр модулей главного меню. Единственное место, где модуль связывается
* с путём и ролями: по нему рисуется меню и защищается роут модуля.
*/
export interface AppModule {
id: string
title: string
description: string
path: string
/** Роли, которым модуль доступен; администратор видит всё и в списке не нужен. */
roles: readonly UserRole[]
/** `d` для stroke-иконки 24×24. */
icon: string
}
export const MODULES: readonly AppModule[] = [
{
id: 'receipts',
title: 'Чеки',
description: 'Замовлення з CRM, ЕТТН-чеки Checkbox і статуси Нової Пошти',
path: '/receipts',
roles: ['cashier', 'viewer'],
icon: 'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6 M9 16h3',
},
{
id: 'cash-registers',
title: 'Каси',
description: 'Каси Checkbox, ліцензійні ключі та ключі Нової Пошти',
path: '/cash-registers',
roles: [],
icon: 'M4 10h16v10H4z M7 10V4h10v6 M8 14h2 M14 14h2 M8 17h2 M14 17h2',
},
]
export function canAccess(user: User | null | undefined, roles: readonly UserRole[]): boolean {
if (!user) return false
return user.role === 'admin' || roles.includes(user.role)
}
export function getModule(id: string): AppModule {
const module = MODULES.find((m) => m.id === id)
if (!module) throw new Error(`Невідомий модуль: ${id}`)
return module
}
@@ -96,18 +96,18 @@ function orderDiscountOf(draft: Draft): number {
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */ /** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
function toPayload(draft: Draft): OrderUpdate | string { function toPayload(draft: Draft): OrderUpdate | string {
if (draft.goods.length === 0) return 'Добавьте хотя бы один товар' if (draft.goods.length === 0) return 'Додайте хоча б один товар'
const goods = [] const goods = []
for (const good of draft.goods) { for (const good of draft.goods) {
const label = good.name.trim() || 'без названия' const label = good.name.trim() || 'без назви'
if (!good.name.trim()) return 'У товара не указано наименование' if (!good.name.trim()) return 'У товару не вказано найменування'
const price = toKopecks(good.price) const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity) const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount) const discount = toKopecks(good.discount)
if (price === null) return `Товар «${label}»: некорректная цена` if (price === null) return `Товар «${label}»: некоректна ціна`
if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество` if (quantity === null || quantity <= 0) return `Товар «${label}»: некоректна кількість`
if (discount === null) return `Товар «${label}»: некорректная скидка` if (discount === null) return `Товар «${label}»: некоректна знижка`
if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки` if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: знижка більша за суму рядка`
goods.push({ goods.push({
id: good.id, id: good.id,
sku: good.sku.trim(), sku: good.sku.trim(),
@@ -118,9 +118,9 @@ function toPayload(draft: Draft): OrderUpdate | string {
}) })
} }
const total = toKopecks(draft.total) const total = toKopecks(draft.total)
if (total === null) return 'Некорректная сумма заказа' if (total === null) return 'Некоректна сума замовлення'
const sum = goodsSum(draft.goods) ?? 0 const sum = goodsSum(draft.goods) ?? 0
if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)` if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)`
return { return {
recipient_name: draft.recipient_name, recipient_name: draft.recipient_name,
@@ -134,7 +134,7 @@ function toPayload(draft: Draft): OrderUpdate | string {
} }
const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [ const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [
{ field: 'recipient_name', label: 'Клиент' }, { field: 'recipient_name', label: 'Клієнт' },
{ field: 'recipient_phone', label: 'Телефон', type: 'tel' }, { field: 'recipient_phone', label: 'Телефон', type: 'tel' },
{ field: 'recipient_email', label: 'Email', type: 'email' }, { field: 'recipient_email', label: 'Email', type: 'email' },
{ field: 'waybill_number', label: 'Номер ТТН' }, { field: 'waybill_number', label: 'Номер ТТН' },
@@ -153,7 +153,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
const editable = canEdit && !order.has_receipt const editable = canEdit && !order.has_receipt
function requestClose() { function requestClose() {
if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return
onClose() onClose()
} }
@@ -203,10 +203,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
setDraft(next) setDraft(next)
setSaved(next) setSaved(next)
setOrderDiscount(orderDiscountOf(next)) setOrderDiscount(orderDiscountOf(next))
setMessage({ tone: 'ok', text: 'Изменения сохранены' }) setMessage({ tone: 'ok', text: 'Зміни збережено' })
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' }) setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' })
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -217,10 +217,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<div className="order-modal"> <div className="order-modal">
<div className="order-modal-header"> <div className="order-modal-header">
<h3> <h3>
Заказ {order.id} Замовлення {order.id}
{order.edited && <span className="order-modal-edited">изменён вручную</span>} {order.edited && <span className="order-modal-edited">змінено вручну</span>}
</h3> </h3>
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрыть"> <button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
× ×
</button> </button>
</div> </div>
@@ -232,19 +232,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</div> </div>
<div> <div>
<span className="order-modal-label">Статус ТТН</span> <span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Нет данных'}</span> <span>{order.np_status || 'Немає даних'}</span>
</div> </div>
<div> <div>
<span className="order-modal-label">Наложенный платёж</span> <span className="order-modal-label">Накладений платіж</span>
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span> <span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
</div> </div>
<div> <div>
<span className="order-modal-label">Оплачено</span> <span className="order-modal-label">Оплачено</span>
<span> <span>
{order.np_payment_status === 'Payed' {order.np_payment_status === 'Payed'
? 'Да' ? 'Так'
: order.np_payment_status === 'NeedPayment' : order.np_payment_status === 'NeedPayment'
? 'Нет' ? 'Ні'
: '—'} : '—'}
</span> </span>
</div> </div>
@@ -272,7 +272,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable ? ( {editable ? (
<label className="order-modal-notes"> <label className="order-modal-notes">
<span className="order-modal-label">Заметки</span> <span className="order-modal-label">Нотатки</span>
<textarea <textarea
className="order-modal-input" className="order-modal-input"
rows={2} rows={2}
@@ -283,7 +283,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
) : ( ) : (
draft.notes && ( draft.notes && (
<div className="order-modal-notes"> <div className="order-modal-notes">
<span className="order-modal-label">Заметки</span> <span className="order-modal-label">Нотатки</span>
<p>{draft.notes}</p> <p>{draft.notes}</p>
</div> </div>
) )
@@ -292,13 +292,13 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<table className="order-modal-goods"> <table className="order-modal-goods">
<thead> <thead>
<tr> <tr>
<th>Наименование</th> <th>Найменування</th>
<th>SKU</th> <th>SKU</th>
<th>Цена</th> <th>Ціна</th>
<th>Кол-во</th> <th>К-сть</th>
<th>Скидка</th> <th>Знижка</th>
<th>Сумма</th> <th>Сума</th>
{editable && <th aria-label="Удалить" />} {editable && <th aria-label="Видалити" />}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -350,7 +350,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<button <button
type="button" type="button"
className="order-modal-remove" className="order-modal-remove"
aria-label={`Удалить товар ${good.name}`} aria-label={`Видалити товар ${good.name}`}
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))} onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))}
> >
× ×
@@ -363,7 +363,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{draft.goods.length === 0 && ( {draft.goods.length === 0 && (
<tr> <tr>
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty"> <td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
Товары не указаны Товари не вказані
</td> </td>
</tr> </tr>
)} )}
@@ -372,19 +372,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable && ( {editable && (
<button type="button" className="order-modal-add" onClick={addGood}> <button type="button" className="order-modal-add" onClick={addGood}>
+ Добавить товар + Додати товар
</button> </button>
)} )}
<div className="order-modal-total"> <div className="order-modal-total">
<span>Итого</span> <span>Разом</span>
{editable ? ( {editable ? (
<span> <span>
<input <input
className="order-modal-input order-modal-input--num" className="order-modal-input order-modal-input--num"
inputMode="decimal" inputMode="decimal"
value={draft.total} value={draft.total}
title="Сумма заказа после всех скидок" title="Сума замовлення після всіх знижок"
onChange={(e) => setTotal(e.target.value)} onChange={(e) => setTotal(e.target.value)}
/>{' '} />{' '}
₴ ₴
@@ -403,12 +403,12 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</span> </span>
</div> </div>
<div> <div>
<span className="order-modal-label">Предоплата в чеке</span> <span className="order-modal-label">Передоплата в чеку</span>
<span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span> <span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span>
</div> </div>
{order.receipt_error && ( {order.receipt_error && (
<div> <div>
<span className="order-modal-label">Ошибка</span> <span className="order-modal-label">Помилка</span>
<span>{order.receipt_error}</span> <span>{order.receipt_error}</span>
</div> </div>
)} )}
@@ -425,11 +425,11 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
disabled={!dirty || saving} disabled={!dirty || saving}
onClick={() => void handleSave()} onClick={() => void handleSave()}
> >
{saving ? 'Сохранение…' : 'Сохранить'} {saving ? 'Збереження…' : 'Зберегти'}
</button> </button>
)} )}
<button type="button" className="order-modal-close-btn" onClick={requestClose}> <button type="button" className="order-modal-close-btn" onClick={requestClose}>
Закрыть Закрити
</button> </button>
</div> </div>
</div> </div>
+7 -7
View File
@@ -43,13 +43,13 @@ export interface ReceiptCreateResponse {
export type Tone = 'delivered' | 'processing' | 'danger' | 'new' export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = { export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
pending: { label: 'Отправляется', tone: 'new' }, pending: { label: 'Надсилається', tone: 'new' },
created: { label: 'Ждёт оплаты', tone: 'processing' }, created: { label: 'Очікує оплати', tone: 'processing' },
done: { label: 'Фискализирован', tone: 'delivered' }, done: { label: 'Фіскалізовано', tone: 'delivered' },
returned: { label: 'Возврат посылки', tone: 'danger' }, returned: { label: 'Повернення посилки', tone: 'danger' },
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' }, receipt_error: { label: 'Помилка фіскалізації', tone: 'danger' },
cancelled: { label: 'Отменён', tone: 'new' }, cancelled: { label: 'Скасовано', tone: 'new' },
failed: { label: 'Не создан', tone: 'danger' }, failed: { label: 'Не створено', tone: 'danger' },
} }
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */ /** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
+13
View File
@@ -74,6 +74,19 @@ input {
/* --- Общие утилиты, используются в нескольких экранах --- */ /* --- Общие утилиты, используются в нескольких экранах --- */
/* Скрыт визуально, но читается скринридером (подписи к полям без видимого label). */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.page-center { .page-center {
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
+1 -1
View File
@@ -6,7 +6,7 @@ import '@/index.css'
const rootElement = document.getElementById('root') const rootElement = document.getElementById('root')
if (!rootElement) { if (!rootElement) {
throw new Error('Элемент #root не найден в index.html') throw new Error('Елемент #root не знайдено в index.html')
} }
createRoot(rootElement).render( createRoot(rootElement).render(
+41 -41
View File
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
checkCashRegister, checkCashRegister,
@@ -55,8 +55,6 @@ export function CashRegistersPage() {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null) const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
if (user && user.role !== 'admin') return <Navigate to="/" replace />
async function run(action: () => Promise<unknown>, okText: string) { async function run(action: () => Promise<unknown>, okText: string) {
setBusy(true) setBusy(true)
try { try {
@@ -64,7 +62,7 @@ export function CashRegistersPage() {
setMessage({ tone: 'ok', text: okText }) setMessage({ tone: 'ok', text: okText })
await queryClient.invalidateQueries({ queryKey: ['cash-registers'] }) await queryClient.invalidateQueries({ queryKey: ['cash-registers'] })
} catch (err) { } catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Ошибка запроса' }) setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Помилка запиту' })
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -101,7 +99,7 @@ export function CashRegistersPage() {
if (form.license_key) payload.license_key = form.license_key if (form.license_key) payload.license_key = form.license_key
if (form.pin_code) payload.pin_code = form.pin_code if (form.pin_code) payload.pin_code = form.pin_code
if (form.np_api_key) payload.np_api_key = form.np_api_key if (form.np_api_key) payload.np_api_key = form.np_api_key
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена') await run(() => updateCashRegister(editingId, payload), 'Касу оновлено')
} else { } else {
await run( await run(
() => () =>
@@ -114,7 +112,7 @@ export function CashRegistersPage() {
tax_codes: parseTaxCodes(form.tax_codes), tax_codes: parseTaxCodes(form.tax_codes),
is_default: form.is_default, is_default: form.is_default,
}), }),
'Касса добавлена', 'Касу додано',
) )
} }
resetForm() resetForm()
@@ -123,21 +121,23 @@ export function CashRegistersPage() {
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-topbar"> <header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span> <div className="dashboard-topbar-start">
<Link to="/" className="orders-link-btn"> <Link to="/" className="orders-link-btn">
← К заказам ← Головне меню
</Link> </Link>
<span className="dashboard-brand">Assistant System</span>
</div>
</header> </header>
<main className="orders-body"> <main className="orders-body">
<div className="orders-toolbar"> <div className="orders-toolbar">
<h2>Кассы Checkbox</h2> <h2>Каси Checkbox</h2>
</div> </div>
{message && ( {message && (
<div className={`orders-notice orders-notice--${message.tone}`}> <div className={`orders-notice orders-notice--${message.tone}`}>
<span>{message.text}</span> <span>{message.text}</span>
<button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Скрыть"> <button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Сховати">
× ×
</button> </button>
</div> </div>
@@ -147,34 +147,34 @@ export function CashRegistersPage() {
<table className="orders-table"> <table className="orders-table">
<thead> <thead>
<tr> <tr>
<th>Название</th> <th>Назва</th>
<th>Фиск. номер</th> <th>Фіск. номер</th>
<th>Ключ лицензии</th> <th>Ключ ліцензії</th>
<th>Ключ Новой Почты</th> <th>Ключ Нової Пошти</th>
<th>Налоги</th> <th>Податки</th>
<th>Статус</th> <th>Статус</th>
<th>Действия</th> <th>Дії</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={7} className="orders-empty"> <td colSpan={7} className="orders-empty">
Загрузка… Завантаження…
</td> </td>
</tr> </tr>
)} )}
{isError && ( {isError && (
<tr> <tr>
<td colSpan={7} className="orders-empty"> <td colSpan={7} className="orders-empty">
Не удалось загрузить кассы Не вдалося завантажити каси
</td> </td>
</tr> </tr>
)} )}
{registers?.length === 0 && ( {registers?.length === 0 && (
<tr> <tr>
<td colSpan={7} className="orders-empty"> <td colSpan={7} className="orders-empty">
Кассы не настроены — чеки создавать нельзя Каси не налаштовані — створювати чеки неможливо
</td> </td>
</tr> </tr>
)} )}
@@ -182,15 +182,15 @@ export function CashRegistersPage() {
<tr key={register.id}> <tr key={register.id}>
<td> <td>
{register.name} {register.name}
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основная</span>} {register.is_default && <span className="orders-status orders-status--delivered cr-default">основна</span>}
</td> </td>
<td>{register.fiscal_number || '—'}</td> <td>{register.fiscal_number || '—'}</td>
<td>{register.license_key_masked}</td> <td>{register.license_key_masked}</td>
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</td> <td>{register.np_api_key_masked ?? <span className="cr-missing">не задано</span>}</td>
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td> <td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без податку'}</td>
<td> <td>
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}> <span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
{register.is_active ? 'Активна' : 'Отключена'} {register.is_active ? 'Активна' : 'Вимкнена'}
</span> </span>
</td> </td>
<td className="orders-actions-cell"> <td className="orders-actions-cell">
@@ -198,12 +198,12 @@ export function CashRegistersPage() {
type="button" type="button"
className="orders-view-btn" className="orders-view-btn"
disabled={busy} disabled={busy}
onClick={() => void run(() => checkCashRegister(register.id), `Вход в «${register.name}» успешен`)} onClick={() => void run(() => checkCashRegister(register.id), `Вхід у «${register.name}» успішний`)}
> >
Проверить Перевірити
</button> </button>
<button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}> <button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}>
Изменить Змінити
</button> </button>
<button <button
type="button" type="button"
@@ -212,11 +212,11 @@ export function CashRegistersPage() {
onClick={() => onClick={() =>
void run( void run(
() => updateCashRegister(register.id, { is_active: !register.is_active }), () => updateCashRegister(register.id, { is_active: !register.is_active }),
register.is_active ? 'Касса отключена' : 'Касса включена', register.is_active ? 'Касу вимкнено' : 'Касу увімкнено',
) )
} }
> >
{register.is_active ? 'Отключить' : 'Включить'} {register.is_active ? 'Вимкнути' : 'Увімкнути'}
</button> </button>
</td> </td>
</tr> </tr>
@@ -226,48 +226,48 @@ export function CashRegistersPage() {
</div> </div>
<form className="cr-form" onSubmit={(e) => void handleSubmit(e)}> <form className="cr-form" onSubmit={(e) => void handleSubmit(e)}>
<h3>{editingId ? 'Изменить кассу' : 'Добавить кассу'}</h3> <h3>{editingId ? 'Змінити касу' : 'Додати касу'}</h3>
<label> <label>
Название Назва
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label> </label>
<label> <label>
Фискальный номер Фіскальний номер
<input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} /> <input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} />
</label> </label>
<label> <label>
Ключ лицензии кассы Ключ ліцензії каси
<input <input
required={!editingId} required={!editingId}
autoComplete="off" autoComplete="off"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.license_key} value={form.license_key}
onChange={(e) => setForm({ ...form, license_key: e.target.value })} onChange={(e) => setForm({ ...form, license_key: e.target.value })}
/> />
</label> </label>
<label> <label>
PIN-код кассира PIN-код касира
<input <input
required={!editingId} required={!editingId}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.pin_code} value={form.pin_code}
onChange={(e) => setForm({ ...form, pin_code: e.target.value })} onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
/> />
</label> </label>
<label> <label>
Ключ API Новой Почты (посылки этого кабинета пробиваются через эту кассу) Ключ API Нової Пошти (посилки цього кабінету пробиваються через цю касу)
<input <input
required={!editingId} required={!editingId}
autoComplete="off" autoComplete="off"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.np_api_key} value={form.np_api_key}
onChange={(e) => setForm({ ...form, np_api_key: e.target.value })} onChange={(e) => setForm({ ...form, np_api_key: e.target.value })}
/> />
</label> </label>
<label> <label>
Коды налогов (через запятую, пусто — без налога) Коди податків (через кому, порожньо — без податку)
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} /> <input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
</label> </label>
<label className="cr-checkbox"> <label className="cr-checkbox">
@@ -276,15 +276,15 @@ export function CashRegistersPage() {
checked={form.is_default} checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })} onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
/> />
Основная касса (первой проверяет новые посылки) Основна каса (першою перевіряє нові посилки)
</label> </label>
<div className="cr-form-actions"> <div className="cr-form-actions">
<button type="submit" className="orders-create-btn" disabled={busy}> <button type="submit" className="orders-create-btn" disabled={busy}>
{editingId ? 'Сохранить' : 'Добавить'} {editingId ? 'Зберегти' : 'Додати'}
</button> </button>
{editingId && ( {editingId && (
<button type="button" className="orders-view-btn" onClick={resetForm}> <button type="button" className="orders-view-btn" onClick={resetForm}>
Отмена Скасувати
</button> </button>
)} )}
</div> </div>
+6
View File
@@ -13,6 +13,12 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.dashboard-topbar-start {
display: flex;
align-items: center;
gap: 16px;
}
.dashboard-brand { .dashboard-brand {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
+49 -54
View File
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'
import { deleteOrder } from '@/api/orders' import { deleteOrder } from '@/api/orders'
import { cancelReceipt, createReceipts } from '@/api/receipts' import { cancelReceipt, createReceipts } from '@/api/receipts'
import '@/pages/DashboardPage.css' import '@/pages/DashboardPage.css'
import { ROLE_LABEL } from '@/features/auth/roles'
import { useAuth } from '@/features/auth/useAuth' import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal' import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order, OrderTab } from '@/features/orders/types' import type { Order, OrderTab } from '@/features/orders/types'
@@ -13,17 +14,11 @@ import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/rece
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types' import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types' import type { ReceiptRequestItem } from '@/features/receipts/types'
const ROLE_LABEL: Record<string, string> = {
admin: 'Администратор',
cashier: 'Кассир',
viewer: 'Наблюдатель',
}
const TABS: { key: OrderTab; label: string }[] = [ const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' }, { key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' }, { key: 'has_receipt', label: 'Виписані чеки' },
{ key: 'received', label: 'Полученные' }, { key: 'received', label: 'Отримані' },
{ key: 'refused', label: 'Отказы' }, { key: 'refused', label: 'Відмови' },
] ]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' { function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
@@ -45,7 +40,7 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
return null return null
} }
const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const MONEY_FORMAT = new Intl.NumberFormat('uk-UA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function formatMoney(amount: string): string { function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount)) return MONEY_FORMAT.format(Number(amount))
@@ -80,7 +75,7 @@ export function DashboardPage() {
for (const order of targets) { for (const order of targets) {
const prepayment = toKopecks(prepaymentOf(order)) const prepayment = toKopecks(prepaymentOf(order))
if (prepayment === null) { if (prepayment === null) {
localErrors.push(`${order.id}: некорректная сумма предоплаты`) localErrors.push(`${order.id}: некоректна сума передоплати`)
continue continue
} }
items.push({ order_id: order.id, prepayment_kopecks: prepayment }) items.push({ order_id: order.id, prepayment_kopecks: prepayment })
@@ -95,14 +90,14 @@ export function DashboardPage() {
const result = await createReceipts(items) const result = await createReceipts(items)
const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)] const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)]
const lines = [ const lines = [
...(result.created.length ? [`Отправлено в Checkbox: ${result.created.length}`] : []), ...(result.created.length ? [`Надіслано в Checkbox: ${result.created.length}`] : []),
...errors, ...errors,
] ]
setNotice({ tone: errors.length ? 'error' : 'ok', lines }) setNotice({ tone: errors.length ? 'error' : 'ok', lines })
setSelected(new Set()) setSelected(new Set())
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось создать чеки'] }) setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося створити чеки'] })
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -111,19 +106,19 @@ export function DashboardPage() {
async function handleCancel(order: Order) { async function handleCancel(order: Order) {
if (!order.receipt_id) return if (!order.receipt_id) return
const outcome = { const outcome = {
no_receipt: 'Заказ вернётся в очередь.', no_receipt: 'Замовлення повернеться в чергу.',
has_receipt: 'Заказ вернётся в очередь.', has_receipt: 'Замовлення повернеться в чергу.',
received: 'Заказ останется в полученных.', received: 'Замовлення залишиться в отриманих.',
refused: 'Заказ останется в отказах.', refused: 'Замовлення залишиться у відмовах.',
}[tab] }[tab]
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return if (!window.confirm(`Скасувати ЕТТН-чек за замовленням ${order.id}? ${outcome}`)) return
setCancellingId(order.id) setCancellingId(order.id)
try { try {
await cancelReceipt(order.receipt_id) await cancelReceipt(order.receipt_id)
setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] }) setNotice({ tone: 'ok', lines: [`Чек за замовленням ${order.id} скасовано`] })
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] }) setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося скасувати чек'] })
} finally { } finally {
setCancellingId(null) setCancellingId(null)
} }
@@ -174,25 +169,25 @@ export function DashboardPage() {
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-topbar"> <header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span> <div className="dashboard-topbar-start">
<Link to="/" className="orders-link-btn">
← Головне меню
</Link>
<span className="dashboard-brand">Assistant System</span>
</div>
<div className="dashboard-user"> <div className="dashboard-user">
<span>{user?.full_name}</span> <span>{user?.full_name}</span>
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span> <span className="dashboard-role">{user ? ROLE_LABEL[user.role] : ''}</span>
<button type="button" className="dashboard-logout" onClick={() => void logout()}> <button type="button" className="dashboard-logout" onClick={() => void logout()}>
Выйти Вийти
</button> </button>
</div> </div>
</header> </header>
<main className="orders-body"> <main className="orders-body">
<div className="orders-toolbar"> <div className="orders-toolbar">
<h2>Заказы</h2> <h2>Замовлення</h2>
<div className="orders-toolbar-actions"> <div className="orders-toolbar-actions">
{user?.role === 'admin' && (
<Link to="/cash-registers" className="orders-link-btn">
Кассы
</Link>
)}
{tab === 'no_receipt' && canFiscalize && ( {tab === 'no_receipt' && canFiscalize && (
<button <button
type="button" type="button"
@@ -200,20 +195,20 @@ export function DashboardPage() {
disabled={selected.size === 0 || submitting} disabled={selected.size === 0 || submitting}
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))} onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
> >
Создать чеки по выбранным ({selected.size}) Створити чеки за вибраними ({selected.size})
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className="orders-summary"> <div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа"> <div className="orders-summary-card" title="Посилки з післяплатою, які ще не забрали і за якими немає відмови">
<span className="orders-summary-label">Наложка в пути</span> <span className="orders-summary-label">Післяплата в дорозі</span>
<span className="orders-summary-value"> <span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'} {summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span> </span>
<span className="orders-summary-hint"> <span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '} {summary ? `${summary.cod_in_transit_count} посилок` : ' '}
</span> </span>
</div> </div>
</div> </div>
@@ -225,7 +220,7 @@ export function DashboardPage() {
<li key={line}>{line}</li> <li key={line}>{line}</li>
))} ))}
</ul> </ul>
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Скрыть"> <button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Сховати">
× ×
</button> </button>
</div> </div>
@@ -248,16 +243,16 @@ export function DashboardPage() {
</div> </div>
<div className="orders-filterbar"> <div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span> <span className="orders-selected-count">Вибрано: {selected.size} замовлень</span>
<input <input
type="search" type="search"
className="orders-search" className="orders-search"
placeholder="Поиск по ID, ТТН, клиенту" placeholder="Пошук за ID, ТТН, клієнтом"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<button type="button" className="orders-filter-btn"> <button type="button" className="orders-filter-btn">
Фильтр Фільтр
</button> </button>
</div> </div>
@@ -266,31 +261,31 @@ export function DashboardPage() {
<thead> <thead>
<tr> <tr>
<th className="orders-checkbox-col"> <th className="orders-checkbox-col">
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать всё" /> <input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Вибрати все" />
</th> </th>
<th>ID заказа</th> <th>ID замовлення</th>
<th>Номер ТТН</th> <th>Номер ТТН</th>
<th>Клиент</th> <th>Клієнт</th>
<th>Сумма</th> <th>Сума</th>
<th>Наложка</th> <th>Післяплата</th>
<th>Оплачено</th> <th>Оплачено</th>
<th>Предоплата, ₴</th> <th>Передоплата, ₴</th>
<th>Статус ТТН</th> <th>Статус ТТН</th>
<th>Действия</th> <th>Дії</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Загрузка… Завантаження…
</td> </td>
</tr> </tr>
)} )}
{isError && ( {isError && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Не удалось загрузить заказы из CRM Не вдалося завантажити замовлення з CRM
</td> </td>
</tr> </tr>
)} )}
@@ -305,7 +300,7 @@ export function DashboardPage() {
type="checkbox" type="checkbox"
checked={selected.has(order.id)} checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)} onChange={() => toggleOne(order.id)}
aria-label={`Выбрать заказ ${order.id}`} aria-label={`Вибрати замовлення ${order.id}`}
/> />
</td> </td>
<td>{order.id}</td> <td>{order.id}</td>
@@ -334,7 +329,7 @@ export function DashboardPage() {
placeholder="0.00" placeholder="0.00"
value={prepaymentOf(order)} value={prepaymentOf(order)}
disabled={!canFiscalize} disabled={!canFiscalize}
title="Сумма заказа − предоплата должна равняться наложке" title="Сума замовлення − передоплата має дорівнювати післяплаті"
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))} onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
/> />
) : ( ) : (
@@ -343,19 +338,19 @@ export function DashboardPage() {
</td> </td>
<td> <td>
<span className={`orders-status orders-status--${npStatusTone(order)}`}> <span className={`orders-status orders-status--${npStatusTone(order)}`}>
{order.np_status || 'Нет данных'} {order.np_status || 'Немає даних'}
</span> </span>
</td> </td>
<td className="orders-actions-cell"> <td className="orders-actions-cell">
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}> <button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Просмотр Перегляд
</button> </button>
{tab === 'no_receipt' && canFiscalize && ( {tab === 'no_receipt' && canFiscalize && (
<button <button
type="button" type="button"
className="orders-receipt-btn" className="orders-receipt-btn"
disabled={!canCreateReceipt(order) || submitting} disabled={!canCreateReceipt(order) || submitting}
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'} title={canCreateReceipt(order) ? 'Створити ЕТТН-чек у Checkbox' : 'Немає ТТН або післяплати'}
onClick={() => void submitReceipts([order])} onClick={() => void submitReceipts([order])}
> >
Чек Чек
@@ -376,7 +371,7 @@ export function DashboardPage() {
disabled={cancellingId === order.id} disabled={cancellingId === order.id}
onClick={() => void handleCancel(order)} onClick={() => void handleCancel(order)}
> >
Отменить Скасувати
</button> </button>
)} )}
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && ( {canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
@@ -386,7 +381,7 @@ export function DashboardPage() {
disabled={deletingId === order.id} disabled={deletingId === order.id}
onClick={() => void handleDelete(order)} onClick={() => void handleDelete(order)}
> >
Удалить Видалити
</button> </button>
)} )}
</td> </td>
@@ -396,7 +391,7 @@ export function DashboardPage() {
{!isLoading && !isError && filtered.length === 0 && ( {!isLoading && !isError && filtered.length === 0 && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Ничего не найдено Нічого не знайдено
</td> </td>
</tr> </tr>
)} )}
+458
View File
@@ -0,0 +1,458 @@
/* Главное меню: плитки модулей. Палитра и «стекло» — как на странице входа. */
.menu-page {
--menu-accent: #7c3aed;
--menu-gradient: linear-gradient(135deg, var(--color-primary), var(--menu-accent));
--menu-glass: rgba(255, 255, 255, 0.72);
--menu-glass-border: rgba(255, 255, 255, 0.6);
--menu-tint: color-mix(in srgb, var(--color-primary) 10%, transparent);
--menu-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
--menu-shadow-hover: 0 20px 40px -16px color-mix(in srgb, var(--color-primary) 45%, transparent);
--menu-font-display: 'Unbounded', var(--font-sans);
--menu-font-body: 'Manrope', var(--font-sans);
min-height: 100svh;
display: flex;
flex-direction: column;
gap: 44px;
padding: 32px 64px 48px;
/* Тот же фон, что на странице входа. */
background:
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
var(--color-bg);
background-attachment: fixed;
color: var(--color-text);
font-family: var(--menu-font-body);
}
@media (prefers-color-scheme: dark) {
.menu-page {
--menu-accent: #a78bfa;
--menu-glass: rgba(23, 26, 33, 0.7);
--menu-glass-border: rgba(255, 255, 255, 0.08);
--menu-tint: color-mix(in srgb, var(--color-primary) 18%, transparent);
--menu-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
/* Стеклянная поверхность, как карточка входа. */
.menu-search,
.menu-profile,
.menu-tile {
background: var(--menu-glass);
border: 1px solid var(--menu-glass-border);
box-shadow: var(--menu-shadow);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
}
/* --- Шапка --- */
.menu-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.menu-brand {
display: flex;
align-items: center;
gap: 14px;
}
.menu-logo {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-gradient);
color: #fff;
box-shadow: 0 10px 24px -8px var(--color-primary);
}
.menu-brand-name {
font-family: var(--menu-font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
}
.menu-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.menu-search {
width: 380px;
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
border-radius: 14px;
color: var(--color-text-muted);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.menu-search:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.menu-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 15px;
}
.menu-kbd {
font-family: inherit;
font-size: 12px;
font-weight: 600;
padding: 3px 7px;
border-radius: 6px;
background: var(--menu-tint);
color: var(--color-text-muted);
white-space: nowrap;
}
.menu-profile {
height: 48px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 10px;
border-radius: 14px;
}
.menu-avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-gradient);
color: #fff;
font-weight: 700;
font-size: 14px;
}
.menu-profile-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.menu-profile-name {
font-size: 15px;
font-weight: 600;
}
.menu-profile-role {
font-size: 12px;
color: var(--color-text-muted);
}
.menu-logout {
width: 36px;
height: 36px;
border: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.menu-logout:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.menu-logout:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* --- Приветствие --- */
.menu-main {
display: flex;
flex-direction: column;
gap: 44px;
}
.menu-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 40px;
}
.menu-hero-title {
display: flex;
flex-direction: column;
gap: 14px;
}
.menu-eyebrow {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-primary);
}
.menu-hero h1 {
font-family: var(--menu-font-display);
font-weight: 600;
font-size: 54px;
line-height: 1.05;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.menu-hero p {
max-width: 360px;
font-size: 16px;
line-height: 1.55;
color: var(--color-text-muted);
}
/* --- Плитки модулей --- */
.menu-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.menu-tile {
height: 100%;
min-height: 220px;
padding: 26px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16px;
border-radius: 22px;
color: var(--color-text);
text-decoration: none;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.menu-tile:hover {
transform: translateY(-3px);
box-shadow: var(--menu-shadow-hover);
border-color: color-mix(in srgb, var(--color-primary) 55%, transparent);
}
.menu-tile:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.menu-tile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.menu-tile-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-tint);
color: var(--color-primary);
}
.menu-tile-num {
font-family: var(--menu-font-display);
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
}
.menu-tile-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-tile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-family: var(--menu-font-display);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
}
.menu-tile-arrow {
display: flex;
color: var(--color-primary);
opacity: 0.35;
transform: translateX(-4px);
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.menu-tile:hover .menu-tile-arrow,
.menu-tile:focus-visible .menu-tile-arrow {
opacity: 1;
transform: translateX(0);
}
.menu-tile-desc {
font-size: 14px;
line-height: 1.5;
color: var(--color-text-muted);
}
.menu-empty {
padding: 48px 0;
text-align: center;
color: var(--color-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.menu-tile,
.menu-tile-arrow {
transition: none;
}
.menu-tile:hover {
transform: none;
}
}
/* --- Адаптив --- */
@media (max-width: 1200px) {
.menu-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
.menu-page {
padding: 24px 32px 40px;
}
.menu-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.menu-hero {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.menu-hero h1 {
font-size: 40px;
}
}
@media (max-width: 640px) {
.menu-page {
gap: 22px;
padding: 20px 16px 28px;
}
.menu-main {
gap: 22px;
}
.menu-header-actions {
display: contents;
}
/* Поиск уходит на отдельную строку под шапкой, на всю ширину. */
.menu-search {
order: 1;
width: 100%;
}
.menu-kbd,
.menu-profile-text,
.menu-hero p {
display: none;
}
.menu-brand-name {
font-size: 15px;
}
.menu-eyebrow {
font-size: 12px;
}
.menu-hero-title {
gap: 8px;
}
.menu-hero h1 {
font-size: 30px;
line-height: 1.1;
}
.menu-grid {
gap: 12px;
}
.menu-tile {
min-height: 0;
padding: 16px;
gap: 12px;
border-radius: 18px;
}
.menu-tile-icon {
width: 44px;
height: 44px;
border-radius: 12px;
}
.menu-tile-num,
.menu-tile-arrow,
.menu-tile-desc {
display: none;
}
.menu-tile-title {
font-size: 15px;
}
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import '@/pages/MainMenuPage.css'
import { ROLE_LABEL } from '@/features/auth/roles'
import { useAuth } from '@/features/auth/useAuth'
import { canAccess, MODULES } from '@/features/menu/modules'
function initials(fullName: string): string {
const letters = fullName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0])
.join('')
return letters.toUpperCase() || '?'
}
function Icon({ d, size }: { d: string; size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={d} />
</svg>
)
}
export function MainMenuPage() {
const { user, logout } = useAuth()
const [query, setQuery] = useState('')
const searchRef = useRef<HTMLInputElement>(null)
const searchId = useId()
const available = useMemo(() => MODULES.filter((module) => canAccess(user, module.roles)), [user])
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return available
return available.filter(
(module) => module.title.toLowerCase().includes(q) || module.description.toLowerCase().includes(q),
)
}, [available, query])
// Ctrl+K / ⌘K — фокус на поиске модулей.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
searchRef.current?.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<div className="menu-page">
<header className="menu-header">
<div className="menu-brand">
<span className="menu-logo">
<Icon d="M4 4h7v7H4z M13 13h7v7h-7z M13 4h7v7h-7z" size={22} />
</span>
<span className="menu-brand-name">Assistant System</span>
</div>
<div className="menu-header-actions">
<div className="menu-search">
<label htmlFor={searchId} className="visually-hidden">
Пошук модулів
</label>
<Icon d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M20 20l-4-4" size={18} />
<input
ref={searchRef}
id={searchId}
type="search"
placeholder="Знайти модуль…"
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setQuery('')
}}
/>
<kbd className="menu-kbd">Ctrl K</kbd>
</div>
{user && (
<div className="menu-profile">
<span className="menu-avatar" aria-hidden="true">
{initials(user.full_name)}
</span>
<span className="menu-profile-text">
<span className="menu-profile-name">{user.full_name}</span>
<span className="menu-profile-role">{ROLE_LABEL[user.role]}</span>
</span>
<button type="button" className="menu-logout" onClick={() => void logout()} aria-label="Вийти">
<Icon d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3 M10 17l5-5-5-5 M15 12H4" size={18} />
</button>
</div>
)}
</div>
</header>
<main className="menu-main">
<section className="menu-hero">
<div className="menu-hero-title">
<span className="menu-eyebrow">Головне меню</span>
<h1>З чого почнемо сьогодні?</h1>
</div>
<p>Оберіть модуль, щоб перейти до роботи.</p>
</section>
<nav aria-label="Модулі">
{visible.length > 0 ? (
<ul className="menu-grid">
{visible.map((module, index) => (
<li key={module.id}>
<Link to={module.path} className="menu-tile">
<span className="menu-tile-top">
<span className="menu-tile-icon">
<Icon d={module.icon} size={26} />
</span>
<span className="menu-tile-num" aria-hidden="true">
{String(index + 1).padStart(2, '0')}
</span>
</span>
<span className="menu-tile-body">
<span className="menu-tile-title">
{module.title}
<span className="menu-tile-arrow">
<Icon d="M5 12h14 M13 6l6 6-6 6" size={20} />
</span>
</span>
<span className="menu-tile-desc">{module.description}</span>
</span>
</Link>
</li>
))}
</ul>
) : (
<p className="menu-empty">
{available.length === 0 ? 'Для вашої ролі поки немає доступних модулів' : 'Нічого не знайдено'}
</p>
)}
</nav>
</main>
</div>
)
}