@alexisapp/leave-mobile 0.0.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/package.json +43 -0
- package/src/components/ElementBoundary.tsx +45 -0
- package/src/components/ErrorFallback.tsx +70 -0
- package/src/components/cards/LeaveCard.tsx +119 -0
- package/src/components/cards/LeaveCardList.tsx +55 -0
- package/src/features/balance/components/BalanceTile.tsx +28 -0
- package/src/features/balance/components/BalanceTiles.tsx +44 -0
- package/src/features/balance/index.ts +1 -0
- package/src/features/history/components/LeaveHistory.tsx +10 -0
- package/src/features/history/index.ts +1 -0
- package/src/features/index.ts +3 -0
- package/src/features/upcoming/components/UpcomingLeaves.tsx +10 -0
- package/src/features/upcoming/index.ts +1 -0
- package/src/index.ts +8 -0
- package/src/providers/LeaveProvider.tsx +94 -0
- package/src/screens/LeaveScreen.tsx +58 -0
- package/src/screens/index.ts +2 -0
- package/src/theme/LeaveThemeContext.tsx +21 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/tokens.ts +211 -0
- package/src/ui/components/Button.tsx +107 -0
- package/src/ui/components/Card.tsx +41 -0
- package/src/ui/components/DatePicker.tsx +220 -0
- package/src/ui/components/Dialog.tsx +186 -0
- package/src/ui/components/Drawer.tsx +71 -0
- package/src/ui/components/InlineItem.tsx +33 -0
- package/src/ui/components/Input.tsx +77 -0
- package/src/ui/components/RadioGroup.tsx +94 -0
- package/src/ui/components/ScrollArea.tsx +23 -0
- package/src/ui/components/Select.tsx +145 -0
- package/src/ui/components/Sheet.tsx +85 -0
- package/src/ui/components/State.tsx +110 -0
- package/src/ui/components/Tabs.tsx +115 -0
- package/src/ui/components/TextArea.tsx +80 -0
- package/src/ui/components/Tooltip.tsx +64 -0
- package/src/ui/components/Typography.tsx +30 -0
- package/src/ui/components/useToast.ts +20 -0
- package/src/ui/index.ts +17 -0
- package/src/ui/theme.ts +2 -0
- package/src/utils/__tests__/leaveStatusColorsUtils.test.ts +70 -0
- package/src/utils/leaveStatusColorsUtils.ts +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# @leave/mobile
|
|
2
|
+
|
|
3
|
+
React Native package for the leave management feature. Consumed as an npm package by the `mobile-platform` host app.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm install
|
|
9
|
+
pnpm typecheck # tsc --noEmit
|
|
10
|
+
pnpm lint # eslint src/
|
|
11
|
+
pnpm test # vitest run
|
|
12
|
+
pnpm test:watch # vitest (watch mode)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
No build step — Metro resolves raw `.ts` via the `react-native` condition in `package.json`. The host compiles everything at bundle time.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
The primary export is `LeaveScreen` — a self-contained screen that initializes the client, i18n, query cache, and theme, then renders balance tiles, upcoming leaves, and leave history.
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { LeaveScreen } from '@leave/mobile';
|
|
23
|
+
|
|
24
|
+
<LeaveScreen
|
|
25
|
+
endpoint="https://api-2.dev-alexishr.io/graphql"
|
|
26
|
+
gatewayEndpoint="https://api-2.dev-alexishr.io/v2/graphql"
|
|
27
|
+
locale="en-GB"
|
|
28
|
+
getToken={() => auth0.getAccessTokenSilently()}
|
|
29
|
+
onAuthError={() => navigation.navigate('Login')}
|
|
30
|
+
onDismiss={() => navigation.goBack()}
|
|
31
|
+
onError={(err) => analytics.track('leave_error', err)}
|
|
32
|
+
/>;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Props (`LeaveScreenProps`)
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Required | Description |
|
|
38
|
+
| ----------------- | ---------------------------------------------------- | -------- | ----------------------------------------------------------- |
|
|
39
|
+
| `endpoint` | `string` | Yes | HR Core GraphQL endpoint URL |
|
|
40
|
+
| `gatewayEndpoint` | `string` | No | Gateway GraphQL endpoint URL |
|
|
41
|
+
| `locale` | `string` | No | Locale code (e.g. `en-GB`, `sv-SE`). Defaults to `en-GB` |
|
|
42
|
+
| `getToken` | `() => Promise<string \| null>` | No | Returns Auth0 JWT for authenticated requests |
|
|
43
|
+
| `onAuthError` | `() => void` | No | Called on 401/403 before throwing — host navigates to login |
|
|
44
|
+
| `devToken` | `string` | No | Hardcoded dev token. Takes precedence over `getToken` |
|
|
45
|
+
| `theme` | `LeaveTheme` | No | Custom theme tokens. Defaults to `defaultLeaveTheme` |
|
|
46
|
+
| `onDismiss` | `() => void` | Yes | Called when user taps close/dismiss |
|
|
47
|
+
| `onError` | `(error: { code: string; message: string }) => void` | No | Error callback for analytics/logging |
|
|
48
|
+
|
|
49
|
+
## Architecture
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
LeaveScreen
|
|
53
|
+
└── LeaveProvider # Initializes client, i18n, QueryClient, theme
|
|
54
|
+
└── LeaveScreenContent
|
|
55
|
+
├── ElementBoundary + BalanceTiles
|
|
56
|
+
├── ElementBoundary + UpcomingLeaves
|
|
57
|
+
└── ElementBoundary + LeaveHistory
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **LeaveProvider** — wires up the `@leave/core` singletons (`initializeClient`, `initLeaveI18n`), TanStack Query `QueryClientProvider`, `react-i18next` `I18nextProvider`, and `LeaveThemeProvider`. Blocks rendering until singletons are ready. Invalidates leave queries on AppState foreground transition.
|
|
61
|
+
- **ElementBoundary** — per-section error/suspense boundary wrapping `AsyncBoundary` from `@leave/core/components`. Shows `ActivityIndicator` during loading, `ErrorFallback` on error.
|
|
62
|
+
- **ErrorFallback** — RN error view with i18n title/description, retry button (for transient errors), and dismiss button.
|
|
63
|
+
|
|
64
|
+
## Source Layout
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
src/
|
|
68
|
+
├── components/ # ElementBoundary, ErrorFallback, cards (LeaveCard, LeaveCardList)
|
|
69
|
+
├── features/ # Feature modules (balance, upcoming, history)
|
|
70
|
+
│ ├── balance/ # BalanceTiles, BalanceTile
|
|
71
|
+
│ ├── upcoming/ # UpcomingLeaves, useUpcomingLeaves
|
|
72
|
+
│ └── history/ # LeaveHistory, useHistoryLeaves
|
|
73
|
+
├── hooks/ # useCurrentEmployeeId, useLeaveList
|
|
74
|
+
├── providers/ # LeaveProvider
|
|
75
|
+
├── screens/ # LeaveScreen (main entry point)
|
|
76
|
+
├── theme/ # LeaveThemeProvider, tokens (defaultLeaveTheme, darkLeaveTheme)
|
|
77
|
+
├── ui/ # Shared UI primitives (Button, Card, Input, Select, Dialog, etc.)
|
|
78
|
+
└── utils/ # formatDateRange, leaveStatus, splitLeaveSections
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Theming
|
|
82
|
+
|
|
83
|
+
The package ships with `defaultLeaveTheme` and `darkLeaveTheme`. Host apps can pass a custom `LeaveTheme` via the `theme` prop. Inside components, use `useLeaveTheme()` to access tokens:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { useLeaveTheme } from '@leave/mobile';
|
|
87
|
+
|
|
88
|
+
const { colors, spacing, typography, radius, opacity } = useLeaveTheme();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Exports
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// Screen
|
|
95
|
+
export { LeaveScreen } from '@leave/mobile';
|
|
96
|
+
export type { LeaveScreenProps } from '@leave/mobile';
|
|
97
|
+
|
|
98
|
+
// Backwards compat alias
|
|
99
|
+
export { TimeOffScreen } from '@leave/mobile';
|
|
100
|
+
|
|
101
|
+
// Theme
|
|
102
|
+
export {
|
|
103
|
+
useLeaveTheme,
|
|
104
|
+
LeaveThemeProvider,
|
|
105
|
+
defaultLeaveTheme,
|
|
106
|
+
darkLeaveTheme,
|
|
107
|
+
} from '@leave/mobile';
|
|
108
|
+
export type { LeaveTheme } from '@leave/mobile';
|
|
109
|
+
|
|
110
|
+
// UI primitives
|
|
111
|
+
export { Button, Card, Typography, Input, Select, Dialog /* ... */ } from '@leave/mobile';
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Dependencies
|
|
115
|
+
|
|
116
|
+
| Package | Purpose |
|
|
117
|
+
| ----------------------- | ------------------------------------- |
|
|
118
|
+
| `@leave/core` | Client, queries, errors, i18n, domain |
|
|
119
|
+
| `@tanstack/react-query` | Server-state cache |
|
|
120
|
+
| `@tanstack/react-form` | Form state management |
|
|
121
|
+
| `zod` | Schema validation |
|
|
122
|
+
| `ky` | HTTP client |
|
|
123
|
+
| `i18next` | Internationalization |
|
|
124
|
+
| `react-i18next` | React bindings for i18next |
|
|
125
|
+
| `react-error-boundary` | Error boundary primitives |
|
|
126
|
+
|
|
127
|
+
### Peer Dependencies
|
|
128
|
+
|
|
129
|
+
| Package | Range |
|
|
130
|
+
| -------------- | ---------------------- |
|
|
131
|
+
| `react` | `^18.0.0 \|\| ^19.0.0` |
|
|
132
|
+
| `react-native` | `>=0.73.0` |
|
|
133
|
+
| `expo` | `>=51.0.0` |
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alexisapp/leave-mobile",
|
|
3
|
+
"version": "0.0.1-beta.1",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"react-native": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"package.json"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"react-native": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"lint": "eslint src/",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
25
|
+
"react-native": ">=0.73.0",
|
|
26
|
+
"expo": ">=51.0.0",
|
|
27
|
+
"@tanstack/react-query": "^5.0.0",
|
|
28
|
+
"react-error-boundary": "^5.0.0",
|
|
29
|
+
"zod": "^4.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"typescript": "~5.9.0",
|
|
33
|
+
"@types/react": "^19.1.0",
|
|
34
|
+
"react-native": ">=0.73.0",
|
|
35
|
+
"vitest": "^3.0.0",
|
|
36
|
+
"@tanstack/react-query": "^5.0.0",
|
|
37
|
+
"react-error-boundary": "^5.0.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"react-i18next": "^14.0.0",
|
|
41
|
+
"@alexisapp/leave-core": "0.0.1-beta.1"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ActivityIndicator, View } from 'react-native';
|
|
2
|
+
import type { PropsWithChildren, ReactNode } from 'react';
|
|
3
|
+
import type { LeaveError } from '@leave/core';
|
|
4
|
+
import { AsyncBoundary } from '@leave/core/components';
|
|
5
|
+
import { ErrorFallback } from './ErrorFallback';
|
|
6
|
+
|
|
7
|
+
interface ElementBoundaryProps {
|
|
8
|
+
suspenseFallback?: ReactNode;
|
|
9
|
+
onDismiss: () => void;
|
|
10
|
+
onError?: ((error: { code: string; message: string }) => void) | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ElementBoundary = ({
|
|
14
|
+
children,
|
|
15
|
+
suspenseFallback,
|
|
16
|
+
onDismiss,
|
|
17
|
+
onError,
|
|
18
|
+
}: PropsWithChildren<ElementBoundaryProps>) => (
|
|
19
|
+
<AsyncBoundary
|
|
20
|
+
suspenseFallback={
|
|
21
|
+
suspenseFallback || (
|
|
22
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
23
|
+
<ActivityIndicator size="large" />
|
|
24
|
+
</View>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
errorBoundaryProps={{
|
|
28
|
+
fallbackRender: ({ error, resetErrorBoundary }) => (
|
|
29
|
+
<ErrorFallback
|
|
30
|
+
error={error}
|
|
31
|
+
resetErrorBoundary={resetErrorBoundary}
|
|
32
|
+
onDismiss={onDismiss}
|
|
33
|
+
/>
|
|
34
|
+
),
|
|
35
|
+
onError: (error) => {
|
|
36
|
+
onError?.({
|
|
37
|
+
code: (error as LeaveError).code ?? 'RENDER_ERROR',
|
|
38
|
+
message: error.message,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
</AsyncBoundary>
|
|
45
|
+
);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
3
|
+
import { isRetryable, getErrorMessage } from '@leave/core';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import type { FallbackProps } from 'react-error-boundary';
|
|
6
|
+
import { useLeaveTheme } from '../theme';
|
|
7
|
+
|
|
8
|
+
interface ErrorFallbackProps extends FallbackProps {
|
|
9
|
+
onDismiss: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ErrorFallback({ error, resetErrorBoundary, onDismiss }: ErrorFallbackProps) {
|
|
13
|
+
const { colors, spacing, typography } = useLeaveTheme();
|
|
14
|
+
const retryable = isRetryable(error);
|
|
15
|
+
const { title, description } = getErrorMessage(error);
|
|
16
|
+
const { t } = useTranslation();
|
|
17
|
+
|
|
18
|
+
const styles = useMemo(
|
|
19
|
+
() =>
|
|
20
|
+
StyleSheet.create({
|
|
21
|
+
container: {
|
|
22
|
+
flex: 1,
|
|
23
|
+
justifyContent: 'center',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
padding: spacing.xxl,
|
|
26
|
+
},
|
|
27
|
+
title: {
|
|
28
|
+
fontSize: typography.body.fontSize,
|
|
29
|
+
fontWeight: '600',
|
|
30
|
+
marginBottom: spacing.sm,
|
|
31
|
+
textAlign: 'center',
|
|
32
|
+
color: colors.textPrimary,
|
|
33
|
+
},
|
|
34
|
+
description: {
|
|
35
|
+
fontSize: typography.bodySmall.fontSize,
|
|
36
|
+
marginBottom: spacing.lg,
|
|
37
|
+
textAlign: 'center',
|
|
38
|
+
color: colors.textMuted,
|
|
39
|
+
},
|
|
40
|
+
retryText: { color: colors.primary, fontSize: typography.body.fontSize },
|
|
41
|
+
closeButton: { marginTop: spacing.md },
|
|
42
|
+
closeText: { color: colors.textMuted, fontSize: typography.bodySmall.fontSize },
|
|
43
|
+
}),
|
|
44
|
+
[colors, spacing, typography]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<View style={styles.container} accessible accessibilityRole='alert'>
|
|
49
|
+
<Text style={styles.title}>{title}</Text>
|
|
50
|
+
<Text style={styles.description}>{description}</Text>
|
|
51
|
+
{retryable && (
|
|
52
|
+
<TouchableOpacity
|
|
53
|
+
onPress={resetErrorBoundary}
|
|
54
|
+
accessibilityLabel={t('retry')}
|
|
55
|
+
accessibilityRole='button'
|
|
56
|
+
>
|
|
57
|
+
<Text style={styles.retryText}>{t('retry')}</Text>
|
|
58
|
+
</TouchableOpacity>
|
|
59
|
+
)}
|
|
60
|
+
<TouchableOpacity
|
|
61
|
+
onPress={onDismiss}
|
|
62
|
+
style={styles.closeButton}
|
|
63
|
+
accessibilityLabel={t('close')}
|
|
64
|
+
accessibilityRole='button'
|
|
65
|
+
>
|
|
66
|
+
<Text style={styles.closeText}>{t('close')}</Text>
|
|
67
|
+
</TouchableOpacity>
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { useLeaveTheme } from '../../theme';
|
|
5
|
+
import { formatDateRange } from '@leave/core/utils';
|
|
6
|
+
import type { DisplayStatus } from '@leave/core/utils';
|
|
7
|
+
import { getStatusChipColors } from '../../utils/leaveStatusColorsUtils';
|
|
8
|
+
|
|
9
|
+
interface LeaveCardProps {
|
|
10
|
+
typeName: string;
|
|
11
|
+
displayStatus: DisplayStatus;
|
|
12
|
+
localStartDate: string;
|
|
13
|
+
localEndDate: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function LeaveCard({
|
|
17
|
+
typeName,
|
|
18
|
+
displayStatus,
|
|
19
|
+
localStartDate,
|
|
20
|
+
localEndDate,
|
|
21
|
+
}: LeaveCardProps) {
|
|
22
|
+
const { colors, spacing, radius, typography, opacity } = useLeaveTheme();
|
|
23
|
+
const { t } = useTranslation();
|
|
24
|
+
const chipColors = getStatusChipColors(displayStatus, colors);
|
|
25
|
+
|
|
26
|
+
const statusLabel = t(`leave.status.${displayStatus}`);
|
|
27
|
+
const dateText = formatDateRange(localStartDate, localEndDate, t('leave.ongoing'));
|
|
28
|
+
|
|
29
|
+
const styles = useMemo(
|
|
30
|
+
() =>
|
|
31
|
+
StyleSheet.create({
|
|
32
|
+
card: {
|
|
33
|
+
backgroundColor: colors.surface,
|
|
34
|
+
borderRadius: radius.lg,
|
|
35
|
+
padding: spacing.md,
|
|
36
|
+
borderWidth: 1,
|
|
37
|
+
borderColor: colors.borderLight,
|
|
38
|
+
shadowColor: colors.shadow,
|
|
39
|
+
shadowOffset: { width: 0, height: 1 },
|
|
40
|
+
shadowOpacity: opacity.shadowLight,
|
|
41
|
+
shadowRadius: 2,
|
|
42
|
+
elevation: 1,
|
|
43
|
+
},
|
|
44
|
+
header: {
|
|
45
|
+
flexDirection: 'row',
|
|
46
|
+
justifyContent: 'space-between',
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
},
|
|
49
|
+
typeName: {
|
|
50
|
+
fontSize: typography.body.fontSize,
|
|
51
|
+
fontWeight: '600',
|
|
52
|
+
color: colors.textPrimary,
|
|
53
|
+
flex: 1,
|
|
54
|
+
marginRight: spacing.sm,
|
|
55
|
+
},
|
|
56
|
+
chip: {
|
|
57
|
+
flexDirection: 'row',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
backgroundColor: chipColors.background,
|
|
60
|
+
borderWidth: 1,
|
|
61
|
+
borderColor: chipColors.border,
|
|
62
|
+
borderRadius: radius.xl,
|
|
63
|
+
paddingHorizontal: spacing.sm,
|
|
64
|
+
paddingVertical: spacing.xxs,
|
|
65
|
+
gap: spacing.xxs,
|
|
66
|
+
},
|
|
67
|
+
chipIcon: {
|
|
68
|
+
fontSize: typography.caption.fontSize,
|
|
69
|
+
color: chipColors.text,
|
|
70
|
+
},
|
|
71
|
+
chipText: {
|
|
72
|
+
fontSize: typography.caption.fontSize,
|
|
73
|
+
fontWeight: '500',
|
|
74
|
+
color: chipColors.text,
|
|
75
|
+
},
|
|
76
|
+
dates: {
|
|
77
|
+
fontSize: typography.bodySmall.fontSize,
|
|
78
|
+
color: colors.textMuted,
|
|
79
|
+
marginTop: spacing.xs,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
[colors, spacing, radius, typography, opacity, chipColors]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const chipIcon = getChipIcon(displayStatus);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<View
|
|
89
|
+
style={styles.card}
|
|
90
|
+
accessibilityRole='summary'
|
|
91
|
+
accessibilityLabel={`${typeName}, ${statusLabel}, ${dateText}`}
|
|
92
|
+
>
|
|
93
|
+
<View style={styles.header}>
|
|
94
|
+
<Text style={styles.typeName} numberOfLines={1}>
|
|
95
|
+
{typeName}
|
|
96
|
+
</Text>
|
|
97
|
+
<View style={styles.chip}>
|
|
98
|
+
<Text style={styles.chipIcon}>{chipIcon}</Text>
|
|
99
|
+
<Text style={styles.chipText}>{statusLabel}</Text>
|
|
100
|
+
</View>
|
|
101
|
+
</View>
|
|
102
|
+
<Text style={styles.dates}>{dateText}</Text>
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getChipIcon(status: DisplayStatus): string {
|
|
108
|
+
switch (status) {
|
|
109
|
+
case 'APPROVED':
|
|
110
|
+
return '\u2713'; // ✓
|
|
111
|
+
case 'PENDING':
|
|
112
|
+
case 'PENDING_CHANGES':
|
|
113
|
+
case 'PENDING_REVOKE':
|
|
114
|
+
return '\u25F7'; // ◷
|
|
115
|
+
case 'DENIED':
|
|
116
|
+
case 'CANCELLED':
|
|
117
|
+
return '\u2717'; // ✗
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import type { LeaveListItem } from '@leave/core/hooks';
|
|
4
|
+
import { useLeaveTheme } from '../../theme';
|
|
5
|
+
import { LeaveCard } from './LeaveCard';
|
|
6
|
+
|
|
7
|
+
interface LeaveCardListProps {
|
|
8
|
+
title: string;
|
|
9
|
+
items: readonly LeaveListItem[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function LeaveCardList({ title, items }: LeaveCardListProps) {
|
|
13
|
+
const { colors, spacing, typography } = useLeaveTheme();
|
|
14
|
+
|
|
15
|
+
const styles = useMemo(
|
|
16
|
+
() =>
|
|
17
|
+
StyleSheet.create({
|
|
18
|
+
container: {
|
|
19
|
+
gap: spacing.sm,
|
|
20
|
+
},
|
|
21
|
+
header: {
|
|
22
|
+
fontSize: typography.caption.fontSize,
|
|
23
|
+
fontWeight: '600',
|
|
24
|
+
color: colors.textMuted,
|
|
25
|
+
textTransform: 'uppercase',
|
|
26
|
+
letterSpacing: 0.5,
|
|
27
|
+
},
|
|
28
|
+
list: {
|
|
29
|
+
gap: spacing.sm,
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
[colors, spacing, typography]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (items.length === 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View style={styles.container}>
|
|
41
|
+
<Text style={styles.header}>{title}</Text>
|
|
42
|
+
<View style={styles.list}>
|
|
43
|
+
{items.map(item => (
|
|
44
|
+
<LeaveCard
|
|
45
|
+
key={item.id}
|
|
46
|
+
typeName={item.typeName}
|
|
47
|
+
displayStatus={item.displayStatus}
|
|
48
|
+
localStartDate={item.localStartDate}
|
|
49
|
+
localEndDate={item.localEndDate}
|
|
50
|
+
/>
|
|
51
|
+
))}
|
|
52
|
+
</View>
|
|
53
|
+
</View>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
import { Card, Typography } from '../../../ui';
|
|
3
|
+
import { useLeaveTheme } from '../../../theme';
|
|
4
|
+
|
|
5
|
+
interface BalanceTileProps {
|
|
6
|
+
label: string;
|
|
7
|
+
value: number;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function BalanceTile({ label, value, subtitle }: BalanceTileProps) {
|
|
12
|
+
const { spacing } = useLeaveTheme();
|
|
13
|
+
return (
|
|
14
|
+
<View style={{ flex: 1 }} accessibilityLabel={`${label}: ${value} ${subtitle}`}>
|
|
15
|
+
<Card>
|
|
16
|
+
<Typography variant="caption" style={{ textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
17
|
+
{label}
|
|
18
|
+
</Typography>
|
|
19
|
+
<Typography variant="h2" style={{ marginTop: spacing.xs }}>
|
|
20
|
+
{String(value)}
|
|
21
|
+
</Typography>
|
|
22
|
+
<Typography variant="caption" style={{ marginTop: spacing.xxs }}>
|
|
23
|
+
{subtitle}
|
|
24
|
+
</Typography>
|
|
25
|
+
</Card>
|
|
26
|
+
</View>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useBalance } from '@leave/core/hooks';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { useLeaveTheme } from '../../../theme';
|
|
5
|
+
import { BalanceTile } from './BalanceTile';
|
|
6
|
+
|
|
7
|
+
export function BalanceTiles() {
|
|
8
|
+
const { spacing } = useLeaveTheme();
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const balance = useBalance();
|
|
11
|
+
|
|
12
|
+
if (!balance) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<View style={{ gap: spacing.sm }}>
|
|
16
|
+
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
|
17
|
+
<BalanceTile
|
|
18
|
+
label={t('balance.remaining.label')}
|
|
19
|
+
value={balance.remaining}
|
|
20
|
+
subtitle={t('balance.remaining.subtitle')}
|
|
21
|
+
/>
|
|
22
|
+
<BalanceTile
|
|
23
|
+
label={t('balance.spent.label')}
|
|
24
|
+
value={balance.spent}
|
|
25
|
+
subtitle={t('balance.spent.subtitle')}
|
|
26
|
+
/>
|
|
27
|
+
</View>
|
|
28
|
+
{balance.saved != null && balance.unpaid != null && (
|
|
29
|
+
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
|
30
|
+
<BalanceTile
|
|
31
|
+
label={t('balance.saved.label')}
|
|
32
|
+
value={balance.saved}
|
|
33
|
+
subtitle={t('balance.saved.subtitle')}
|
|
34
|
+
/>
|
|
35
|
+
<BalanceTile
|
|
36
|
+
label={t('balance.unpaid.label')}
|
|
37
|
+
value={balance.unpaid}
|
|
38
|
+
subtitle={t('balance.unpaid.subtitle')}
|
|
39
|
+
/>
|
|
40
|
+
</View>
|
|
41
|
+
)}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BalanceTiles } from './components/BalanceTiles';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next';
|
|
2
|
+
import { useLeaveList } from '@leave/core/hooks';
|
|
3
|
+
import { LeaveCardList } from '../../../components/cards/LeaveCardList';
|
|
4
|
+
|
|
5
|
+
export function LeaveHistory() {
|
|
6
|
+
const { history } = useLeaveList();
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
|
|
9
|
+
return <LeaveCardList title={t('leave.history')} items={history} />;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LeaveHistory } from './components/LeaveHistory';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next';
|
|
2
|
+
import { useLeaveList } from '@leave/core/hooks';
|
|
3
|
+
import { LeaveCardList } from '../../../components/cards/LeaveCardList';
|
|
4
|
+
|
|
5
|
+
export function UpcomingLeaves() {
|
|
6
|
+
const { upcoming } = useLeaveList();
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
|
|
9
|
+
return <LeaveCardList title={t('leave.upcoming')} items={upcoming} />;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UpcomingLeaves } from './components/UpcomingLeaves';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { LeaveScreen } from './screens/LeaveScreen';
|
|
2
|
+
export type { LeaveScreenProps } from './screens/LeaveScreen';
|
|
3
|
+
// Backwards compat
|
|
4
|
+
export { LeaveScreen as TimeOffScreen } from './screens/LeaveScreen';
|
|
5
|
+
export type { LeaveScreenProps as TimeOffScreenProps } from './screens/LeaveScreen';
|
|
6
|
+
export { useLeaveTheme, LeaveThemeProvider, defaultLeaveTheme, darkLeaveTheme } from './theme';
|
|
7
|
+
export type { LeaveTheme } from './theme';
|
|
8
|
+
export * from './ui';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useState } from 'react';
|
|
2
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
3
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import { I18nextProvider } from 'react-i18next';
|
|
5
|
+
import { initializeClient, resetClient } from '@leave/core';
|
|
6
|
+
import { initLeaveI18n, getLeaveI18n, resetLeaveI18n } from '@leave/core/i18n';
|
|
7
|
+
import { leaveQueries } from '@leave/core/queries';
|
|
8
|
+
import { LeaveThemeProvider, type LeaveTheme } from '../theme';
|
|
9
|
+
|
|
10
|
+
const queryClient = new QueryClient({
|
|
11
|
+
defaultOptions: {
|
|
12
|
+
queries: {
|
|
13
|
+
staleTime: 5 * 60 * 1000,
|
|
14
|
+
gcTime: 30 * 60 * 1000,
|
|
15
|
+
retry: 2,
|
|
16
|
+
refetchOnWindowFocus: false,
|
|
17
|
+
},
|
|
18
|
+
mutations: {
|
|
19
|
+
retry: 0,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export interface LeaveProviderProps {
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
endpoint: string;
|
|
27
|
+
gatewayEndpoint?: string | undefined;
|
|
28
|
+
locale?: string | undefined;
|
|
29
|
+
getToken?: (() => Promise<string | null>) | undefined;
|
|
30
|
+
onAuthError?: (() => void) | undefined;
|
|
31
|
+
devToken?: string | undefined;
|
|
32
|
+
theme?: LeaveTheme | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function LeaveProvider(props: LeaveProviderProps) {
|
|
36
|
+
const { children, endpoint, gatewayEndpoint, locale, getToken, onAuthError, devToken, theme } =
|
|
37
|
+
props;
|
|
38
|
+
|
|
39
|
+
const [initialized, setInitialized] = useState(false);
|
|
40
|
+
const [i18nReady, setI18nReady] = useState(false);
|
|
41
|
+
|
|
42
|
+
// Client initialization (ADR-011 singleton guard)
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
initializeClient({ endpoint, gatewayEndpoint, getToken, onAuthError, devToken });
|
|
45
|
+
setInitialized(true);
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
resetClient();
|
|
49
|
+
setInitialized(false);
|
|
50
|
+
};
|
|
51
|
+
}, [endpoint, gatewayEndpoint, getToken, onAuthError, devToken]);
|
|
52
|
+
|
|
53
|
+
// i18n initialization (ADR-011 singleton)
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
|
|
57
|
+
void initLeaveI18n(locale ?? 'en-GB').then(() => {
|
|
58
|
+
if (!cancelled) {
|
|
59
|
+
setI18nReady(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true;
|
|
65
|
+
resetLeaveI18n();
|
|
66
|
+
setI18nReady(false);
|
|
67
|
+
};
|
|
68
|
+
}, [locale]);
|
|
69
|
+
|
|
70
|
+
// AppState foreground refetch — replaces missing window.focus in RN
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
let previousState: AppStateStatus = AppState.currentState;
|
|
73
|
+
|
|
74
|
+
const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
|
|
75
|
+
if (previousState.match(/inactive|background/) && nextState === 'active') {
|
|
76
|
+
void queryClient.invalidateQueries({ queryKey: leaveQueries.all });
|
|
77
|
+
}
|
|
78
|
+
previousState = nextState;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return () => subscription.remove();
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
// State gate — prevent children from rendering before singletons are ready
|
|
85
|
+
if (!initialized || !i18nReady) return null;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<QueryClientProvider client={queryClient}>
|
|
89
|
+
<I18nextProvider i18n={getLeaveI18n()}>
|
|
90
|
+
<LeaveThemeProvider theme={theme}>{children}</LeaveThemeProvider>
|
|
91
|
+
</I18nextProvider>
|
|
92
|
+
</QueryClientProvider>
|
|
93
|
+
);
|
|
94
|
+
}
|