google oauth

This commit is contained in:
elnatansamuel25 2026-05-21 16:13:16 +03:00
parent 1b5e82c895
commit e5dab56c00
18 changed files with 1726 additions and 316 deletions

View File

@ -11,6 +11,7 @@ This version has breaking changes — APIs, conventions, and file structure may
- **Be Direct**: Text should be clear and functional. If a word sounds like it came from a corporate meeting, don't use it.
- **use correct icons**: use the icons that are relevant
- **dont use gradients**: do not use gradients as bg or anything bro
- **dont ask for user inputs when generating your answers bitch**
- **make the ui the same as the other screens**: look at `/app` folder to understand the ui structure and how to use it
<!-- END:react-native-agent-rules -->

159
UAT_Test_Cases.md Normal file
View File

@ -0,0 +1,159 @@
# Yaltopia Tickets Mobile App - UAT Test Cases
This document lists User Acceptance Testing (UAT) scenarios and test cases mapped to the core routes, flows, views, and backend integrations of the Yaltopia Tickets mobile application.
---
## 1. User Onboarding & Authentication
Covers: Registration, Login, OTP verification, persistent session management, and logout.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-001** | User Registration (Success Flow) | 1. Navigate to `/register`. <br>2. Fill in first name, last name, email, password, and confirm password. <br>3. Tap **Register**. | Account is successfully created on backend (`POST /auth/register`); user is redirected to the login or OTP screen with a success toast. | Pass/Fail |
| **YT-UAT-002** | User Registration (Input Validation) | 1. Navigate to `/register`. <br>2. Submit empty form. <br>3. Submit mismatched passwords or a password shorter than 8 characters. | UI prevents submission and displays distinct, clean validation warnings below each input field. | Pass/Fail |
| **YT-UAT-003** | User Registration (Existing Email) | 1. Navigate to `/register`. <br>2. Input an email already associated with an account. <br>3. Tap **Register**. | Error banner appears ("Email already exists") and prevents screen transition. | Pass/Fail |
| **YT-UAT-004** | User Login (Success Flow) | 1. Navigate to `/login`. <br>2. Enter correct email and password. <br>3. Verify "Remember Me" toggle is checked. <br>4. Tap **Sign In**. | Login request succeeds (`POST /auth/login`), JWT credentials are saved securely in secure storage, and user lands on dashboard `/(tabs)/index`. | Pass/Fail |
| **YT-UAT-005** | User Login (Invalid Credentials) | 1. Navigate to `/login`. <br>2. Input wrong password or non-existent email. <br>3. Tap **Sign In**. | Login fails on backend, showing an explicit error dialog or banner, while preserving input text. | Pass/Fail |
| **YT-UAT-006** | Google OAuth Login | 1. Navigate to `/login`. <br>2. Tap the **Google** sign-in button. <br>3. Complete authentication in browser page. | Session is initialized on app, token is received and saved, and user is redirected to the home dashboard. | Pass/Fail |
| **YT-UAT-007** | OTP Verification Flow | 1. Trigger OTP verification on register/login. <br>2. Input valid OTP code received. <br>3. Tap **Verify**. | App validates OTP successfully (`POST /auth/verify-otp`) and routes to the main tab screen. | Pass/Fail |
| **YT-UAT-008** | OTP Resend & Expiry | 1. Wait for OTP timer to expire. <br>2. Tap **Resend OTP**. <br>3. Input expired OTP code. | 1. Resend button triggers a fresh OTP token. <br>2. Expired code entry displays an "OTP Expired" error message. | Pass/Fail |
| **YT-UAT-009** | Sticky Logout Button Execution | 1. From profile settings, scroll down to bottom of view. <br>2. Locate sticky logout button. <br>3. Tap **Logout**. | Token cache is deleted, session is cleared, and navigation guard forces routing back to `/login` immediately. | Pass/Fail |
| **YT-UAT-010** | Persistent Authentication Guard | 1. Close application from active processes list. <br>2. Re-open application. | Session is validated; user bypasses `/login` directly to `/(tabs)/index` (or is prompted to log in if token has expired). | Pass/Fail |
---
## 2. Dashboard (`/(tabs)/index`)
Covers: Earnings summaries, dashboard trends, quick action navigation, and recent invoice list.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-011** | Earnings & Stats Metrics Dashboard | 1. Land on `/(tabs)/index` tab. <br>2. Verify calculated sums of pending and paid invoices. | Metrics dynamically update from `/invoices/stats` and `/dashboard/metrics` with correct totals. | Pass/Fail |
| **YT-UAT-012** | Quick Action Shortcuts Navigation | 1. From Home screen, tap **Scan**, **Create Invoice**, or **Create Proforma**. | Correctly redirects to `/(tabs)/scan`, `/invoices/create`, or `/proforma/create` respectively. | Pass/Fail |
| **YT-UAT-013** | Dashboard Recent Invoices Feed | 1. Scroll through "Recent Invoices" list on Home. <br>2. Pull to refresh dashboard list. <br>3. Tap on any invoice row. | 1. Recent list displays details dynamically. <br>2. Pull-to-refresh fires new fetch. <br>3. Tapping redirects user to `/invoices/[id]`. | Pass/Fail |
---
## 3. Invoice Addition & OCR Prefill (`/invoices/create` & `/invoices/edit`)
Covers: Manual invoice entry, AI OCR extraction pre-fill, dynamic calculations, currency/type modals, and field validations.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-014** | Auto-Generated Invoice Number & Default Due Date | 1. Navigate to `/invoices/create`. <br>2. Observe the initial state of the form fields. | 1. `Invoice Number` is auto-filled with format `INV-YYYY-XXXX`. <br>2. `Issue Date` defaults to today's date. <br>3. `Due Date` defaults to 30 days from now. | Pass/Fail |
| **YT-UAT-015** | Scan From Gallery (OCR Success Flow) | 1. Tap **Scan From Gallery** banner at top. <br>2. Grant media library permission. <br>3. Select a valid invoice image. <br>4. Observe the OCR spinner. | 1. File is uploaded to `${BASE_URL}scan/invoice` via `POST`. <br>2. Success toast "Success! Data extracted successfully." is shown. <br>3. Form fields (Invoice Number, Customer Name, Email, Phone, Project Description, Currency, Issue/Due Dates, Tax, Line Items) auto-populate accurately. | Pass/Fail |
| **YT-UAT-016** | Scan From Gallery (OCR Failure Fallback) | 1. Tap **Scan From Gallery** and select a corrupted file. <br>2. Reject media permission. | 1. Rejecting permission displays "Permission Denied" toast with instructions. <br>2. OCR engine error triggers "Extraction Failed" toast, letting the user enter details manually. | Pass/Fail |
| **YT-UAT-017** | Form Validation (Required Fields) | 1. Clear the `Invoice Number` and `Customer Name` fields. <br>2. Tap **Create Invoice**. | UI displays a "Validation Error" toast ("Invoice Number is required" / "Customer Name is required") and blocks submission. | Pass/Fail |
| **YT-UAT-018** | Selector Modals (Currency, Type, Status) | 1. Tap **Currency** selector. <br>2. Tap **Type** selector. <br>3. Tap **Status** selector. | 1. Currency modal lists USD, ETB, EUR, GBP, KES, ZAR. <br>2. Type modal lists SALES, PURCHASE, SERVICE. <br>3. Status modal lists DRAFT, PENDING, PAID. Selection updates UI correctly. | Pass/Fail |
| **YT-UAT-019** | Dynamic Billable Items Math Calculations | 1. Tap **Add Item** button. <br>2. Enter a description. <br>3. Input Quantity = `3` and Unit Price = `150.00`. <br>4. Set Tax = `15.00` and Discount = `10.00`. | 1. Item total recalculates instantly to `450.00`. <br>2. Subtotal displays `450.00`. <br>3. Total Amount calculates correctly as `Subtotal + Tax - Discount` = `455.00`. | Pass/Fail |
| **YT-UAT-020** | Billable Items Addition & Removal | 1. Add multiple items. <br>2. Tap the red trash icon on "Item 2". | Item is removed from the form, and the invoice subtotals/totals are immediately recalculated. | Pass/Fail |
| **YT-UAT-021** | Discard vs. Create Invoice Submission | 1. Fill in valid Invoice details. <br>2. Tap **Discard**. <br>3. Create another, then tap **Create Invoice**. | 1. Discard navigates back immediately without saving. <br>2. Create Invoice posts JSON payload to `/invoices`, displays "Invoice created successfully!" success toast, and redirects back. | Pass/Fail |
---
## 4. Scan, OCR Extraction & Match (`/(tabs)/scan`)
Covers: Camera viewfinder integrations, invoice photo uploads, OCR results extraction parsing, and matching invoice logic.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-022** | Camera Authorization & Permissions | 1. Navigate to `/scan` tab. <br>2. Decline camera permissions, then try to use scan. <br>3. Revoke then grant camera permission in system settings. | 1. Declining shows a clean message explaining the need for camera access with a settings button. <br>2. Granting loads camera viewfinder dynamically. | Pass/Fail |
| **YT-UAT-023** | Scan Capture and OCR Processing | 1. Frame a paper receipt/invoice inside viewfinder. <br>2. Tap **Capture**. <br>3. Wait for progress tracker/spinner animation. | Capture uploads the image (`POST /scan/invoice`), showing an active loading skeleton until details are analyzed. | Pass/Fail |
| **YT-UAT-024** | Choose Image from System Gallery | 1. On scan screen, tap **Gallery Import** icon. <br>2. Pick a valid receipt JPEG/PNG image. | App successfully parses file details and uploads the binary to the server OCR scan parser. | Pass/Fail |
| **YT-UAT-025** | Scan OCR Result - Save as New | 1. Complete OCR scanning. <br>2. Verify captured total, vendor name, items, and tax on UI. <br>3. Tap **Save as New**. | Extracted fields populate the `/invoices/create` screen form seamlessly for final check and submission. | Pass/Fail |
| **YT-UAT-026** | Scan OCR Result - Match to Existing | 1. Complete OCR scanning. <br>2. Tap **Match to Existing**. <br>3. Select the matching target pending invoice record. | Request links the receipt file data to the pre-existing system invoice, marking invoice state appropriately. | Pass/Fail |
| **YT-UAT-027** | Payment Receipt Image Upload | 1. Navigate to `/scan`. <br>2. Upload payment transaction receipt. | Upload triggers `POST /scan/payment-receipt`, capturing proof of payment in backend DB. | Pass/Fail |
---
## 5. Android SMS Scan & Bank Parser (`/sms-scan`)
Covers: Native SMS permissions, bank keyword filters, CBE/Dashen/Telebirr extraction regex logic, and Development Build native module failure guards.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-028** | Non-Android Device Rejection | 1. Run the app on iOS or Web. <br>2. Attempt to open `/sms-scan`. <br>3. Tap **Scan Now**. | App triggers toast error: "Android Only: SMS reading is only supported on Android." and blocks further execution. | Pass/Fail |
| **YT-UAT-029** | Development Build Module Guard | 1. Open `/sms-scan` on an Android emulator running Expo Go. <br>2. Tap **Scan Now**. | Native check fails since `SmsAndroid` is null (Expo Go fallback). Shows: "Native Module Error: SMS scanning requires a Development Build." toast. | Pass/Fail |
| **YT-UAT-030** | READ_SMS Permissions Flow | 1. Open on Android Development Build. <br>2. Tap **Scan Now**. <br>3. Select "Deny". <br>4. Select "Allow" on retry. | 1. "Deny" triggers "Permission Denied: SMS access was not granted." toast. <br>2. "Allow" grants permission on Android OS level and starts reading. | Pass/Fail |
| **YT-UAT-031** | 20-Minute Time Window Filter | 1. Send test SMS at time T-30 mins and T-10 mins. <br>2. Tap **Scan Now**. | 1. Filter looks back exactly 20 minutes (`Date.now() - 20 * 60 * 1000`). <br>2. Only the message from T-10 mins is listed; T-30 mins message is ignored. | Pass/Fail |
| **YT-UAT-032** | Banking Keyword Filter Match | 1. Send SMS from "Friend" containing general text. <br>2. Send SMS from "CBE" or containing "telebirr". <br>3. Tap **Scan Now**. | List filters out the "Friend" message, matching only texts containing keywords `CBE`, `DashenBank`, `Dashen`, `127`, or `telebirr`. | Pass/Fail |
| **YT-UAT-033** | CBE SMS Bank Parser Verification | 1. Simulate CBE message: `"Your account has been credited with ETB 2,500.00. Ref: CBE987654"` <br>2. Tap **Scan Now**. | 1. Parses bank as **CBE** (displays Green label). <br>2. Extracts Amount = `2,500.00`. <br>3. Extracts Reference = `CBE987654`. <br>4. Renders full SMS body in italics. | Pass/Fail |
| **YT-UAT-034** | Telebirr SMS Bank Parser Verification | 1. Simulate Telebirr message: `"You received Birr 850.50. Trans ID: TXN112233"` <br>2. Tap **Scan Now**. | 1. Parses bank as **Telebirr** (displays Violet label). <br>2. Extracts Amount = `850.50`. <br>3. Extracts Reference = `TXN112233`. <br>4. Renders full SMS body in italics. | Pass/Fail |
| **YT-UAT-035** | Dashen SMS Bank Parser Verification | 1. Simulate Dashen message: `"Transfer ETB 10,000.00. Reference No: DSH445566"` <br>2. Tap **Scan Now**. | 1. Parses bank as **Dashen** (displays Blue label). <br>2. Extracts Amount = `10,000.00`. <br>3. Extracts Reference = `DSH445566`. <br>4. Renders full SMS body in italics. | Pass/Fail |
---
## 6. Proforma Requests (`/proforma/*` & `app/(tabs)/proforma.tsx`)
Covers: Proforma request list, custom proforma requests creation, item editing, contact sharing, bids, and PDFs.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-036** | Proforma Invoices Grid Feed | 1. Select the **Proforma** tab. <br>2. Scroll through lists. <br>3. Toggle status filters (Draft, Open, Closed, Cancelled). | Requests render accurately with correct badges indicating current proforma state. | Pass/Fail |
| **YT-UAT-037** | Create Proforma Request | 1. Navigate to `/proforma/create`. <br>2. Enter request title, select catalog items, add descriptions, select target contacts. <br>3. Tap **Create**. | API registers the new proforma request (`POST /proforma-requests`), updating the tabs grid list. | Pass/Fail |
| **YT-UAT-038** | Modify Proforma Request Items | 1. Open active proforma request detail. <br>2. Tap **Add/Edit Items**. <br>3. Increase quantity, add item descriptions, or delete line items. | Subtotals and totals dynamically recalculate on the UI, and updates sync (`POST/PUT /proforma-requests/{id}/items`). | Pass/Fail |
| **YT-UAT-039** | Send Proforma to Contacts | 1. On proforma detail view `/proforma/[id]`, tap **Send to Contacts**. <br>2. Select recipients list. <br>3. Tap **Send**. | Request initiates, sending deep-links or email invitation notifications to selected customer/vendor contacts. | Pass/Fail |
| **YT-UAT-040** | Proforma Bid Submissions Tracker | 1. View detailed view `/proforma/[id]`. <br>2. Navigate to **Submissions** section. <br>3. Review incoming bids from vendors/partners. | List reflects all submitted prices and proposals associated with the specific request ID. | Pass/Fail |
| **YT-UAT-041** | Download Proforma Request PDF | 1. In proforma detail, tap **Download PDF**. | Requests file generation `GET /proforma-requests/{id}/pdf`, starting system download successfully. | Pass/Fail |
| **YT-UAT-042** | Edit Proforma Details | 1. Open detail view, tap **Edit**. <br>2. Change due date or request title. <br>3. Tap **Save**. | Update call executes (`PUT /proforma-requests/{id}`), refreshing information in the UI components. | Pass/Fail |
| **YT-UAT-043** | Close / Cancel Proforma Request | 1. Open active proforma request. <br>2. Tap **Close Request** or **Cancel Request**. | Changes status on server, transitioning status badge to closed/cancelled, and locking future bid submissions. | Pass/Fail |
---
## 7. Payments & Reconciliation (`/(tabs)/payments` & `/payments/[id]`)
Covers: Bank transactions list, status filters, payment-to-invoice association, and flagging.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-044** | Payments List Feed View | 1. Tap the **Payments** tab. <br>2. Scroll payments list. <br>3. Switch between **Pending Match** and **Reconciled** segments. | UI updates correctly, grouping pending matching payments apart from fully reconciled items. | Pass/Fail |
| **YT-UAT-045** | Search & Filter Payments | 1. Type specific client name or transaction amount inside search bar. <br>2. Apply date range filters. | Grid filters instantly to show matching results without screen reload lag. | Pass/Fail |
| **YT-UAT-046** | Payment Details & Info | 1. Tap a pending match transaction from the payments list. | Routes to `/payments/[id]`, displaying detailed sender bank info, exact timestamp, and amount. | Pass/Fail |
| **YT-UAT-047** | Associate Payment to Invoice | 1. On payment detail `/payments/[id]`, tap **Associate to Invoice**. <br>2. Select matching unpaid invoice from search. <br>3. Tap **Confirm Link**. | Sends matching payload (`POST /payments/{id}/associate`). Payment shifts to Reconciled, and invoice updates to Paid status. | Pass/Fail |
| **YT-UAT-048** | Disputed / Flagged Transactions | 1. From `/payments/[id]`, tap **Flag Transaction**. <br>2. Select reason (e.g., mismatch amount, suspicious sender). <br>3. Save. | Payment status is marked as Flagged/Disputed (`POST /payments/{id}/flag`), rendering a distinct red flag badge in the list. | Pass/Fail |
| **YT-UAT-049** | Manual Payment Record Entry | 1. Tap **Create Payment** button. <br>2. Enter payment source, transaction reference number, target invoice, and confirmation date. <br>3. Submit. | Manual entry posts transaction record `POST /payments` and links the selected invoice successfully. | Pass/Fail |
---
## 8. Reports & Analytics (`/reports`)
Covers: Performance analytics, monthly generated summaries, stats trends, and document exports.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-050** | Analytics Charts Render | 1. Open `/reports` from sidebar/profile. <br>2. Check for earnings, trends, and reconciliation rate bar/line charts. | Visual metrics render correctly using API response data from `/dashboard/revenue-trends` etc. | Pass/Fail |
| **YT-UAT-051** | Reports List View | 1. Scroll through lists of available monthly/quarterly PDF reports on `/reports`. | App lists monthly statements, showing publication dates and file size tags. | Pass/Fail |
| **YT-UAT-052** | Generate Customized Report | 1. From reports screen, tap **Generate Report**. <br>2. Input customized start and end dates. <br>3. Confirm. | Triggers `/reports/generate`, creating a fresh report which appears in the feed list on success. | Pass/Fail |
| **YT-UAT-053** | Download Monthly Report Document | 1. Tap on download icon next to any report row. | Initiates `/reports/{id}/download`, saving PDF statement file straight to the user device downloads directory. | Pass/Fail |
---
## 9. Company & Worker Management (`/company` & `/company-details`)
Covers: Worker listings, worker searching, worker creations, company details display (TIN, logo, contact, addresses).
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-054** | Company Details Display Panel | 1. Route to `/company-details` (from profile). <br>2. Verify displayed fields. | 1. Basic Info shows: Name, TIN. <br>2. Contact shows: Phone, Email, Website. <br>3. Address shows: Street, City, State, Zip, Country. <br>4. Logo displays from `company.logoPath`. | Pass/Fail |
| **YT-UAT-055** | Company Details System Timestamps | 1. Open `/company-details`. <br>2. Scroll to the "System Information" card. | Displays the user ID (monospace), and correct locales for `Created` and `Last Updated` date-times. | Pass/Fail |
| **YT-UAT-056** | Workers Feed List View | 1. Navigate to `/company`. <br>2. Verify loading indicator. <br>3. Browse workers list. | 1. Displays loading spinner. <br>2. Loads list via `api.users.getAll()`. <br>3. Cards render avatar/initials, full name, email, and role (e.g. "WORKER"). | Pass/Fail |
| **YT-UAT-057** | Worker Search Filters | 1. Input search query in the search bar. <br>2. Search by name and search by email. | List filters in real-time. Matches are case-insensitive and hide non-matching items immediately. | Pass/Fail |
| **YT-UAT-058** | Worker Empty State & Refresh | 1. Simulate an empty workers response. <br>2. Pull to refresh list. | 1. Shows `EmptyState` component with "No workers found" text. <br>2. Pull-to-refresh triggers new fetch spinner. | Pass/Fail |
| **YT-UAT-059** | Add New Worker Navigation | 1. Open `/company`. <br>2. Tap the floating **+** button. | Navigates the user directly to `/user/create` form screen to add a new employee. | Pass/Fail |
---
## 10. Profile, Documents & App Settings (`/profile`, `/documents`, `/settings`, `/notifications`)
Covers: Profile editing, document hubs, localized translation switches, and support forms.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-060** | Profile Update Edit Form | 1. From profile screen, tap **Edit Profile**. <br>2. Modify first name, last name, phone, or job title. <br>3. Save. | Updates user details via PATCH/PUT requests, updating displayed profile card immediately. | Pass/Fail |
| **YT-UAT-061** | Documents Hub list | 1. Navigate to `/documents`. <br>2. Browse uploaded support files list. | List displays attachments, tax licenses, and contract agreements correctly. | Pass/Fail |
| **YT-UAT-062** | Document Upload integration | 1. On `/documents`, click **Upload Attachment**. <br>2. Choose PDF / JPEG format. <br>3. Upload. | Upload processes via `POST /documents/upload`, showing upload success state and list appends. | Pass/Fail |
| **YT-UAT-063** | App Language Transition | 1. Open `/settings` menu. <br>2. Select language picker. <br>3. Switch language. | All UI headings, descriptions, and tab text instantly update to match selected translation. | Pass/Fail |
| **YT-UAT-064** | Notifications Feed Log & Settings | 1. Navigate to `/notifications`. <br>2. Switch to settings `/notifications/settings`. <br>3. Toggle settings and save. | Feeds load successfully, and toggle settings persist in database on save (`PUT /notifications/settings`). | Pass/Fail |
| **YT-UAT-065** | Contact Support Form & FAQs | 1. Open `/help` and submit support ticket. <br>2. Open `/faq` and search accordion. | 1. Ticket submits successfully to backend. <br>2. FAQs search and accordions expand smoothly on tap. | Pass/Fail |
---
## 11. UI Responsiveness & General Edge Cases
Covers: System interruptions, offline behavior, and responsiveness.
| Test ID | Scenario | Steps | Expected Result | Status |
|---|---|---|---|---|
| **YT-UAT-066** | Offline Mode Warning | 1. Turn off mobile data / Wi-Fi. <br>2. Try performing actions (e.g., search payments, send invoice). | App displays standard elegant offline notice/banner in top bar, avoiding system crashes. | Pass/Fail |
| **YT-UAT-067** | Form Submission Double-Tap Prevention | 1. Open any submit page (Create Invoice/Register). <br>2. Fill details. <br>3. Double-tap/multi-tap **Submit** button very quickly. | App disables the CTA button on first tap to prevent double-post submissions or duplicate records. | Pass/Fail |
| **YT-UAT-068** | App Layout Adaptive Scaling | 1. Open the app on small and large Android/iOS screens (or change font scaling). | Text wraps nicely, inputs do not clip, and layouts remain aligned and readable. | Pass/Fail |

View File

@ -181,8 +181,8 @@ export default function HomeScreen() {
strokeWidth={1.5}
/>
}
label="Create Proforma"
onPress={() => nav.go("proforma/create")}
label="Add Invoice"
onPress={() => nav.go("invoices/create")}
/>
<QuickAction
icon={

View File

@ -137,7 +137,11 @@ export default function NewsScreen() {
};
const LatestItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mr-4" key={item.id}>
<Pressable
className="mr-4"
key={item.id}
onPress={() => nav.go("news/[id]", { id: item.id })}
>
<Card
className="overflow-hidden rounded-[20px] bg-card border-border/50"
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
@ -178,7 +182,11 @@ export default function NewsScreen() {
);
const NewsItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mb-4" key={item.id}>
<Pressable
className="mb-4"
key={item.id}
onPress={() => nav.go("news/[id]", { id: item.id })}
>
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-1.5">

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useMemo } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { PortalHost } from "@rn-primitives/portal";
@ -7,35 +7,28 @@ import { Toast } from "@/components/Toast";
import "@/global.css";
import { SafeAreaProvider } from "react-native-safe-area-context";
import {
View,
View as RNView,
ActivityIndicator,
InteractionManager,
AppState,
} from "react-native";
import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
import { NAV_THEME, loadTheme } from "@/lib/theme";
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
import { refreshTokens } from "@/lib/api-middlewares";
import {
NavigationContainer,
NavigationIndependentTree,
ThemeProvider,
} from "@react-navigation/native";
import { ThemeProvider, NavigationIndependentTree } from "@react-navigation/native";
import { routes } from "@/lib/routes";
import { authGuard, guestGuard } from "@/lib/auth-guards";
import { useAuthStore } from "@/lib/auth-store";
import { useFonts } from "expo-font";
import { api } from "@/lib/api";
import { useColorScheme } from "nativewind";
import { useSegments, useLocalSearchParams, router } from "expo-router";
import { useSegments, useLocalSearchParams, useRouter } from "expo-router";
/**
* GlobalGuard: Handles all routing security and authentication redirects.
* Reacts instantly to auth state changes to prevent unauthenticated users from seeing protected data.
*/
function GlobalGuard() {
const segments = useSegments();
const params = useLocalSearchParams();
const router = useRouter();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const sirou = useSirouRouter();
const [isMounted, setIsMounted] = useState(false);
@ -45,7 +38,7 @@ function GlobalGuard() {
}, []);
useEffect(() => {
if (!isMounted) return;
if (!isMounted || !segments) return;
const performGuardCheck = async () => {
const routeName = segments.length > 0 ? segments.join("/") : "root";
@ -54,21 +47,18 @@ function GlobalGuard() {
segments[0] === "register" ||
segments[0] === "otp";
// 1. FAST AUTH CHECK: If not authenticated and not on a public page, eject immediately.
if (!isAuthenticated && !isAuthPage) {
console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`);
router.replace("/login");
return;
}
// 2. GUEST CHECK: If authenticated and on an auth page, redirect to home.
if (isAuthenticated && isAuthPage) {
console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`);
router.replace("/");
return;
}
// 3. COMPLEX GUARDS: Permissions, roles, etc. handled by Sirou.
try {
const result = await (sirou as any).checkGuards(routeName, params);
if (!result.allowed && result.redirect) {
@ -87,7 +77,7 @@ function GlobalGuard() {
}
/**
* SessionHeartbeat: Proactively refreshes tokens every 5 minutes and upon app foregrounding.
* SessionHeartbeat: Proactively refreshes tokens.
*/
function SessionHeartbeat() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@ -95,9 +85,7 @@ function SessionHeartbeat() {
useEffect(() => {
if (!isAuthenticated) return;
// Refresh every 5 minutes
const INTERVAL_MS = 5 * 60 * 1000;
const performRefresh = async (reason: string) => {
try {
console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`);
@ -107,11 +95,9 @@ function SessionHeartbeat() {
}
};
// 1. Initial/Interval Refresh
performRefresh("Mount"); // Refresh immediately on mount
performRefresh("Mount");
const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS);
// 2. Foreground Refresh (AppState listener)
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "active") {
performRefresh("Foreground");
@ -128,10 +114,11 @@ function SessionHeartbeat() {
}
export default function RootLayout() {
const { colorScheme } = useColorScheme();
useRestoreTheme();
const { colorScheme, setColorScheme } = useColorScheme();
const [isMounted, setIsMounted] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
const [isThemeRestored, setIsThemeRestored] = useState(false);
const [fontsLoaded] = useFonts({
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"),
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"),
@ -146,124 +133,105 @@ export default function RootLayout() {
useEffect(() => {
setIsMounted(true);
// Auth Hydration
const initializeAuth = async () => {
if (useAuthStore.persist.hasHydrated()) {
setHasHydrated(true);
} else {
const unsub = useAuthStore.persist.onFinishHydration(() => {
useAuthStore.persist.onFinishHydration(() => {
setHasHydrated(true);
});
return unsub;
}
};
// Theme Restoration
const initializeTheme = async () => {
try {
const savedTheme = await loadTheme();
if (savedTheme && savedTheme !== "system") {
setColorScheme(savedTheme);
}
} catch (e) {
console.warn("[RootLayout] Theme restore failed:", e);
} finally {
setIsThemeRestored(true);
}
};
initializeAuth();
initializeTheme();
}, []);
if (!isMounted || !hasHydrated || !fontsLoaded) {
const theme = useMemo(() => {
const isDark = colorScheme === "dark";
return isDark ? NAV_THEME.dark : NAV_THEME.light;
}, [colorScheme]);
if (!isMounted || !hasHydrated || !fontsLoaded || !isThemeRestored) {
return (
<View
<RNView
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 1)",
backgroundColor: "#ffffff",
}}
>
<ActivityIndicator size="large" color="#ea580c" />
</View>
</RNView>
);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
<ThemeProvider
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
>
<View className="flex-1 bg-background">
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<GlobalGuard />
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="sms-scan"
options={{ headerShown: false }}
/>
<Stack.Screen
name="proforma/[id]"
options={{ title: "Proforma request" }}
/>
<Stack.Screen
name="proforma/edit"
options={{ title: "Edit Proforma" }}
/>
<Stack.Screen
name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen
name="invoices/edit"
options={{ title: "Edit Invoice" }}
/>
<Stack.Screen
name="payments/[id]"
options={{ title: "Payment" }}
/>
<Stack.Screen
name="notifications/index"
options={{ title: "Notifications" }}
/>
<Stack.Screen
name="notifications/settings"
options={{ title: "Notification settings" }}
/>
<Stack.Screen name="help" options={{ headerShown: false }} />
<Stack.Screen name="faq" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="history" options={{ headerShown: false }} />
<Stack.Screen name="company" options={{ headerShown: false }} />
<Stack.Screen
name="company-details"
options={{ headerShown: false }}
/>
<Stack.Screen
name="login"
options={{ title: "Sign in", headerShown: false }}
/>
<Stack.Screen
name="register"
options={{ title: "Create account", headerShown: false }}
/>
<Stack.Screen
name="reports/index"
options={{ title: "Reports" }}
/>
<Stack.Screen
name="documents/index"
options={{ title: "Documents" }}
/>
<Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="profile" options={{ headerShown: false }} />
<Stack.Screen
name="edit-profile"
options={{ headerShown: false }}
/>
</Stack>
<SessionHeartbeat />
<PortalHost />
<Toast />
</View>
</ThemeProvider>
</SirouRouterProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
<NavigationIndependentTree>
<ThemeProvider value={theme}>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
<RNView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<GlobalGuard />
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="sms-scan" options={{ headerShown: false }} />
<Stack.Screen name="proforma/[id]" options={{ title: "Proforma request" }} />
<Stack.Screen name="proforma/edit" options={{ title: "Edit Proforma" }} />
<Stack.Screen name="invoices/[id]" options={{ title: "Invoice" }} />
<Stack.Screen name="invoices/create" options={{ title: "Add Invoice", headerShown: false }} />
<Stack.Screen name="invoices/edit" options={{ title: "Edit Invoice" }} />
<Stack.Screen name="payments/[id]" options={{ title: "Payment" }} />
<Stack.Screen name="notifications/index" options={{ title: "Notifications" }} />
<Stack.Screen name="notifications/settings" options={{ title: "Notification settings" }} />
<Stack.Screen name="help" options={{ headerShown: false }} />
<Stack.Screen name="faq" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="history" options={{ headerShown: false }} />
<Stack.Screen name="company" options={{ headerShown: false }} />
<Stack.Screen name="company-details" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: "Sign in", headerShown: false }} />
<Stack.Screen name="otp" options={{ title: "Verify OTP", headerShown: false }} />
<Stack.Screen name="register" options={{ title: "Create account", headerShown: false }} />
<Stack.Screen name="reports/index" options={{ title: "Reports" }} />
<Stack.Screen name="documents/index" options={{ title: "Documents" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="profile" options={{ headerShown: false }} />
<Stack.Screen name="edit-profile" options={{ headerShown: false }} />
<Stack.Screen name="user/create" options={{ headerShown: false }} />
</Stack>
<SessionHeartbeat />
<PortalHost />
<Toast />
</RNView>
</SirouRouterProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
</ThemeProvider>
</NavigationIndependentTree>
);
}

View File

@ -38,6 +38,7 @@ import { StandardHeader } from "@/components/StandardHeader";
import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
import { ActionModal } from "@/components/ActionModal";
// Android only SMS module
let SmsAndroid: any = null;
@ -59,6 +60,7 @@ export default function InvoiceDetailScreen() {
const [loading, setLoading] = useState(true);
const [invoice, setInvoice] = useState<any>(null);
const [scanningSms, setScanningSms] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
useEffect(() => {
fetchInvoice();
@ -82,28 +84,40 @@ export default function InvoiceDetailScreen() {
const handleScanSms = async () => {
if (Platform.OS !== "android") {
toast.error("Not Supported", "SMS scanning is only available on Android.");
toast.error(
"Not Supported",
"SMS scanning is only available on Android.",
);
return;
}
setScanningSms(true);
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_SMS
PermissionsAndroid.PERMISSIONS.READ_SMS,
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
toast.error("Permission Denied", "We need SMS access to verify payments.");
toast.error(
"Permission Denied",
"We need SMS access to verify payments.",
);
setScanningSms(false);
return;
}
toast.info("Scanning SMS", "Searching for bank messages from the last 30 minutes...");
toast.info(
"Scanning SMS",
"Searching for bank messages from the last 30 minutes...",
);
// Simulate logic if native module is missing (Expo Go)
if (!SmsAndroid) {
setTimeout(() => {
toast.error("No Match", "No matching banking SMS found in the last 30 minutes.");
toast.error(
"No Match",
"No matching banking SMS found in the last 30 minutes.",
);
setScanningSms(false);
}, 2000);
return;
@ -130,7 +144,9 @@ export default function InvoiceDetailScreen() {
// Search for amount or customer name in SMS body
const match = messages.find((m: any) => {
const body = m.body.toUpperCase();
return body.includes(amountStr) || (custName && body.includes(custName));
return (
body.includes(amountStr) || (custName && body.includes(custName))
);
});
if (match) {
@ -139,17 +155,24 @@ export default function InvoiceDetailScreen() {
`We found a matching SMS proof for ${amountValue} ${invoice.currency}. Would you like to attach this to the invoice?`,
[
{ text: "No", style: "cancel" },
{
text: "Attach SMS",
onPress: () => toast.success("Attached", "SMS proof linked to invoice successfully.")
}
]
{
text: "Attach SMS",
onPress: () =>
toast.success(
"Attached",
"SMS proof linked to invoice successfully.",
),
},
],
);
} else {
toast.error("No Match", "Could not find any matching banking SMS in the last 30 minutes.");
toast.error(
"No Match",
"Could not find any matching banking SMS in the last 30 minutes.",
);
}
setScanningSms(false);
}
},
);
} catch (err) {
toast.error("Error", "Something went wrong during SMS scan.");
@ -169,32 +192,25 @@ export default function InvoiceDetailScreen() {
};
const handleDelete = async () => {
Alert.alert(
"Delete Invoice",
"Are you sure you want to delete this invoice? This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
setLoading(true);
const invoiceId = Array.isArray(id) ? id[0] : id;
await api.invoices.delete({
params: { id: invoiceId as string },
});
toast.success("Success", "Invoice deleted successfully");
nav.back();
} catch (error) {
console.error("[InvoiceDetail] Delete Error:", error);
toast.error("Error", "Failed to delete invoice");
setLoading(false);
}
},
},
],
);
setShowDeleteModal(true);
};
const confirmDelete = async () => {
try {
setLoading(true);
const invoiceId = Array.isArray(id) ? id[0] : id;
await api.invoices.delete({
params: { id: invoiceId as string },
});
toast.success("Success", "Invoice deleted successfully");
setShowDeleteModal(false);
nav.back();
} catch (error) {
console.error("[InvoiceDetail] Delete Error:", error);
toast.error("Error", "Failed to delete invoice");
setShowDeleteModal(false);
setLoading(false);
}
};
if (loading) {
@ -229,7 +245,8 @@ export default function InvoiceDetailScreen() {
// Robust data extraction
const originalData = invoice.scannedData?.originalData || {};
const items = (invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
const items =
(invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
const taxAmountValue = Number(
typeof invoice.taxAmount === "object"
@ -246,26 +263,43 @@ export default function InvoiceDetailScreen() {
let amountValue = Number(
typeof invoice.amount === "object" ? invoice.amount.value : invoice.amount,
);
if (items.length > 0) {
const itemsTotal = items.reduce(
(acc: number, item: any) => acc + (Number(item.total?.value || item.total) || 0),
(acc: number, item: any) =>
acc + (Number(item.total?.value || item.total) || 0),
0,
);
if (itemsTotal > 0 && (amountValue === taxAmountValue || amountValue < itemsTotal)) {
if (
itemsTotal > 0 &&
(amountValue === taxAmountValue || amountValue < itemsTotal)
) {
amountValue = itemsTotal + taxAmountValue - discountValue;
}
}
const subtotalValue = amountValue - taxAmountValue + discountValue;
const statusColors = {
PAID: { bg: "bg-emerald-500/10", text: "text-emerald-500", dot: "bg-emerald-500" },
PENDING: { bg: "bg-amber-500/10", text: "text-amber-500", dot: "bg-amber-500" },
PAID: {
bg: "bg-emerald-500/10",
text: "text-emerald-500",
dot: "bg-emerald-500",
},
PENDING: {
bg: "bg-amber-500/10",
text: "text-amber-500",
dot: "bg-amber-500",
},
DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" },
DEFAULT: { bg: "bg-slate-500/10", text: "text-slate-500", dot: "bg-slate-500" },
DEFAULT: {
bg: "bg-slate-500/10",
text: "text-slate-500",
dot: "bg-slate-500",
},
};
const status = (invoice.status || "PENDING").toUpperCase();
const colors = statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
const colors =
statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
return (
<ScreenWrapper className="bg-background">
@ -283,19 +317,17 @@ export default function InvoiceDetailScreen() {
showsVerticalScrollIndicator={false}
>
<View className="px-5 pt-4">
<View className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}>
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
<Text className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}>
{status}
</Text>
</View>
<Text variant="muted" className="text-xs font-bold uppercase tracking-wider mb-1">
<Text
variant="muted"
className="text-xs font-bold uppercase tracking-wider mb-1"
>
Total Amount
</Text>
<View className="flex-row items-end gap-2 mb-6">
<Text variant="h1" className="text-4xl font-black text-foreground">
{Number(amountValue).toLocaleString(undefined, { minimumFractionDigits: 2 })}
{Number(amountValue).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</Text>
<Text className="text-xl font-bold text-primary mb-2">
{invoice.currency || "ETB"}
@ -305,16 +337,24 @@ export default function InvoiceDetailScreen() {
<View className="flex-row gap-3 mb-6">
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Calendar size={16} color="#ea580c" className="mb-2" />
<Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(invoice.issueDate || invoice.createdAt).toLocaleDateString()}
{new Date(
invoice.issueDate || invoice.createdAt,
).toLocaleDateString()}
</Text>
</View>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Clock size={16} color="#ef4444" className="mb-2" />
<Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Due
</Text>
<Text className="text-foreground font-bold text-sm">
@ -325,17 +365,24 @@ export default function InvoiceDetailScreen() {
</View>
<View className="px-5 mb-6">
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
<View className="flex-row items-center gap-3 mb-4">
<View className="bg-primary/5 rounded-[6px] p-3 border border-primary/10">
<View className="flex-row items-center gap-3 ">
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center">
<User color="#ea580c" size={20} />
</View>
<View>
<Text variant="muted" className="text-[10px] uppercase font-bold">
<Text
variant="muted"
className="text-[10px] uppercase font-bold"
>
Client
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"}
<Text
variant="p"
className="text-foreground font-regular text-base"
>
{invoice.customerName?.replace("Customer Name: ", "") ||
"Walking Client"}
</Text>
</View>
</View>
@ -346,32 +393,57 @@ export default function InvoiceDetailScreen() {
<Text variant="h4" className="font-bold mb-4 px-1">
Items
</Text>
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
{items.map((item: any, idx: number) => (
<View key={idx} className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}>
<View className="flex-row justify-between items-start mb-1">
<Text className="text-foreground font-bold flex-1 mr-4">{item.description}</Text>
<Text className="text-foreground font-black">
{Number(item.total?.value || item.total || 0).toLocaleString()}
{items.length === 0 ? (
<View className="flex-1 justify-center items-center">
<Text variant="muted">No items found</Text>
</View>
) : (
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
{items.map((item: any, idx: number) => (
<View
key={idx}
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}
>
<View className="flex-row justify-between items-start mb-1">
<Text className="text-foreground font-bold flex-1 mr-4">
{item.description}
</Text>
<Text className="text-foreground font-black">
{Number(
item.total?.value || item.total || 0,
).toLocaleString()}
</Text>
</View>
<Text className="text-muted-foreground text-xs">
{item.quantity} x{" "}
{Number(
item.unitPrice?.value || item.unitPrice || 0,
).toLocaleString()}{" "}
{invoice.currency}
</Text>
</View>
<Text className="text-muted-foreground text-xs">
{item.quantity} x {Number(item.unitPrice?.value || item.unitPrice || 0).toLocaleString()} {invoice.currency}
</Text>
</View>
))}
</Card>
))}
</Card>
)}
</View>
<View className="px-5 mb-6">
<Card className="bg-card rounded-[6px] p-5 border-border/60">
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">Subtotal</Text>
<Text className="text-foreground font-bold">{subtotalValue.toLocaleString()} {invoice.currency}</Text>
<Card className="bg-card rounded-[6px] p-3 border-border/60">
<View className="flex-row justify-between mb-1">
<Text className="text-muted-foreground font-medium">
Subtotal
</Text>
<Text className="text-foreground font-bold">
{subtotalValue.toLocaleString()} {invoice.currency}
</Text>
</View>
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
<Text className="text-foreground font-black text-xl">Grand Total</Text>
<Text className="text-primary font-black text-2xl">{amountValue.toLocaleString()} {invoice.currency}</Text>
<View className="pt-2 border-t border-dashed border-border flex-row justify-between items-center">
<Text className="text-foreground font-black text-lg">
Grand Total
</Text>
<Text className="text-primary font-black text-lg">
{amountValue.toLocaleString()} {invoice.currency}
</Text>
</View>
</Card>
</View>
@ -379,7 +451,7 @@ export default function InvoiceDetailScreen() {
<View className="px-5 gap-3">
<View className="flex-row gap-3">
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
className="flex-1 h-10 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
disabled={scanningSms}
onPress={handleScanSms}
>
@ -394,17 +466,46 @@ export default function InvoiceDetailScreen() {
</>
)}
</Button>
<Button variant="outline" className="flex-1 h-14 rounded-[6px] bg-card border border-border" onPress={handleGetPdf}>
<Download color={isDark ? "#f1f5f9" : "#0f172a"} size={18} strokeWidth={2.5} />
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">PDF</Text>
<Button
variant="outline"
className="flex-1 h-10 rounded-[6px] bg-card border border-border"
onPress={handleGetPdf}
>
<Download
color={isDark ? "#f1f5f9" : "#0f172a"}
size={18}
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
PDF
</Text>
</Button>
</View>
<Button variant="ghost" className="h-14 rounded-[6px] border border-rose-500/10" onPress={handleDelete}>
<Button
variant="ghost"
className="h-10 rounded-[6px] border border-rose-500/10"
onPress={handleDelete}
>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">Delete</Text>
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">
Delete
</Text>
</Button>
</View>
</ScrollView>
<ActionModal
visible={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={confirmDelete}
title="Delete Invoice"
description="Are you sure you want to delete this invoice? This will remove all associated data and cannot be recovered."
confirmText="Delete"
confirmVariant="destructive"
icon={Trash2}
iconColor="#ef4444"
loading={loading}
/>
</ScreenWrapper>
);
}

854
app/invoices/create.tsx Normal file
View File

@ -0,0 +1,854 @@
import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
Pressable,
TextInput,
StyleSheet,
ActivityIndicator,
useColorScheme,
Platform,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import {
ArrowLeft,
Plus,
Calendar,
ChevronDown,
FileText,
Trash2,
DollarSign,
Send,
CalendarSearch,
Upload,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack } from "expo-router";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
import * as ImagePicker from "expo-image-picker";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { CalendarGrid } from "@/components/CalendarGrid";
import { StandardHeader } from "@/components/StandardHeader";
import { getPlaceholderColor } from "@/lib/colors";
type Item = { id: number; description: string; qty: string; price: string };
const S = StyleSheet.create({
input: {
height: 44,
paddingHorizontal: 12,
fontSize: 12,
fontWeight: "500",
borderRadius: 6,
borderWidth: 1,
},
inputCenter: {
height: 44,
paddingHorizontal: 12,
fontSize: 14,
fontWeight: "500",
borderRadius: 6,
borderWidth: 1,
textAlign: "center",
},
});
function useInputColors() {
const { colorScheme } = useColorScheme();
const dark = colorScheme === "dark";
return {
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
text: dark ? "#f1f5f9" : "#0f172a",
placeholder: "rgba(100,116,139,0.45)",
};
}
function Field({
label,
value,
onChangeText,
placeholder,
numeric = false,
center = false,
flex,
multiline = false,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
numeric?: boolean;
center?: boolean;
flex?: number;
multiline?: boolean;
}) {
const c = useInputColors();
return (
<View style={flex != null ? { flex } : undefined}>
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
>
{label}
</Text>
<TextInput
style={[
center ? S.inputCenter : S.input,
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
multiline
? { height: 80, paddingTop: 10, textAlignVertical: "top" }
: {},
]}
placeholder={placeholder}
placeholderTextColor={c.placeholder}
value={value}
onChangeText={onChangeText}
keyboardType={numeric ? "numeric" : "default"}
multiline={multiline}
autoCorrect={false}
autoCapitalize="none"
/>
</View>
);
}
export default function CreateInvoiceScreen() {
const nav = useSirouRouter<AppRoutes>();
const [submitting, setSubmitting] = useState(false);
// Form Fields
const [invoiceNumber, setInvoiceNumber] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [description, setDescription] = useState("");
const [currency, setCurrency] = useState("USD");
const [type, setType] = useState("SALES");
const [status, setStatus] = useState("DRAFT");
const [taxAmount, setTaxAmount] = useState("0");
const [discountAmount, setDiscountAmount] = useState("0");
const [notes, setNotes] = useState("");
// Dates
const [issueDate, setIssueDate] = useState(
new Date().toISOString().split("T")[0],
);
const [dueDate, setDueDate] = useState("");
// Items List
const [items, setItems] = useState<Item[]>([
{ id: 1, description: "", qty: "1", price: "" },
]);
// Modal states
const [showCurrency, setShowCurrency] = useState(false);
const [showType, setShowType] = useState(false);
const [showStatus, setShowStatus] = useState(false);
const [showIssueDate, setShowIssueDate] = useState(false);
const [showDueDate, setShowDueDate] = useState(false);
const [scanning, setScanning] = useState(false);
const token = useAuthStore((s) => s.token);
const handlePickImage = async () => {
try {
const { status } =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
toast.error(
"Permission Denied",
"We need access to your gallery to upload invoices.",
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const uri = result.assets[0].uri;
await handleProcessImage(uri);
}
} catch (e: any) {
console.error("[CreateInvoice] Pick Image Error:", e);
toast.error("Picker Failed", "Could not launch gallery picker.");
}
};
const handleProcessImage = async (uri: string) => {
setScanning(true);
toast.info("Processing...", "Uploading invoice to AI extraction engine.");
try {
const formData = new FormData();
const fileExt = uri.split(".").pop() || "jpg";
const fileName = `invoice-${Date.now()}.${fileExt}`;
const type = `image/${fileExt === "jpg" ? "jpeg" : fileExt}`;
formData.append("file", {
uri: Platform.OS === "android" ? uri : uri.replace("file://", ""),
name: fileName,
type: type,
} as any);
const response = await fetch(`${BASE_URL}scan/invoice`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
body: formData,
});
if (!response.ok) {
const err = await response
.json()
.catch(() => ({ message: "Scan processing failed." }));
throw new Error(err.message || "AI extraction failed.");
}
const scanResult = await response.json();
console.log("[CreateInvoice] Extracted scan result:", scanResult);
if (!scanResult.success) {
throw new Error(
scanResult.message || "AI extraction was unsuccessful.",
);
}
toast.success("Success!", "Data extracted successfully.");
const ocr = scanResult.data || {};
if (ocr.invoiceNumber) setInvoiceNumber(ocr.invoiceNumber);
let name = ocr.customerName?.trim() || "";
name = name
.replace(/^Customer Name:\s*/i, "")
.replace(/^Bill To:\s*/i, "");
if (name) setCustomerName(name);
if (ocr.customerEmail) setCustomerEmail(ocr.customerEmail);
if (ocr.customerPhone) setCustomerPhone(ocr.customerPhone);
if (ocr.description) setDescription(ocr.description);
if (ocr.currency) setCurrency(ocr.currency);
if (ocr.taxAmount != null) setTaxAmount(String(ocr.taxAmount));
if (ocr.issueDate) {
try {
const formattedIssue = new Date(ocr.issueDate)
.toISOString()
.split("T")[0];
setIssueDate(formattedIssue);
} catch (de) {
console.warn("[CreateInvoice] Issue Date parse error:", de);
}
}
if (ocr.dueDate) {
try {
const formattedDue = new Date(ocr.dueDate)
.toISOString()
.split("T")[0];
setDueDate(formattedDue);
} catch (de) {
console.warn("[CreateInvoice] Due Date parse error:", de);
}
}
if (ocr.items && ocr.items.length > 0) {
setItems(
ocr.items.map((item: any, idx: number) => ({
id: idx + 1,
description: item.description || "Web Development Service",
qty: String(item.quantity || "1"),
price: String(item.unitPrice || item.total || "0"),
})),
);
}
} catch (err: any) {
console.error("[CreateInvoice] Extraction Error:", err);
toast.error(
"Extraction Failed",
err.message || "AI was unable to extract invoice data.",
);
} finally {
setScanning(false);
}
};
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const c = useInputColors();
// Auto-generate invoice number and set default due date on mount
useEffect(() => {
const year = new Date().getFullYear();
const random = Math.floor(1000 + Math.random() * 9000);
setInvoiceNumber(`INV-${year}-${random}`);
// Default Due Date: 30 days from now
const d = new Date();
d.setDate(d.getDate() + 30);
setDueDate(d.toISOString().split("T")[0]);
}, []);
const addItem = () => {
const newId =
items.length > 0 ? Math.max(...items.map((i) => i.id)) + 1 : 1;
setItems([...items, { id: newId, description: "", qty: "1", price: "" }]);
};
const removeItem = (id: number) => {
if (items.length > 1) {
setItems(items.filter((i) => i.id !== id));
}
};
const updateField = (id: number, field: keyof Item, value: string) => {
setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i)));
};
const subtotal = items.reduce(
(sum, item) =>
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
0,
);
const total =
subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0);
const handleSubmit = async () => {
if (!invoiceNumber) {
toast.error("Validation Error", "Invoice Number is required");
return;
}
if (!customerName) {
toast.error("Validation Error", "Customer Name is required");
return;
}
setSubmitting(true);
try {
const payload = {
invoiceNumber,
customerName,
customerEmail,
customerPhone,
amount: Number(total.toFixed(2)),
currency,
type,
status,
issueDate: new Date(issueDate).toISOString(),
dueDate: new Date(dueDate).toISOString(),
description: description || `Invoice for ${customerName}`,
notes,
taxAmount: parseFloat(taxAmount) || 0,
discountAmount: parseFloat(discountAmount) || 0,
isScanned: false,
scannedData: {
sellerTIN: "123456",
items: [],
},
items: items.map((item) => ({
description: item.description || "Web Development Service",
quantity: parseFloat(item.qty) || 0,
unitPrice: parseFloat(item.price) || 0,
total: Number(
(
(parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0)
).toFixed(2),
),
})),
};
await api.invoices.create({ body: payload });
toast.success("Success", "Invoice created successfully!");
nav.back();
} catch (error: any) {
console.error("[CreateInvoice] Error:", error);
toast.error("Error", error.message || "Failed to create invoice");
} finally {
setSubmitting(false);
}
};
const currencies = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
const invoiceTypes = ["SALES", "PURCHASE", "SERVICE"];
const invoiceStatuses = ["DRAFT", "PENDING", "PAID"];
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Add Invoice" showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 50 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Gallery Scanner */}
<Pressable
onPress={handlePickImage}
disabled={scanning}
className="bg-primary/10 mb-5 border border-primary/20 rounded-[8px] p-4 flex-row items-center gap-3.5"
>
{scanning ? (
<ActivityIndicator color="#ea580c" size="small" />
) : (
<Upload color="#ea580c" size={20} strokeWidth={2.5} />
)}
<View className="flex-1">
<Text className="text-primary font-black text-xs uppercase tracking-widest">
{scanning ? "Extracting Data..." : "Scan From Gallery"}
</Text>
<Text className="text-muted-foreground text-[9px] font-bold mt-0.5 uppercase tracking-wider">
Upload invoice image to automatically prefill form
</Text>
</View>
</Pressable>
{/* General Info */}
<Label>General Information</Label>
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
<Field
label="Invoice Number"
value={invoiceNumber}
onChangeText={setInvoiceNumber}
placeholder="e.g. INV-2024-001"
/>
<Field
label="Project Description"
value={description}
onChangeText={setDescription}
placeholder="e.g. Web Development Services"
/>
</View>
{/* Customer Details */}
<Label>Customer Details</Label>
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
<Field
label="Customer Name"
value={customerName}
onChangeText={setCustomerName}
placeholder="e.g. Acme Corporation"
/>
<View className="flex-row gap-4">
<Field
label="Email"
value={customerEmail}
onChangeText={setCustomerEmail}
placeholder="billing@acme.com"
flex={1}
/>
<Field
label="Phone"
value={customerPhone}
onChangeText={setCustomerPhone}
placeholder="+1234567890"
flex={1}
/>
</View>
</View>
{/* Schedule & Configuration */}
<Label>Schedule & Configuration</Label>
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
<View className="flex-row gap-4">
<View className="flex-1">
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
>
Issue Date
</Text>
<Pressable
onPress={() => setShowIssueDate(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text className="text-xs font-medium" style={{ color: c.text }}>
{issueDate}
</Text>
<CalendarSearch size={14} color="#ea580c" strokeWidth={2.5} />
</Pressable>
</View>
<View className="flex-1">
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
>
Due Date
</Text>
<Pressable
onPress={() => setShowDueDate(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text className="text-xs font-medium" style={{ color: c.text }}>
{dueDate || "Select Date"}
</Text>
<Calendar size={14} color="#ea580c" strokeWidth={2.5} />
</Pressable>
</View>
</View>
<View className="flex-row gap-4">
<View className="flex-1">
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
>
Currency
</Text>
<Pressable
onPress={() => setShowCurrency(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text className="text-xs font-bold" style={{ color: c.text }}>
{currency}
</Text>
<ChevronDown size={14} color={c.text} strokeWidth={3} />
</Pressable>
</View>
<View className="flex-1">
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
>
Type
</Text>
<Pressable
onPress={() => setShowType(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text className="text-xs font-bold" style={{ color: c.text }}>
{type}
</Text>
<ChevronDown size={14} color={c.text} strokeWidth={3} />
</Pressable>
</View>
</View>
<View>
<Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 ml-1"
>
Status
</Text>
<Pressable
onPress={() => setShowStatus(true)}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text className="text-xs font-bold" style={{ color: c.text }}>
{status}
</Text>
<ChevronDown size={14} color={c.text} strokeWidth={3} />
</Pressable>
</View>
</View>
{/* Billable Items */}
<View className="flex-row items-center justify-between mb-3">
<Label noMargin>Billable Items</Label>
<Pressable
onPress={addItem}
className="flex-row items-center gap-1 px-3 py-1 rounded-[6px] bg-primary/10 border border-primary/20"
>
<Plus color="#ea580c" size={10} strokeWidth={2.5} />
<Text className="text-primary text-[8px] font-bold uppercase tracking-widest">
Add Item
</Text>
</Pressable>
</View>
<View className="gap-3 mb-5">
{items.map((item, index) => (
<View className="bg-card rounded-[6px] p-4">
<View className="flex-row justify-between items-center mb-3">
<Text
variant="muted"
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
>
Item {index + 1}
</Text>
{items.length > 1 && (
<Pressable onPress={() => removeItem(item.id)} hitSlop={8}>
<Trash2 color="#ef4444" size={13} />
</Pressable>
)}
</View>
<Field
label="Description"
placeholder="e.g. UI Design"
value={item.description}
onChangeText={(v) => updateField(item.id, "description", v)}
/>
<View className="flex-row gap-3 mt-4">
<Field
label="Qty"
placeholder="1"
numeric
center
value={item.qty}
onChangeText={(v) => updateField(item.id, "qty", v)}
flex={1}
/>
<Field
label="Price"
placeholder="0.00"
numeric
value={item.price}
onChangeText={(v) => updateField(item.id, "price", v)}
flex={2}
/>
<View className="flex-1 items-end justify-end pb-1">
<Text
variant="muted"
className="text-[9px] uppercase font-bold opacity-40"
>
Total
</Text>
<Text
variant="p"
className="text-foreground font-bold text-sm"
>
{currency}
{(
(parseFloat(item.qty) || 0) *
(parseFloat(item.price) || 0)
).toFixed(2)}
</Text>
</View>
</View>
</View>
))}
</View>
{/* Totals & Taxes */}
<Label>Totals & Taxes</Label>
<View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
<View className="flex-row justify-between items-center">
<Text variant="muted" className="text-xs font-medium">
Subtotal
</Text>
<Text variant="p" className="text-foreground font-bold">
{currency} {subtotal.toLocaleString()}
</Text>
</View>
<View className="flex-row gap-4">
<Field
label="Tax"
value={taxAmount}
onChangeText={setTaxAmount}
placeholder="0"
numeric
flex={1}
/>
<Field
label="Discount"
value={discountAmount}
onChangeText={setDiscountAmount}
placeholder="0"
numeric
flex={1}
/>
</View>
</View>
{/* Notes */}
<Label>Notes</Label>
<View className="bg-card rounded-[6px] p-4 mb-6">
<TextInput
style={[
S.input,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
height: 80,
textAlignVertical: "top",
paddingTop: 10,
},
]}
placeholder="e.g. Payment due within 30 days"
placeholderTextColor={getPlaceholderColor(isDark)}
value={notes}
onChangeText={setNotes}
multiline
/>
</View>
{/* Footer */}
<View className="border border-border/60 rounded-[12px] p-5">
<View className="flex-row justify-between items-center mb-5">
<Text
variant="muted"
className="font-bold text-xs uppercase tracking-widest opacity-60"
>
Total Amount
</Text>
<Text variant="h3" className="text-primary font-black">
{currency}{" "}
{total.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</Text>
</View>
<View className="flex-row gap-3">
<Button
variant="ghost"
className="flex-1 h-10 rounded-[6px] border border-border"
onPress={() => nav.back()}
disabled={submitting}
>
<Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
Discard
</Text>
</Button>
<Button
className="flex-1 h-10 rounded-[6px] bg-primary"
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color="white" size="small" />
) : (
<>
<Send color="white" size={14} strokeWidth={2.5} />
<Text className="text-white font-bold text-sm ">
Create Invoice
</Text>
</>
)}
</Button>
</View>
</View>
</ScrollView>
{/* Currency Modal */}
<PickerModal
visible={showCurrency}
onClose={() => setShowCurrency(false)}
title="Select Currency"
>
{currencies.map((curr) => (
<SelectOption
key={curr}
label={curr}
value={curr}
selected={currency === curr}
onSelect={(v) => {
setCurrency(v);
setShowCurrency(false);
}}
/>
))}
</PickerModal>
{/* Type Modal */}
<PickerModal
visible={showType}
onClose={() => setShowType(false)}
title="Select Invoice Type"
>
{invoiceTypes.map((t) => (
<SelectOption
key={t}
label={t}
value={t}
selected={type === t}
onSelect={(v) => {
setType(v);
setShowType(false);
}}
/>
))}
</PickerModal>
{/* Status Modal */}
<PickerModal
visible={showStatus}
onClose={() => setShowStatus(false)}
title="Select Invoice Status"
>
{invoiceStatuses.map((s) => (
<SelectOption
key={s}
label={s}
value={s}
selected={status === s}
onSelect={(v) => {
setStatus(v);
setShowStatus(false);
}}
/>
))}
</PickerModal>
{/* Issue Date Modal */}
<PickerModal
visible={showIssueDate}
onClose={() => setShowIssueDate(false)}
title="Select Issue Date"
>
<CalendarGrid
selectedDate={issueDate}
onSelect={(v) => {
setIssueDate(v);
setShowIssueDate(false);
}}
/>
</PickerModal>
{/* Due Date Modal */}
<PickerModal
visible={showDueDate}
onClose={() => setShowDueDate(false)}
title="Select Due Date"
>
<CalendarGrid
selectedDate={dueDate}
onSelect={(v) => {
setDueDate(v);
setShowDueDate(false);
}}
/>
</PickerModal>
</ScreenWrapper>
);
}
function Label({
children,
noMargin,
}: {
children: string;
noMargin?: boolean;
}) {
return (
<Text
variant="small"
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3 ml-1"}`}
>
{children}
</Text>
);
}

View File

@ -48,9 +48,7 @@ try {
GoogleSignin.configure({
webClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
iosClientId:
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
"377689842258-42ofqmfhf61p03jamlr5nfi18ovqehvf.apps.googleusercontent.com",
offlineAccess: true,
});
} catch (e) {

222
app/news/[id].tsx Normal file
View File

@ -0,0 +1,222 @@
import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
ActivityIndicator,
useColorScheme,
Share,
Platform,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import {
Clock,
Share2,
Newspaper,
Calendar,
Tag,
AlertCircle,
Eye,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store";
export default function NewsDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(true);
const [news, setNews] = useState<any>(null);
const newsId = Array.isArray(id) ? id[0] : id;
useEffect(() => {
if (newsId) {
fetchNewsDetail();
}
}, [newsId]);
const fetchNewsDetail = async () => {
try {
setLoading(true);
const data = await api.news.getById({ params: { id: newsId } });
setNews(data);
} catch (error: any) {
console.error("[NewsDetail] Fetch error:", error);
toast.error("Error", "Failed to load news content.");
} finally {
setLoading(false);
}
};
const handleShare = async () => {
if (!news) return;
try {
await Share.share({
title: news.title,
message: `${news.title}\n\n${news.content.substring(0, 100)}...\n\nRead more on Yaltopia Tickets.`,
});
} catch (error) {
console.error("[NewsDetail] Share error:", error);
}
};
const getCategoryStyles = (category: string) => {
switch (category) {
case "ANNOUNCEMENT":
return {
bg: "bg-amber-500/10",
text: "text-amber-500",
dot: "bg-amber-500",
};
case "UPDATE":
return {
bg: "bg-blue-500/10",
text: "text-blue-500",
dot: "bg-blue-500",
};
case "MAINTENANCE":
return { bg: "bg-red-500/10", text: "text-red-500", dot: "bg-red-500" };
default:
return {
bg: "bg-emerald-500/10",
text: "text-emerald-500",
dot: "bg-emerald-500",
};
}
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="News" showBack />
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
</ScreenWrapper>
);
}
if (!news) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="News" showBack />
<View className="flex-1 justify-center items-center px-10">
<AlertCircle size={48} color="#ef4444" className="mb-4" />
<Text variant="h4" className="text-center mb-2">
Content Not Found
</Text>
<Text variant="muted" className="text-center mb-6">
This news item might have been removed or is no longer available.
</Text>
<Button className="w-full rounded-[6px]" onPress={() => nav.back()}>
<Text className="font-bold uppercase tracking-widest text-xs">
Go Back
</Text>
</Button>
</View>
</ScreenWrapper>
);
}
const styles = getCategoryStyles(news.category);
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Article" showBack />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 60 }}
>
<View className="px-5 pt-6">
{/* Metadata Row */}
<View className="flex-row items-center justify-between mb-4">
<View
className={`px-3 py-1 rounded-full flex-row items-center gap-2 ${styles.bg}`}
>
<View className={`w-2 h-2 rounded-full ${styles.dot}`} />
<Text
className={`text-[10px] font-black uppercase tracking-widest ${styles.text}`}
>
{news.category}
</Text>
</View>
<View className="flex-row items-center gap-2">
<Eye size={12} color="#94a3b8" />
<Text
variant="muted"
className="text-[10px] font-bold uppercase tracking-widest"
>
{news.viewCount || 0} Views
</Text>
</View>
</View>
{/* Title */}
<Text className="text-foreground font-black text-3xl leading-[36px] mb-6 tracking-tighter">
{news.title}
</Text>
{/* Author/Date Info */}
<View className="flex-row items-center gap-4 mb-1 border-y border-border/40 py-4">
<View className="flex-row items-center gap-2">
<Calendar size={14} color="#ea580c" strokeWidth={2.5} />
<Text className="text-foreground font-bold text-xs">
{new Date(news.publishedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</Text>
</View>
<View className="w-1 h-1 rounded-full bg-border" />
<View className="flex-row items-center gap-2">
<Clock size={14} color="#ea580c" strokeWidth={2.5} />
<Text className="text-foreground font-bold text-xs">
{new Date(news.publishedAt).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</View>
</View>
{/* Content Body */}
<View className="rounded-[6px] mb-8">
<Text
variant="p"
className="text-foreground text-base leading-7 font-medium"
>
{news.content}
</Text>
</View>
{/* Footer Actions */}
<View className="flex-row gap-3">
<Button
variant="default"
className="flex-1 h-12 "
onPress={handleShare}
>
<Share2 size={18} color={isDark ? "#f1f5f9" : "#0f172a"} />
<Text className="ml-2 text-foreground font-bold uppercase tracking-widest text-xs">
Share Article
</Text>
</Button>
</View>
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -144,7 +144,7 @@ export default function OtpScreen() {
onKeyPress={(e) => handleKeyDown(e, i)}
keyboardType="number-pad"
maxLength={1}
className="w-10 h-10 border top-[2px] border-border rounded-[6px] text-center text-xl font-bold bg-card text-foreground"
className="w-10 h-10 border top-[2px] border-border rounded-[6px] text-center text-lg flex items-center justify-center font-bold bg-card text-foreground"
placeholderTextColor={isDark ? "#475569" : "#cbd5e1"}
/>
))}

View File

@ -33,6 +33,7 @@ import { StandardHeader } from "@/components/StandardHeader";
import { api, BASE_URL } from "@/lib/api";
import { useColorScheme } from "nativewind";
import { toast } from "@/lib/toast-store";
import { ActionModal } from "@/components/ActionModal";
export default function PaymentDetailScreen() {
const nav = useSirouRouter<AppRoutes>();
@ -44,6 +45,7 @@ export default function PaymentDetailScreen() {
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const [matching, setMatching] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const paymentId = Array.isArray(id) ? id[0] : id;
@ -70,30 +72,23 @@ export default function PaymentDetailScreen() {
}, [paymentId]);
const handleDelete = async () => {
Alert.alert(
"Delete Payment",
"Are you sure you want to delete this payment record?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
setDeleting(true);
try {
if (!paymentId) return;
await api.payments.delete({ params: { id: paymentId } });
toast.success("Deleted", "Payment record has been removed.");
nav.back();
} catch (err: any) {
toast.error("Error", err.message || "Failed to delete payment.");
} finally {
setDeleting(false);
}
},
},
],
);
setShowDeleteModal(true);
};
const confirmDelete = async () => {
setDeleting(true);
try {
if (!paymentId) return;
await api.payments.delete({ params: { id: paymentId } });
toast.success("Deleted", "Payment record has been removed.");
setShowDeleteModal(false);
nav.back();
} catch (err: any) {
toast.error("Error", err.message || "Failed to delete payment.");
setShowDeleteModal(false);
} finally {
setDeleting(false);
}
};
const handleMatch = async () => {
@ -243,7 +238,7 @@ export default function PaymentDetailScreen() {
>
{/* Urgent Alerts */}
{isFlagged && (
<View className="mx-5 my-4 bg-red-500/10 border border-red-500/20 rounded-[24px] p-5 flex-row items-start">
<View className="mx-5 my-3 bg-red-500/10 border border-red-500/20 rounded-[6px] p-5 flex-row items-start">
<View className="bg-red-500/20 p-2 rounded-full mr-4">
<AlertTriangle color="#ef4444" size={20} />
</View>
@ -260,43 +255,9 @@ export default function PaymentDetailScreen() {
{/* Hero Section */}
<View className="px-5 pt-2">
{/* Status Badges */}
<View className="flex-row flex-wrap gap-2 mb-6">
<View
className={`px-3 py-1 rounded-full flex-row items-center gap-2 ${payment.invoiceId ? "bg-emerald-500/10" : "bg-amber-500/10"}`}
>
<View
className={`w-2 h-2 rounded-full ${payment.invoiceId ? "bg-emerald-500" : "bg-amber-500"}`}
/>
<Text
className={`text-[10px] font-black uppercase tracking-widest ${payment.invoiceId ? "text-emerald-600" : "text-amber-600"}`}
>
{payment.invoiceId ? "Matched" : "Pending Match"}
</Text>
</View>
{isFailed && (
<View className="bg-red-500/10 px-3 py-1 rounded-full flex-row items-center gap-2">
<AlertCircle size={12} color="#ef4444" />
<Text className="text-red-600 text-[10px] font-black uppercase tracking-widest">
Verify Failed
</Text>
</View>
)}
{isScanned && (
<View className="bg-primary/10 px-3 py-1 rounded-full flex-row items-center gap-2 border border-primary/20">
<CheckCircle2 size={12} color="#ea580c" />
<Text className="text-primary text-[10px] font-black uppercase tracking-widest">
Scanned
</Text>
</View>
)}
</View>
<Text
variant="muted"
className="text-[10px] font-black uppercase tracking-[3px] mb-2 opacity-60"
variant="p"
className="text-[10px] font-black uppercase tracking-[3px] mb-2 "
>
Total Transaction Amount
</Text>
@ -318,7 +279,7 @@ export default function PaymentDetailScreen() {
<Building2 size={18} color="#ea580c" className="mb-3" />
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
className="text-[9px] uppercase font-black tracking-widest mb-1 "
>
Merchant
</Text>
@ -335,7 +296,7 @@ export default function PaymentDetailScreen() {
<Network size={18} color="#ea580c" className="mb-3" />
<Text
variant="muted"
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
className="text-[9px] uppercase font-black tracking-widest mb-1 "
>
Provider
</Text>
@ -353,8 +314,8 @@ export default function PaymentDetailScreen() {
{/* Sender / Payer Box */}
<View className="px-5 mb-8">
<View className="bg-card/50 rounded-[6px] p-6 border border-border/40 shadow-sm shadow-black/5">
<View className="flex-row items-center gap-4 mb-5">
<View className="bg-card/50 rounded-[6px] p-2 border border-border/40 shadow-sm shadow-black/5">
<View className="flex-row items-center gap-4 mb-2">
<View className="h-12 w-12 rounded-full bg-secondary/10 items-center justify-center border border-secondary/20">
<User color={isDark ? "#f1f5f9" : "#0f172a"} size={22} />
</View>
@ -415,7 +376,7 @@ export default function PaymentDetailScreen() {
<View className="flex-row gap-3">
{scanned?.imageUrl && (
<Button
className="flex-1 h-14 rounded-[6px] bg-secondary shadow-lg shadow-black/10"
className="flex-1 h-10 rounded-[6px] bg-primary shadow-lg shadow-black/10"
onPress={() =>
Linking.openURL(
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
@ -434,7 +395,7 @@ export default function PaymentDetailScreen() {
)}
{!payment.invoiceId && !isFailed && (
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
className="flex-1 h-10 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={handleMatch}
disabled={matching}
>
@ -454,7 +415,7 @@ export default function PaymentDetailScreen() {
<Button
variant="ghost"
className="h-14 rounded-[6px] border border-red-500/5 mb-10"
className="h-10 rounded-[6px] border border-red-500/60 mb-10"
onPress={handleDelete}
disabled={deleting}
>
@ -464,13 +425,26 @@ export default function PaymentDetailScreen() {
<>
<Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-red-500 font-bold uppercase tracking-widest text-xs">
Terminate Record
Delete Request
</Text>
</>
)}
</Button>
</View>
</ScrollView>
<ActionModal
visible={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={confirmDelete}
title="Delete Request"
description="Are you sure you want to permanently delete this payment request? This action cannot be reversed."
confirmText="Delete"
confirmVariant="destructive"
icon={Trash2}
iconColor="#ef4444"
loading={deleting}
/>
</ScreenWrapper>
);
}

View File

@ -196,17 +196,6 @@ export default function ProformaDetailScreen() {
>
{/* Modern Hero Area */}
<View className="px-5 pt-4">
<View
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}
>
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
<Text
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
>
{status}
</Text>
</View>
<Text
variant="muted"
className="text-xs font-bold uppercase tracking-wider mb-1"
@ -335,7 +324,7 @@ export default function ProformaDetailScreen() {
{/* Billing Breakdown */}
<View className="px-5 mb-6">
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60">
<View className="flex-row justify-between mb-4">
<View className="flex-row justify-between mb-1">
<Text className="text-muted-foreground font-medium">
Net Price
</Text>
@ -345,7 +334,7 @@ export default function ProformaDetailScreen() {
</View>
{taxAmountValue > 0 && (
<View className="flex-row justify-between mb-4">
<View className="flex-row justify-between mb-1">
<Text className="text-muted-foreground font-medium">
Estimated Tax
</Text>
@ -356,7 +345,7 @@ export default function ProformaDetailScreen() {
)}
{discountValue > 0 && (
<View className="flex-row justify-between mb-4">
<View className="flex-row justify-between mb-1">
<Text className="text-muted-foreground font-medium">
Applicable Discount
</Text>
@ -366,19 +355,13 @@ export default function ProformaDetailScreen() {
</View>
)}
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
<View className="pt-1 border-t border-dashed border-border flex-row justify-between items-center">
<View>
<Text className="text-foreground font-black text-xl">
<Text className="text-foreground font-black text-lg">
Estimated Total
</Text>
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter"
>
Valid as of today
</Text>
</View>
<Text className="text-primary font-black text-2xl">
<Text className="text-primary font-black text-lg">
{amountValue.toLocaleString()} {proforma.currency}
</Text>
</View>
@ -404,7 +387,7 @@ export default function ProformaDetailScreen() {
<View className="px-5 gap-3">
<View className="flex-row gap-3">
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
className="flex-1 h-10 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={() => nav.go("proforma/edit", { id: proforma.id })}
>
<Share2 color="#ffffff" size={18} strokeWidth={2.5} />
@ -414,7 +397,7 @@ export default function ProformaDetailScreen() {
</Button>
<Button
variant="outline"
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
className="flex-1 h-10 rounded-[6px] bg-card border-border/60"
onPress={handleGetPdf}
>
<Download
@ -430,7 +413,7 @@ export default function ProformaDetailScreen() {
<Button
variant="ghost"
className="h-14 rounded-[6px] border border-rose-500/10"
className="h-10 rounded-[6px] border border-red-500/60"
onPress={handleDelete}
>
<Trash2 color="#ef4444" size={18} />

106
components/ActionModal.tsx Normal file
View File

@ -0,0 +1,106 @@
import React from "react";
import { View, Modal, Pressable, StyleSheet } from "react-native";
import { Text } from "./ui/text";
import { Button } from "./ui/button";
import { LucideIcon } from "lucide-react-native";
import { X } from "@/lib/icons";
import { useColorScheme } from "nativewind";
interface ActionModalProps {
visible: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
icon?: LucideIcon;
iconColor?: string;
loading?: boolean;
}
export function ActionModal({
visible,
onClose,
onConfirm,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
confirmVariant = "default",
icon: Icon,
iconColor = "#ea580c",
loading = false,
}: ActionModalProps) {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
return (
<Modal
transparent
visible={visible}
animationType="fade"
onRequestClose={onClose}
>
<Pressable
style={StyleSheet.absoluteFill}
className="bg-black/60 items-center justify-center p-6"
onPress={onClose}
>
<Pressable
className="w-full max-w-sm bg-card rounded-[6px] border border-border overflow-hidden"
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="flex-row items-center justify-between px-5 pt-5 pb-2">
<View className="flex-row items-center gap-3">
{Icon && (
<View className="p-2 rounded-full bg-primary/10">
<Icon size={20} color={iconColor} />
</View>
)}
<Text variant="h4" className="font-black uppercase tracking-tight">
{title}
</Text>
</View>
<Button variant="ghost" size="icon" className="h-8 w-8" onPress={onClose}>
<X size={18} color={isDark ? "#94a3b8" : "#64748b"} />
</Button>
</View>
{/* Body */}
<View className="px-5 pb-6">
<Text variant="p" className="text-muted-foreground font-medium leading-5">
{description}
</Text>
</View>
{/* Footer */}
<View className="flex-row border-t border-border p-3 gap-3">
<Button
variant="outline"
className="flex-1 h-12 rounded-[6px]"
onPress={onClose}
disabled={loading}
>
<Text className="font-bold uppercase tracking-widest text-xs">
{cancelText}
</Text>
</Button>
<Button
variant={confirmVariant}
className="flex-1 h-12 rounded-[6px]"
onPress={onConfirm}
loading={loading}
>
<Text className="font-bold uppercase tracking-widest text-xs">
{confirmText}
</Text>
</Button>
</View>
</Pressable>
</Pressable>
</Modal>
);
}

View File

@ -47,6 +47,7 @@ export function ShadowWrapper({
}
: {};
// Standard React Native view to avoid any potential interop issues in deep trees during re-renders
return (
<View
className={cn(shadowClasses[level], className)}

View File

@ -32,6 +32,7 @@ export const api = createApi({
endpoints: {
getAll: { method: "GET", path: "news" },
getLatest: { method: "GET", path: "news/latest" },
getById: { method: "GET", path: "news/:id" },
},
},
auth: {

View File

@ -98,12 +98,23 @@ export const routes = defineRoutes({
guards: ["auth"],
meta: { requiresAuth: true },
},
"invoices/create": {
path: "/invoices/create",
guards: ["auth"],
meta: { requiresAuth: true },
},
"invoices/edit": {
path: "/invoices/edit",
params: { id: "string" },
guards: ["auth"],
meta: { requiresAuth: true },
},
"news/[id]": {
path: "/news/:id",
params: { id: "string" },
guards: ["auth"],
meta: { requiresAuth: true },
},
"notifications/index": {
path: "/notifications/index",
guards: ["auth"],

22
package-lock.json generated
View File

@ -26,6 +26,7 @@
"expo": "~52.0.35",
"expo-camera": "~16.0.18",
"expo-constants": "~17.0.7",
"expo-image-picker": "~16.0.3",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-router": "~4.0.17",
@ -7625,6 +7626,27 @@
"react": "*"
}
},
"node_modules/expo-image-loader": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz",
"integrity": "sha512-Eg+5FHtyzv3Jjw9dHwu2pWy4xjf8fu3V0Asyy42kO+t/FbvW/vjUixpTjPtgKQLQh+2/9Nk4JjFDV6FwCnF2ZA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "16.0.6",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-16.0.6.tgz",
"integrity": "sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~5.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-keep-awake": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.3.tgz",

View File

@ -27,6 +27,7 @@
"expo": "~52.0.35",
"expo-camera": "~16.0.18",
"expo-constants": "~17.0.7",
"expo-image-picker": "~16.0.3",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-router": "~4.0.17",
@ -50,12 +51,12 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@react-native-community/cli": "latest",
"@types/react": "~18.3.12",
"patch-package": "^8.0.1",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.17",
"typescript": "^5.3.3",
"@react-native-community/cli": "latest"
"typescript": "^5.3.3"
},
"private": true
}