google oauth
This commit is contained in:
parent
1b5e82c895
commit
e5dab56c00
|
|
@ -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.
|
- **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
|
- **use correct icons**: use the icons that are relevant
|
||||||
- **dont use gradients**: do not use gradients as bg or anything bro
|
- **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
|
- **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 -->
|
<!-- END:react-native-agent-rules -->
|
||||||
|
|
|
||||||
159
UAT_Test_Cases.md
Normal file
159
UAT_Test_Cases.md
Normal 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 |
|
||||||
|
|
@ -181,8 +181,8 @@ export default function HomeScreen() {
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Create Proforma"
|
label="Add Invoice"
|
||||||
onPress={() => nav.go("proforma/create")}
|
onPress={() => nav.go("invoices/create")}
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={
|
icon={
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,11 @@ export default function NewsScreen() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const LatestItem = ({ item }: { item: NewsItem }) => (
|
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
|
<Card
|
||||||
className="overflow-hidden rounded-[20px] bg-card border-border/50"
|
className="overflow-hidden rounded-[20px] bg-card border-border/50"
|
||||||
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
|
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
|
||||||
|
|
@ -178,7 +182,11 @@ export default function NewsScreen() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const NewsItem = ({ item }: { item: NewsItem }) => (
|
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">
|
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<View className="flex-row items-center gap-2 mb-1.5">
|
<View className="flex-row items-center gap-2 mb-1.5">
|
||||||
|
|
|
||||||
154
app/_layout.tsx
154
app/_layout.tsx
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { PortalHost } from "@rn-primitives/portal";
|
import { PortalHost } from "@rn-primitives/portal";
|
||||||
|
|
@ -7,35 +7,28 @@ import { Toast } from "@/components/Toast";
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import {
|
import {
|
||||||
View,
|
View as RNView,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
InteractionManager,
|
|
||||||
AppState,
|
AppState,
|
||||||
} from "react-native";
|
} 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 { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
|
||||||
import { refreshTokens } from "@/lib/api-middlewares";
|
import { refreshTokens } from "@/lib/api-middlewares";
|
||||||
import {
|
import { ThemeProvider, NavigationIndependentTree } from "@react-navigation/native";
|
||||||
NavigationContainer,
|
|
||||||
NavigationIndependentTree,
|
|
||||||
ThemeProvider,
|
|
||||||
} from "@react-navigation/native";
|
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { useFonts } from "expo-font";
|
import { useFonts } from "expo-font";
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useSegments, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSegments, useLocalSearchParams, router } from "expo-router";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GlobalGuard: Handles all routing security and authentication redirects.
|
* GlobalGuard: Handles all routing security and authentication redirects.
|
||||||
* Reacts instantly to auth state changes to prevent unauthenticated users from seeing protected data.
|
|
||||||
*/
|
*/
|
||||||
function GlobalGuard() {
|
function GlobalGuard() {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const sirou = useSirouRouter();
|
const sirou = useSirouRouter();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
@ -45,7 +38,7 @@ function GlobalGuard() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMounted) return;
|
if (!isMounted || !segments) return;
|
||||||
|
|
||||||
const performGuardCheck = async () => {
|
const performGuardCheck = async () => {
|
||||||
const routeName = segments.length > 0 ? segments.join("/") : "root";
|
const routeName = segments.length > 0 ? segments.join("/") : "root";
|
||||||
|
|
@ -54,21 +47,18 @@ function GlobalGuard() {
|
||||||
segments[0] === "register" ||
|
segments[0] === "register" ||
|
||||||
segments[0] === "otp";
|
segments[0] === "otp";
|
||||||
|
|
||||||
// 1. FAST AUTH CHECK: If not authenticated and not on a public page, eject immediately.
|
|
||||||
if (!isAuthenticated && !isAuthPage) {
|
if (!isAuthenticated && !isAuthPage) {
|
||||||
console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`);
|
console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`);
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. GUEST CHECK: If authenticated and on an auth page, redirect to home.
|
|
||||||
if (isAuthenticated && isAuthPage) {
|
if (isAuthenticated && isAuthPage) {
|
||||||
console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`);
|
console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`);
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. COMPLEX GUARDS: Permissions, roles, etc. handled by Sirou.
|
|
||||||
try {
|
try {
|
||||||
const result = await (sirou as any).checkGuards(routeName, params);
|
const result = await (sirou as any).checkGuards(routeName, params);
|
||||||
if (!result.allowed && result.redirect) {
|
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() {
|
function SessionHeartbeat() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
@ -95,9 +85,7 @@ function SessionHeartbeat() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
// Refresh every 5 minutes
|
|
||||||
const INTERVAL_MS = 5 * 60 * 1000;
|
const INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const performRefresh = async (reason: string) => {
|
const performRefresh = async (reason: string) => {
|
||||||
try {
|
try {
|
||||||
console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`);
|
console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`);
|
||||||
|
|
@ -107,11 +95,9 @@ function SessionHeartbeat() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Initial/Interval Refresh
|
performRefresh("Mount");
|
||||||
performRefresh("Mount"); // Refresh immediately on mount
|
|
||||||
const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS);
|
const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS);
|
||||||
|
|
||||||
// 2. Foreground Refresh (AppState listener)
|
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
if (nextAppState === "active") {
|
if (nextAppState === "active") {
|
||||||
performRefresh("Foreground");
|
performRefresh("Foreground");
|
||||||
|
|
@ -128,10 +114,11 @@ function SessionHeartbeat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme, setColorScheme } = useColorScheme();
|
||||||
useRestoreTheme();
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
const [isThemeRestored, setIsThemeRestored] = useState(false);
|
||||||
|
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"),
|
"DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"),
|
||||||
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"),
|
"DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"),
|
||||||
|
|
@ -147,43 +134,62 @@ export default function RootLayout() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
|
|
||||||
|
// Auth Hydration
|
||||||
const initializeAuth = async () => {
|
const initializeAuth = async () => {
|
||||||
if (useAuthStore.persist.hasHydrated()) {
|
if (useAuthStore.persist.hasHydrated()) {
|
||||||
setHasHydrated(true);
|
setHasHydrated(true);
|
||||||
} else {
|
} else {
|
||||||
const unsub = useAuthStore.persist.onFinishHydration(() => {
|
useAuthStore.persist.onFinishHydration(() => {
|
||||||
setHasHydrated(true);
|
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();
|
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 (
|
return (
|
||||||
<View
|
<RNView
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "rgba(255, 255, 255, 1)",
|
backgroundColor: "#ffffff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActivityIndicator size="large" color="#ea580c" />
|
<ActivityIndicator size="large" color="#ea580c" />
|
||||||
</View>
|
</RNView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<NavigationIndependentTree>
|
||||||
|
<ThemeProvider value={theme}>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||||
<ThemeProvider
|
<RNView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
|
|
||||||
>
|
|
||||||
<View className="flex-1 bg-background">
|
|
||||||
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||||
<GlobalGuard />
|
<GlobalGuard />
|
||||||
<Stack
|
<Stack
|
||||||
|
|
@ -192,78 +198,40 @@ export default function RootLayout() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="sms-scan" options={{ headerShown: false }} />
|
||||||
name="sms-scan"
|
<Stack.Screen name="proforma/[id]" options={{ title: "Proforma request" }} />
|
||||||
options={{ headerShown: false }}
|
<Stack.Screen name="proforma/edit" options={{ title: "Edit Proforma" }} />
|
||||||
/>
|
<Stack.Screen name="invoices/[id]" options={{ title: "Invoice" }} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="invoices/create" options={{ title: "Add Invoice", headerShown: false }} />
|
||||||
name="proforma/[id]"
|
<Stack.Screen name="invoices/edit" options={{ title: "Edit Invoice" }} />
|
||||||
options={{ title: "Proforma request" }}
|
<Stack.Screen name="payments/[id]" options={{ title: "Payment" }} />
|
||||||
/>
|
<Stack.Screen name="notifications/index" options={{ title: "Notifications" }} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="notifications/settings" options={{ title: "Notification settings" }} />
|
||||||
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="help" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="faq" options={{ headerShown: false }} />
|
<Stack.Screen name="faq" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="history" options={{ headerShown: false }} />
|
<Stack.Screen name="history" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="company" options={{ headerShown: false }} />
|
<Stack.Screen name="company" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="company-details" options={{ headerShown: false }} />
|
||||||
name="company-details"
|
<Stack.Screen name="login" options={{ title: "Sign in", headerShown: false }} />
|
||||||
options={{ headerShown: false }}
|
<Stack.Screen name="otp" options={{ title: "Verify OTP", headerShown: false }} />
|
||||||
/>
|
<Stack.Screen name="register" options={{ title: "Create account", headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="reports/index" options={{ title: "Reports" }} />
|
||||||
name="login"
|
<Stack.Screen name="documents/index" options={{ title: "Documents" }} />
|
||||||
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="settings" options={{ title: "Settings" }} />
|
||||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="edit-profile" options={{ headerShown: false }} />
|
||||||
name="edit-profile"
|
<Stack.Screen name="user/create" options={{ headerShown: false }} />
|
||||||
options={{ headerShown: false }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<SessionHeartbeat />
|
<SessionHeartbeat />
|
||||||
<PortalHost />
|
<PortalHost />
|
||||||
<Toast />
|
<Toast />
|
||||||
</View>
|
</RNView>
|
||||||
</ThemeProvider>
|
|
||||||
</SirouRouterProvider>
|
</SirouRouterProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NavigationIndependentTree>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { api, BASE_URL } from "@/lib/api";
|
import { api, BASE_URL } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { ActionModal } from "@/components/ActionModal";
|
||||||
|
|
||||||
// Android only SMS module
|
// Android only SMS module
|
||||||
let SmsAndroid: any = null;
|
let SmsAndroid: any = null;
|
||||||
|
|
@ -59,6 +60,7 @@ export default function InvoiceDetailScreen() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [invoice, setInvoice] = useState<any>(null);
|
const [invoice, setInvoice] = useState<any>(null);
|
||||||
const [scanningSms, setScanningSms] = useState(false);
|
const [scanningSms, setScanningSms] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchInvoice();
|
fetchInvoice();
|
||||||
|
|
@ -82,28 +84,40 @@ export default function InvoiceDetailScreen() {
|
||||||
|
|
||||||
const handleScanSms = async () => {
|
const handleScanSms = async () => {
|
||||||
if (Platform.OS !== "android") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setScanningSms(true);
|
setScanningSms(true);
|
||||||
try {
|
try {
|
||||||
const granted = await PermissionsAndroid.request(
|
const granted = await PermissionsAndroid.request(
|
||||||
PermissionsAndroid.PERMISSIONS.READ_SMS
|
PermissionsAndroid.PERMISSIONS.READ_SMS,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
|
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);
|
setScanningSms(false);
|
||||||
return;
|
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)
|
// Simulate logic if native module is missing (Expo Go)
|
||||||
if (!SmsAndroid) {
|
if (!SmsAndroid) {
|
||||||
setTimeout(() => {
|
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);
|
setScanningSms(false);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
|
|
@ -130,7 +144,9 @@ export default function InvoiceDetailScreen() {
|
||||||
// Search for amount or customer name in SMS body
|
// Search for amount or customer name in SMS body
|
||||||
const match = messages.find((m: any) => {
|
const match = messages.find((m: any) => {
|
||||||
const body = m.body.toUpperCase();
|
const body = m.body.toUpperCase();
|
||||||
return body.includes(amountStr) || (custName && body.includes(custName));
|
return (
|
||||||
|
body.includes(amountStr) || (custName && body.includes(custName))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|
@ -141,15 +157,22 @@ export default function InvoiceDetailScreen() {
|
||||||
{ text: "No", style: "cancel" },
|
{ text: "No", style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Attach SMS",
|
text: "Attach SMS",
|
||||||
onPress: () => toast.success("Attached", "SMS proof linked to invoice successfully.")
|
onPress: () =>
|
||||||
}
|
toast.success(
|
||||||
]
|
"Attached",
|
||||||
|
"SMS proof linked to invoice successfully.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
);
|
);
|
||||||
} else {
|
} 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);
|
setScanningSms(false);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Error", "Something went wrong during SMS scan.");
|
toast.error("Error", "Something went wrong during SMS scan.");
|
||||||
|
|
@ -169,15 +192,10 @@ export default function InvoiceDetailScreen() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
Alert.alert(
|
setShowDeleteModal(true);
|
||||||
"Delete Invoice",
|
};
|
||||||
"Are you sure you want to delete this invoice? This action cannot be undone.",
|
|
||||||
[
|
const confirmDelete = async () => {
|
||||||
{ text: "Cancel", style: "cancel" },
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const invoiceId = Array.isArray(id) ? id[0] : id;
|
const invoiceId = Array.isArray(id) ? id[0] : id;
|
||||||
|
|
@ -185,16 +203,14 @@ export default function InvoiceDetailScreen() {
|
||||||
params: { id: invoiceId as string },
|
params: { id: invoiceId as string },
|
||||||
});
|
});
|
||||||
toast.success("Success", "Invoice deleted successfully");
|
toast.success("Success", "Invoice deleted successfully");
|
||||||
|
setShowDeleteModal(false);
|
||||||
nav.back();
|
nav.back();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[InvoiceDetail] Delete Error:", error);
|
console.error("[InvoiceDetail] Delete Error:", error);
|
||||||
toast.error("Error", "Failed to delete invoice");
|
toast.error("Error", "Failed to delete invoice");
|
||||||
|
setShowDeleteModal(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -229,7 +245,8 @@ export default function InvoiceDetailScreen() {
|
||||||
|
|
||||||
// Robust data extraction
|
// Robust data extraction
|
||||||
const originalData = invoice.scannedData?.originalData || {};
|
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(
|
const taxAmountValue = Number(
|
||||||
typeof invoice.taxAmount === "object"
|
typeof invoice.taxAmount === "object"
|
||||||
|
|
@ -249,23 +266,40 @@ export default function InvoiceDetailScreen() {
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
const itemsTotal = items.reduce(
|
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,
|
0,
|
||||||
);
|
);
|
||||||
if (itemsTotal > 0 && (amountValue === taxAmountValue || amountValue < itemsTotal)) {
|
if (
|
||||||
|
itemsTotal > 0 &&
|
||||||
|
(amountValue === taxAmountValue || amountValue < itemsTotal)
|
||||||
|
) {
|
||||||
amountValue = itemsTotal + taxAmountValue - discountValue;
|
amountValue = itemsTotal + taxAmountValue - discountValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtotalValue = amountValue - taxAmountValue + discountValue;
|
const subtotalValue = amountValue - taxAmountValue + discountValue;
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
PAID: { bg: "bg-emerald-500/10", text: "text-emerald-500", dot: "bg-emerald-500" },
|
PAID: {
|
||||||
PENDING: { bg: "bg-amber-500/10", text: "text-amber-500", dot: "bg-amber-500" },
|
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" },
|
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 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 (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
|
|
@ -283,19 +317,17 @@ export default function InvoiceDetailScreen() {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View className="px-5 pt-4">
|
<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`}>
|
<Text
|
||||||
<View className={`w-2 h-2 rounded-full ${colors.dot}`} />
|
variant="muted"
|
||||||
<Text className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}>
|
className="text-xs font-bold uppercase tracking-wider mb-1"
|
||||||
{status}
|
>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text variant="muted" className="text-xs font-bold uppercase tracking-wider mb-1">
|
|
||||||
Total Amount
|
Total Amount
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-end gap-2 mb-6">
|
<View className="flex-row items-end gap-2 mb-6">
|
||||||
<Text variant="h1" className="text-4xl font-black text-foreground">
|
<Text variant="h1" className="text-4xl font-black text-foreground">
|
||||||
{Number(amountValue).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
{Number(amountValue).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xl font-bold text-primary mb-2">
|
<Text className="text-xl font-bold text-primary mb-2">
|
||||||
{invoice.currency || "ETB"}
|
{invoice.currency || "ETB"}
|
||||||
|
|
@ -305,16 +337,24 @@ export default function InvoiceDetailScreen() {
|
||||||
<View className="flex-row gap-3 mb-6">
|
<View className="flex-row gap-3 mb-6">
|
||||||
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
||||||
<Calendar size={16} color="#ea580c" className="mb-2" />
|
<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
|
Date
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-foreground font-bold text-sm">
|
<Text className="text-foreground font-bold text-sm">
|
||||||
{new Date(invoice.issueDate || invoice.createdAt).toLocaleDateString()}
|
{new Date(
|
||||||
|
invoice.issueDate || invoice.createdAt,
|
||||||
|
).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
|
||||||
<Clock size={16} color="#ef4444" className="mb-2" />
|
<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
|
Due
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-foreground font-bold text-sm">
|
<Text className="text-foreground font-bold text-sm">
|
||||||
|
|
@ -325,17 +365,24 @@ export default function InvoiceDetailScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="px-5 mb-6">
|
<View className="px-5 mb-6">
|
||||||
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
|
<View className="bg-primary/5 rounded-[6px] p-3 border border-primary/10">
|
||||||
<View className="flex-row items-center gap-3 mb-4">
|
<View className="flex-row items-center gap-3 ">
|
||||||
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center">
|
<View className="h-10 w-10 rounded-full bg-primary/20 items-center justify-center">
|
||||||
<User color="#ea580c" size={20} />
|
<User color="#ea580c" size={20} />
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text variant="muted" className="text-[10px] uppercase font-bold">
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] uppercase font-bold"
|
||||||
|
>
|
||||||
Client
|
Client
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="p" className="text-foreground font-bold text-lg">
|
<Text
|
||||||
{invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"}
|
variant="p"
|
||||||
|
className="text-foreground font-regular text-base"
|
||||||
|
>
|
||||||
|
{invoice.customerName?.replace("Customer Name: ", "") ||
|
||||||
|
"Walking Client"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -346,32 +393,57 @@ export default function InvoiceDetailScreen() {
|
||||||
<Text variant="h4" className="font-bold mb-4 px-1">
|
<Text variant="h4" className="font-bold mb-4 px-1">
|
||||||
Items
|
Items
|
||||||
</Text>
|
</Text>
|
||||||
|
{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">
|
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
|
||||||
{items.map((item: any, idx: number) => (
|
{items.map((item: any, idx: number) => (
|
||||||
<View key={idx} className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}>
|
<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">
|
<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-bold flex-1 mr-4">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
<Text className="text-foreground font-black">
|
<Text className="text-foreground font-black">
|
||||||
{Number(item.total?.value || item.total || 0).toLocaleString()}
|
{Number(
|
||||||
|
item.total?.value || item.total || 0,
|
||||||
|
).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-muted-foreground text-xs">
|
<Text className="text-muted-foreground text-xs">
|
||||||
{item.quantity} x {Number(item.unitPrice?.value || item.unitPrice || 0).toLocaleString()} {invoice.currency}
|
{item.quantity} x{" "}
|
||||||
|
{Number(
|
||||||
|
item.unitPrice?.value || item.unitPrice || 0,
|
||||||
|
).toLocaleString()}{" "}
|
||||||
|
{invoice.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="px-5 mb-6">
|
<View className="px-5 mb-6">
|
||||||
<Card className="bg-card rounded-[6px] p-5 border-border/60">
|
<Card className="bg-card rounded-[6px] p-3 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">Subtotal</Text>
|
<Text className="text-muted-foreground font-medium">
|
||||||
<Text className="text-foreground font-bold">{subtotalValue.toLocaleString()} {invoice.currency}</Text>
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground font-bold">
|
||||||
|
{subtotalValue.toLocaleString()} {invoice.currency}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
|
<View className="pt-2 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-foreground font-black text-lg">
|
||||||
<Text className="text-primary font-black text-2xl">{amountValue.toLocaleString()} {invoice.currency}</Text>
|
Grand Total
|
||||||
|
</Text>
|
||||||
|
<Text className="text-primary font-black text-lg">
|
||||||
|
{amountValue.toLocaleString()} {invoice.currency}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -379,7 +451,7 @@ export default function InvoiceDetailScreen() {
|
||||||
<View className="px-5 gap-3">
|
<View className="px-5 gap-3">
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button
|
<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}
|
disabled={scanningSms}
|
||||||
onPress={handleScanSms}
|
onPress={handleScanSms}
|
||||||
>
|
>
|
||||||
|
|
@ -394,17 +466,46 @@ export default function InvoiceDetailScreen() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="flex-1 h-14 rounded-[6px] bg-card border border-border" onPress={handleGetPdf}>
|
<Button
|
||||||
<Download color={isDark ? "#f1f5f9" : "#0f172a"} size={18} strokeWidth={2.5} />
|
variant="outline"
|
||||||
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">PDF</Text>
|
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>
|
</Button>
|
||||||
</View>
|
</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} />
|
<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>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</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>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
854
app/invoices/create.tsx
Normal file
854
app/invoices/create.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -48,9 +48,7 @@ try {
|
||||||
|
|
||||||
GoogleSignin.configure({
|
GoogleSignin.configure({
|
||||||
webClientId:
|
webClientId:
|
||||||
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
|
"377689842258-42ofqmfhf61p03jamlr5nfi18ovqehvf.apps.googleusercontent.com",
|
||||||
iosClientId:
|
|
||||||
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
|
|
||||||
offlineAccess: true,
|
offlineAccess: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
222
app/news/[id].tsx
Normal file
222
app/news/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -144,7 +144,7 @@ export default function OtpScreen() {
|
||||||
onKeyPress={(e) => handleKeyDown(e, i)}
|
onKeyPress={(e) => handleKeyDown(e, i)}
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
maxLength={1}
|
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"}
|
placeholderTextColor={isDark ? "#475569" : "#cbd5e1"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { api, BASE_URL } from "@/lib/api";
|
import { api, BASE_URL } from "@/lib/api";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { ActionModal } from "@/components/ActionModal";
|
||||||
|
|
||||||
export default function PaymentDetailScreen() {
|
export default function PaymentDetailScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
|
@ -44,6 +45,7 @@ export default function PaymentDetailScreen() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [matching, setMatching] = useState(false);
|
const [matching, setMatching] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
const paymentId = Array.isArray(id) ? id[0] : id;
|
const paymentId = Array.isArray(id) ? id[0] : id;
|
||||||
|
|
||||||
|
|
@ -70,30 +72,23 @@ export default function PaymentDetailScreen() {
|
||||||
}, [paymentId]);
|
}, [paymentId]);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
Alert.alert(
|
setShowDeleteModal(true);
|
||||||
"Delete Payment",
|
};
|
||||||
"Are you sure you want to delete this payment record?",
|
|
||||||
[
|
const confirmDelete = async () => {
|
||||||
{ text: "Cancel", style: "cancel" },
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
if (!paymentId) return;
|
if (!paymentId) return;
|
||||||
await api.payments.delete({ params: { id: paymentId } });
|
await api.payments.delete({ params: { id: paymentId } });
|
||||||
toast.success("Deleted", "Payment record has been removed.");
|
toast.success("Deleted", "Payment record has been removed.");
|
||||||
|
setShowDeleteModal(false);
|
||||||
nav.back();
|
nav.back();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error("Error", err.message || "Failed to delete payment.");
|
toast.error("Error", err.message || "Failed to delete payment.");
|
||||||
|
setShowDeleteModal(false);
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMatch = async () => {
|
const handleMatch = async () => {
|
||||||
|
|
@ -243,7 +238,7 @@ export default function PaymentDetailScreen() {
|
||||||
>
|
>
|
||||||
{/* Urgent Alerts */}
|
{/* Urgent Alerts */}
|
||||||
{isFlagged && (
|
{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">
|
<View className="bg-red-500/20 p-2 rounded-full mr-4">
|
||||||
<AlertTriangle color="#ef4444" size={20} />
|
<AlertTriangle color="#ef4444" size={20} />
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -260,43 +255,9 @@ export default function PaymentDetailScreen() {
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<View className="px-5 pt-2">
|
<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
|
<Text
|
||||||
className={`text-[10px] font-black uppercase tracking-widest ${payment.invoiceId ? "text-emerald-600" : "text-amber-600"}`}
|
variant="p"
|
||||||
>
|
className="text-[10px] font-black uppercase tracking-[3px] mb-2 "
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
Total Transaction Amount
|
Total Transaction Amount
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -318,7 +279,7 @@ export default function PaymentDetailScreen() {
|
||||||
<Building2 size={18} color="#ea580c" className="mb-3" />
|
<Building2 size={18} color="#ea580c" className="mb-3" />
|
||||||
<Text
|
<Text
|
||||||
variant="muted"
|
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
|
Merchant
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -335,7 +296,7 @@ export default function PaymentDetailScreen() {
|
||||||
<Network size={18} color="#ea580c" className="mb-3" />
|
<Network size={18} color="#ea580c" className="mb-3" />
|
||||||
<Text
|
<Text
|
||||||
variant="muted"
|
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
|
Provider
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -353,8 +314,8 @@ export default function PaymentDetailScreen() {
|
||||||
|
|
||||||
{/* Sender / Payer Box */}
|
{/* Sender / Payer Box */}
|
||||||
<View className="px-5 mb-8">
|
<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="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-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">
|
<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} />
|
<User color={isDark ? "#f1f5f9" : "#0f172a"} size={22} />
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -415,7 +376,7 @@ export default function PaymentDetailScreen() {
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
{scanned?.imageUrl && (
|
{scanned?.imageUrl && (
|
||||||
<Button
|
<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={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
|
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
|
||||||
|
|
@ -434,7 +395,7 @@ export default function PaymentDetailScreen() {
|
||||||
)}
|
)}
|
||||||
{!payment.invoiceId && !isFailed && (
|
{!payment.invoiceId && !isFailed && (
|
||||||
<Button
|
<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}
|
onPress={handleMatch}
|
||||||
disabled={matching}
|
disabled={matching}
|
||||||
>
|
>
|
||||||
|
|
@ -454,7 +415,7 @@ export default function PaymentDetailScreen() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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}
|
onPress={handleDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
|
|
@ -464,13 +425,26 @@ export default function PaymentDetailScreen() {
|
||||||
<>
|
<>
|
||||||
<Trash2 color="#ef4444" size={18} />
|
<Trash2 color="#ef4444" size={18} />
|
||||||
<Text className="ml-2 text-red-500 font-bold uppercase tracking-widest text-xs">
|
<Text className="ml-2 text-red-500 font-bold uppercase tracking-widest text-xs">
|
||||||
Terminate Record
|
Delete Request
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</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>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,17 +196,6 @@ export default function ProformaDetailScreen() {
|
||||||
>
|
>
|
||||||
{/* Modern Hero Area */}
|
{/* Modern Hero Area */}
|
||||||
<View className="px-5 pt-4">
|
<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
|
<Text
|
||||||
variant="muted"
|
variant="muted"
|
||||||
className="text-xs font-bold uppercase tracking-wider mb-1"
|
className="text-xs font-bold uppercase tracking-wider mb-1"
|
||||||
|
|
@ -335,7 +324,7 @@ export default function ProformaDetailScreen() {
|
||||||
{/* Billing Breakdown */}
|
{/* Billing Breakdown */}
|
||||||
<View className="px-5 mb-6">
|
<View className="px-5 mb-6">
|
||||||
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60">
|
<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">
|
<Text className="text-muted-foreground font-medium">
|
||||||
Net Price
|
Net Price
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -345,7 +334,7 @@ export default function ProformaDetailScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{taxAmountValue > 0 && (
|
{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">
|
<Text className="text-muted-foreground font-medium">
|
||||||
Estimated Tax
|
Estimated Tax
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -356,7 +345,7 @@ export default function ProformaDetailScreen() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{discountValue > 0 && (
|
{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">
|
<Text className="text-muted-foreground font-medium">
|
||||||
Applicable Discount
|
Applicable Discount
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -366,19 +355,13 @@ export default function ProformaDetailScreen() {
|
||||||
</View>
|
</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>
|
<View>
|
||||||
<Text className="text-foreground font-black text-xl">
|
<Text className="text-foreground font-black text-lg">
|
||||||
Estimated Total
|
Estimated Total
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
|
||||||
variant="muted"
|
|
||||||
className="text-[10px] uppercase font-bold tracking-tighter"
|
|
||||||
>
|
|
||||||
Valid as of today
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-primary font-black text-2xl">
|
<Text className="text-primary font-black text-lg">
|
||||||
{amountValue.toLocaleString()} {proforma.currency}
|
{amountValue.toLocaleString()} {proforma.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -404,7 +387,7 @@ export default function ProformaDetailScreen() {
|
||||||
<View className="px-5 gap-3">
|
<View className="px-5 gap-3">
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button
|
<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 })}
|
onPress={() => nav.go("proforma/edit", { id: proforma.id })}
|
||||||
>
|
>
|
||||||
<Share2 color="#ffffff" size={18} strokeWidth={2.5} />
|
<Share2 color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
|
|
@ -414,7 +397,7 @@ export default function ProformaDetailScreen() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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}
|
onPress={handleGetPdf}
|
||||||
>
|
>
|
||||||
<Download
|
<Download
|
||||||
|
|
@ -430,7 +413,7 @@ export default function ProformaDetailScreen() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-14 rounded-[6px] border border-rose-500/10"
|
className="h-10 rounded-[6px] border border-red-500/60"
|
||||||
onPress={handleDelete}
|
onPress={handleDelete}
|
||||||
>
|
>
|
||||||
<Trash2 color="#ef4444" size={18} />
|
<Trash2 color="#ef4444" size={18} />
|
||||||
|
|
|
||||||
106
components/ActionModal.tsx
Normal file
106
components/ActionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ export function ShadowWrapper({
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
// Standard React Native view to avoid any potential interop issues in deep trees during re-renders
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={cn(shadowClasses[level], className)}
|
className={cn(shadowClasses[level], className)}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const api = createApi({
|
||||||
endpoints: {
|
endpoints: {
|
||||||
getAll: { method: "GET", path: "news" },
|
getAll: { method: "GET", path: "news" },
|
||||||
getLatest: { method: "GET", path: "news/latest" },
|
getLatest: { method: "GET", path: "news/latest" },
|
||||||
|
getById: { method: "GET", path: "news/:id" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,23 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
"invoices/create": {
|
||||||
|
path: "/invoices/create",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
"invoices/edit": {
|
"invoices/edit": {
|
||||||
path: "/invoices/edit",
|
path: "/invoices/edit",
|
||||||
params: { id: "string" },
|
params: { id: "string" },
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
"news/[id]": {
|
||||||
|
path: "/news/:id",
|
||||||
|
params: { id: "string" },
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
"notifications/index": {
|
"notifications/index": {
|
||||||
path: "/notifications/index",
|
path: "/notifications/index",
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
|
|
|
||||||
22
package-lock.json
generated
22
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
||||||
"expo": "~52.0.35",
|
"expo": "~52.0.35",
|
||||||
"expo-camera": "~16.0.18",
|
"expo-camera": "~16.0.18",
|
||||||
"expo-constants": "~17.0.7",
|
"expo-constants": "~17.0.7",
|
||||||
|
"expo-image-picker": "~16.0.3",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-linking": "~7.0.5",
|
"expo-linking": "~7.0.5",
|
||||||
"expo-router": "~4.0.17",
|
"expo-router": "~4.0.17",
|
||||||
|
|
@ -7625,6 +7626,27 @@
|
||||||
"react": "*"
|
"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": {
|
"node_modules/expo-keep-awake": {
|
||||||
"version": "14.0.3",
|
"version": "14.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"expo": "~52.0.35",
|
"expo": "~52.0.35",
|
||||||
"expo-camera": "~16.0.18",
|
"expo-camera": "~16.0.18",
|
||||||
"expo-constants": "~17.0.7",
|
"expo-constants": "~17.0.7",
|
||||||
|
"expo-image-picker": "~16.0.3",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-linking": "~7.0.5",
|
"expo-linking": "~7.0.5",
|
||||||
"expo-router": "~4.0.17",
|
"expo-router": "~4.0.17",
|
||||||
|
|
@ -50,12 +51,12 @@
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@react-native-community/cli": "latest",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3"
|
||||||
"@react-native-community/cli": "latest"
|
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user