@tuturuuu/ui 0.22.0 → 0.24.0
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/CHANGELOG.md +36 -0
- package/biome.json +1 -1
- package/package.json +78 -78
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
- package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
- package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
- package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
- package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
- package/src/components/ui/chat/chat-utils.test.ts +107 -0
- package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
- package/src/components/ui/chat/chat-workspace.tsx +66 -24
- package/src/components/ui/chat/hooks-conversations.ts +9 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -0
- package/src/components/ui/chat/message-bubble.tsx +49 -4
- package/src/components/ui/chat/message-composer.tsx +29 -23
- package/src/components/ui/chat/message-markdown.tsx +4 -1
- package/src/components/ui/chat/query-keys.ts +7 -1
- package/src/components/ui/chat/utils.ts +85 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
- package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
- package/src/components/ui/custom/workspace-select.tsx +37 -38
- package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
- package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
- package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
- package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
- package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
- package/src/components/ui/legacy/meet/form.tsx +25 -1
- package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
- package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
- package/src/components/ui/legacy/meet/page.tsx +1 -0
- package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
- package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
- package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
- package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
- package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
- package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
- package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
- package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
- package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
- package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
- package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
- package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
- package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
- package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
- package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
- package/src/components/ui/nuqs-adapter.tsx +1 -0
- package/src/hooks/time-blocking-provider.tsx +127 -211
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { AlertTriangle, Link2, RefreshCw, Unlink } from '@tuturuuu/icons';
|
|
5
|
+
import {
|
|
6
|
+
bulkLinkInventoryFinanceEntries,
|
|
7
|
+
bulkUnlinkInventoryFinanceEntries,
|
|
8
|
+
getInventoryFinanceMappings,
|
|
9
|
+
type InventoryFinanceEntryStatus,
|
|
10
|
+
type InventoryFinanceProvider,
|
|
11
|
+
listInventoryFinanceEntries,
|
|
12
|
+
syncInventoryFinanceProvider,
|
|
13
|
+
} from '@tuturuuu/internal-api';
|
|
14
|
+
import {
|
|
15
|
+
listTransactionCategories,
|
|
16
|
+
listWallets,
|
|
17
|
+
} from '@tuturuuu/internal-api/finance';
|
|
18
|
+
import { Badge } from '@tuturuuu/ui/badge';
|
|
19
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
20
|
+
import { Checkbox } from '@tuturuuu/ui/checkbox';
|
|
21
|
+
import {
|
|
22
|
+
Select,
|
|
23
|
+
SelectContent,
|
|
24
|
+
SelectItem,
|
|
25
|
+
SelectTrigger,
|
|
26
|
+
SelectValue,
|
|
27
|
+
} from '@tuturuuu/ui/select';
|
|
28
|
+
import { toast } from '@tuturuuu/ui/sonner';
|
|
29
|
+
import { useTranslations } from 'next-intl';
|
|
30
|
+
import { useMemo, useState } from 'react';
|
|
31
|
+
import {
|
|
32
|
+
formatInventoryReconciliationAmount,
|
|
33
|
+
InventoryReconciliationEntryRow,
|
|
34
|
+
inventoryProviderTranslationKey,
|
|
35
|
+
} from './inventory-reconciliation-entry-row';
|
|
36
|
+
|
|
37
|
+
const ALL = 'all';
|
|
38
|
+
const NONE = 'none';
|
|
39
|
+
const providerValues: Array<InventoryFinanceProvider | typeof ALL> = [
|
|
40
|
+
ALL,
|
|
41
|
+
'cash',
|
|
42
|
+
'polar',
|
|
43
|
+
'square_pos',
|
|
44
|
+
'square_terminal',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function InventoryReconciliationPanel({
|
|
48
|
+
defaultCurrency,
|
|
49
|
+
wsId,
|
|
50
|
+
}: {
|
|
51
|
+
defaultCurrency: string;
|
|
52
|
+
wsId: string;
|
|
53
|
+
}) {
|
|
54
|
+
const t = useTranslations('inventory-finance-reconciliation');
|
|
55
|
+
const queryClient = useQueryClient();
|
|
56
|
+
const [provider, setProvider] = useState<
|
|
57
|
+
InventoryFinanceProvider | typeof ALL
|
|
58
|
+
>(ALL);
|
|
59
|
+
const [status, setStatus] = useState<InventoryFinanceEntryStatus>('pending');
|
|
60
|
+
const [currency, setCurrency] = useState(defaultCurrency);
|
|
61
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
62
|
+
const [walletId, setWalletId] = useState(NONE);
|
|
63
|
+
const [categoryId, setCategoryId] = useState(NONE);
|
|
64
|
+
|
|
65
|
+
const reconciliationQuery = useQuery({
|
|
66
|
+
queryKey: [
|
|
67
|
+
'inventory-finance-reconciliation',
|
|
68
|
+
wsId,
|
|
69
|
+
provider,
|
|
70
|
+
status,
|
|
71
|
+
currency,
|
|
72
|
+
],
|
|
73
|
+
queryFn: () =>
|
|
74
|
+
listInventoryFinanceEntries(wsId, {
|
|
75
|
+
currency: currency || undefined,
|
|
76
|
+
limit: 100,
|
|
77
|
+
provider: provider === ALL ? undefined : provider,
|
|
78
|
+
status,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
const mappingsQuery = useQuery({
|
|
82
|
+
queryKey: ['inventory-finance-mappings', wsId],
|
|
83
|
+
queryFn: () => getInventoryFinanceMappings(wsId),
|
|
84
|
+
});
|
|
85
|
+
const walletsQuery = useQuery({
|
|
86
|
+
queryKey: ['workspace-wallets', wsId],
|
|
87
|
+
queryFn: () => listWallets(wsId),
|
|
88
|
+
});
|
|
89
|
+
const categoriesQuery = useQuery({
|
|
90
|
+
queryKey: ['transaction-categories', wsId],
|
|
91
|
+
queryFn: () => listTransactionCategories(wsId),
|
|
92
|
+
});
|
|
93
|
+
const entries = reconciliationQuery.data?.data ?? [];
|
|
94
|
+
const selectedEntries = entries.filter((entry) =>
|
|
95
|
+
selectedIds.includes(entry.id)
|
|
96
|
+
);
|
|
97
|
+
const selectedCurrencies = new Set(
|
|
98
|
+
selectedEntries.map((entry) => entry.currency)
|
|
99
|
+
);
|
|
100
|
+
const compatibleWallets = (walletsQuery.data ?? []).filter(
|
|
101
|
+
(wallet) =>
|
|
102
|
+
wallet.id &&
|
|
103
|
+
(selectedCurrencies.size !== 1 ||
|
|
104
|
+
selectedCurrencies.has((wallet.currency ?? '').toUpperCase()))
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const invalidate = async () => {
|
|
108
|
+
setSelectedIds([]);
|
|
109
|
+
await Promise.all([
|
|
110
|
+
queryClient.invalidateQueries({
|
|
111
|
+
queryKey: ['inventory-finance-reconciliation', wsId],
|
|
112
|
+
}),
|
|
113
|
+
queryClient.invalidateQueries({ queryKey: ['transactions'] }),
|
|
114
|
+
queryClient.invalidateQueries({ queryKey: ['workspace-wallets', wsId] }),
|
|
115
|
+
]);
|
|
116
|
+
};
|
|
117
|
+
const linkMutation = useMutation({
|
|
118
|
+
mutationFn: () =>
|
|
119
|
+
bulkLinkInventoryFinanceEntries(wsId, {
|
|
120
|
+
categoryId: categoryId === NONE ? null : categoryId,
|
|
121
|
+
entryIds: selectedIds,
|
|
122
|
+
walletId,
|
|
123
|
+
}),
|
|
124
|
+
onSuccess: async () => {
|
|
125
|
+
toast.success(t('bulk_link_success'));
|
|
126
|
+
await invalidate();
|
|
127
|
+
},
|
|
128
|
+
onError: () => toast.error(t('bulk_action_error')),
|
|
129
|
+
});
|
|
130
|
+
const unlinkMutation = useMutation({
|
|
131
|
+
mutationFn: () =>
|
|
132
|
+
bulkUnlinkInventoryFinanceEntries(wsId, { entryIds: selectedIds }),
|
|
133
|
+
onSuccess: async () => {
|
|
134
|
+
toast.success(t('bulk_unlink_success'));
|
|
135
|
+
await invalidate();
|
|
136
|
+
},
|
|
137
|
+
onError: () => toast.error(t('bulk_action_error')),
|
|
138
|
+
});
|
|
139
|
+
const applyDefaultsMutation = useMutation({
|
|
140
|
+
mutationFn: async () => {
|
|
141
|
+
const mappings = mappingsQuery.data?.mappings ?? [];
|
|
142
|
+
const groups = new Map<
|
|
143
|
+
string,
|
|
144
|
+
{ categoryId: string | null; entryIds: string[]; walletId: string }
|
|
145
|
+
>();
|
|
146
|
+
for (const entry of selectedEntries) {
|
|
147
|
+
const mapping = mappings.find(
|
|
148
|
+
(item) =>
|
|
149
|
+
item.provider === entry.provider &&
|
|
150
|
+
item.currency === entry.currency &&
|
|
151
|
+
item.walletId
|
|
152
|
+
);
|
|
153
|
+
if (!mapping?.walletId) throw new Error('missing-mapping');
|
|
154
|
+
const key = `${mapping.walletId}:${mapping.categoryId ?? ''}`;
|
|
155
|
+
const group = groups.get(key) ?? {
|
|
156
|
+
categoryId: mapping.categoryId,
|
|
157
|
+
entryIds: [],
|
|
158
|
+
walletId: mapping.walletId,
|
|
159
|
+
};
|
|
160
|
+
group.entryIds.push(entry.id);
|
|
161
|
+
groups.set(key, group);
|
|
162
|
+
}
|
|
163
|
+
return Promise.all(
|
|
164
|
+
[...groups.values()].map((group) =>
|
|
165
|
+
bulkLinkInventoryFinanceEntries(wsId, group)
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
onSuccess: async () => {
|
|
170
|
+
toast.success(t('apply_defaults_success'));
|
|
171
|
+
await invalidate();
|
|
172
|
+
},
|
|
173
|
+
onError: () => toast.error(t('missing_mapping_error')),
|
|
174
|
+
});
|
|
175
|
+
const syncMutation = useMutation({
|
|
176
|
+
mutationFn: async () => {
|
|
177
|
+
const selectedProvider =
|
|
178
|
+
provider === 'polar' ? 'polar' : provider === ALL ? null : 'square';
|
|
179
|
+
const providers: ReadonlyArray<'polar' | 'square'> = selectedProvider
|
|
180
|
+
? [selectedProvider]
|
|
181
|
+
: (['polar', 'square'] as const);
|
|
182
|
+
return Promise.all(
|
|
183
|
+
providers.map((item) =>
|
|
184
|
+
syncInventoryFinanceProvider(wsId, {
|
|
185
|
+
environment: 'production',
|
|
186
|
+
limit: 100,
|
|
187
|
+
provider: item,
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
},
|
|
192
|
+
onSuccess: async () => {
|
|
193
|
+
toast.success(t('sync_success'));
|
|
194
|
+
await invalidate();
|
|
195
|
+
},
|
|
196
|
+
onError: () => toast.error(t('sync_error')),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const pending = reconciliationQuery.data?.summary.pending ?? [];
|
|
200
|
+
const pendingLabel = useMemo(
|
|
201
|
+
() =>
|
|
202
|
+
pending.length
|
|
203
|
+
? pending
|
|
204
|
+
.map(
|
|
205
|
+
(item) =>
|
|
206
|
+
`${item.count} · ${formatInventoryReconciliationAmount(item.amount, item.currency)}`
|
|
207
|
+
)
|
|
208
|
+
.join(' / ')
|
|
209
|
+
: t('nothing_pending'),
|
|
210
|
+
[pending, t]
|
|
211
|
+
);
|
|
212
|
+
const allSelected =
|
|
213
|
+
entries.length > 0 &&
|
|
214
|
+
entries.every((entry) => selectedIds.includes(entry.id));
|
|
215
|
+
const isMutating =
|
|
216
|
+
linkMutation.isPending ||
|
|
217
|
+
unlinkMutation.isPending ||
|
|
218
|
+
applyDefaultsMutation.isPending;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<section className="mb-4 overflow-hidden rounded-2xl border bg-card shadow-sm">
|
|
222
|
+
<div className="flex flex-col gap-4 border-b bg-muted/30 p-4 lg:flex-row lg:items-center lg:justify-between">
|
|
223
|
+
<div className="flex gap-3">
|
|
224
|
+
<AlertTriangle className="mt-0.5 h-5 w-5 text-dynamic-orange" />
|
|
225
|
+
<div>
|
|
226
|
+
<h2 className="font-semibold">{t('title')}</h2>
|
|
227
|
+
<p className="text-muted-foreground text-sm">{t('description')}</p>
|
|
228
|
+
<p className="mt-1 font-medium text-sm">{pendingLabel}</p>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
<Button
|
|
232
|
+
disabled={syncMutation.isPending || provider === 'cash'}
|
|
233
|
+
onClick={() => syncMutation.mutate()}
|
|
234
|
+
size="sm"
|
|
235
|
+
variant="outline"
|
|
236
|
+
>
|
|
237
|
+
<RefreshCw
|
|
238
|
+
className={syncMutation.isPending ? 'animate-spin' : undefined}
|
|
239
|
+
/>
|
|
240
|
+
{t('sync_provider_history')}
|
|
241
|
+
</Button>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div className="flex flex-wrap gap-2 border-b p-3">
|
|
245
|
+
<Select
|
|
246
|
+
value={status}
|
|
247
|
+
onValueChange={(value) => {
|
|
248
|
+
setStatus(value as InventoryFinanceEntryStatus);
|
|
249
|
+
setSelectedIds([]);
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
<SelectTrigger className="w-44">
|
|
253
|
+
<SelectValue />
|
|
254
|
+
</SelectTrigger>
|
|
255
|
+
<SelectContent>
|
|
256
|
+
<SelectItem value="pending">{t('view_needs_wallet')}</SelectItem>
|
|
257
|
+
<SelectItem value="linked">{t('view_linked')}</SelectItem>
|
|
258
|
+
<SelectItem value="error">{t('view_errors')}</SelectItem>
|
|
259
|
+
</SelectContent>
|
|
260
|
+
</Select>
|
|
261
|
+
<Select
|
|
262
|
+
value={provider}
|
|
263
|
+
onValueChange={(value) => {
|
|
264
|
+
setProvider(value as InventoryFinanceProvider | typeof ALL);
|
|
265
|
+
setSelectedIds([]);
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<SelectTrigger className="w-48">
|
|
269
|
+
<SelectValue />
|
|
270
|
+
</SelectTrigger>
|
|
271
|
+
<SelectContent>
|
|
272
|
+
{providerValues.map((value) => (
|
|
273
|
+
<SelectItem key={value} value={value}>
|
|
274
|
+
{value === ALL
|
|
275
|
+
? t('all_providers')
|
|
276
|
+
: t(inventoryProviderTranslationKey(value))}
|
|
277
|
+
</SelectItem>
|
|
278
|
+
))}
|
|
279
|
+
</SelectContent>
|
|
280
|
+
</Select>
|
|
281
|
+
<Select value={currency} onValueChange={setCurrency}>
|
|
282
|
+
<SelectTrigger className="w-28">
|
|
283
|
+
<SelectValue />
|
|
284
|
+
</SelectTrigger>
|
|
285
|
+
<SelectContent>
|
|
286
|
+
{[...new Set([defaultCurrency, 'USD', 'VND'])].map((value) => (
|
|
287
|
+
<SelectItem key={value} value={value}>
|
|
288
|
+
{value}
|
|
289
|
+
</SelectItem>
|
|
290
|
+
))}
|
|
291
|
+
</SelectContent>
|
|
292
|
+
</Select>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{entries.length > 0 && (
|
|
296
|
+
<div className="flex items-center gap-2 border-b px-4 py-2 text-sm">
|
|
297
|
+
<Checkbox
|
|
298
|
+
checked={allSelected}
|
|
299
|
+
onCheckedChange={(checked) =>
|
|
300
|
+
setSelectedIds(
|
|
301
|
+
checked === true ? entries.map((entry) => entry.id) : []
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
/>
|
|
305
|
+
{t('select_all', { count: entries.length })}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
<div>
|
|
309
|
+
{entries.map((entry) => (
|
|
310
|
+
<InventoryReconciliationEntryRow
|
|
311
|
+
checked={selectedIds.includes(entry.id)}
|
|
312
|
+
entry={entry}
|
|
313
|
+
key={entry.id}
|
|
314
|
+
onCheckedChange={(checked) =>
|
|
315
|
+
setSelectedIds((current) =>
|
|
316
|
+
checked
|
|
317
|
+
? [...new Set([...current, entry.id])]
|
|
318
|
+
: current.filter((id) => id !== entry.id)
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
/>
|
|
322
|
+
))}
|
|
323
|
+
{!reconciliationQuery.isLoading && entries.length === 0 && (
|
|
324
|
+
<p className="p-8 text-center text-muted-foreground text-sm">
|
|
325
|
+
{t('empty_view')}
|
|
326
|
+
</p>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{selectedIds.length > 0 && (
|
|
331
|
+
<div className="sticky bottom-3 m-3 flex flex-col gap-2 rounded-xl border bg-background/95 p-3 shadow-lg backdrop-blur sm:flex-row sm:items-center">
|
|
332
|
+
<Badge variant="secondary">
|
|
333
|
+
{t('selected_count', { count: selectedIds.length })}
|
|
334
|
+
</Badge>
|
|
335
|
+
{status === 'linked' ? (
|
|
336
|
+
<Button
|
|
337
|
+
disabled={isMutating}
|
|
338
|
+
onClick={() => unlinkMutation.mutate()}
|
|
339
|
+
size="sm"
|
|
340
|
+
variant="destructive"
|
|
341
|
+
>
|
|
342
|
+
<Unlink />
|
|
343
|
+
{t('unlink')}
|
|
344
|
+
</Button>
|
|
345
|
+
) : (
|
|
346
|
+
<>
|
|
347
|
+
<Select value={walletId} onValueChange={setWalletId}>
|
|
348
|
+
<SelectTrigger className="min-w-48">
|
|
349
|
+
<SelectValue placeholder={t('assign_wallet')} />
|
|
350
|
+
</SelectTrigger>
|
|
351
|
+
<SelectContent>
|
|
352
|
+
<SelectItem disabled value={NONE}>
|
|
353
|
+
{t('assign_wallet')}
|
|
354
|
+
</SelectItem>
|
|
355
|
+
{compatibleWallets.map((wallet) => (
|
|
356
|
+
<SelectItem key={wallet.id} value={wallet.id as string}>
|
|
357
|
+
{wallet.name} · {wallet.currency}
|
|
358
|
+
</SelectItem>
|
|
359
|
+
))}
|
|
360
|
+
</SelectContent>
|
|
361
|
+
</Select>
|
|
362
|
+
<Select value={categoryId} onValueChange={setCategoryId}>
|
|
363
|
+
<SelectTrigger className="min-w-44">
|
|
364
|
+
<SelectValue placeholder={t('set_category')} />
|
|
365
|
+
</SelectTrigger>
|
|
366
|
+
<SelectContent>
|
|
367
|
+
<SelectItem value={NONE}>{t('uncategorized')}</SelectItem>
|
|
368
|
+
{(categoriesQuery.data ?? [])
|
|
369
|
+
.filter((category) => category.id)
|
|
370
|
+
.map((category) => (
|
|
371
|
+
<SelectItem
|
|
372
|
+
key={category.id}
|
|
373
|
+
value={category.id as string}
|
|
374
|
+
>
|
|
375
|
+
{category.name}
|
|
376
|
+
</SelectItem>
|
|
377
|
+
))}
|
|
378
|
+
</SelectContent>
|
|
379
|
+
</Select>
|
|
380
|
+
<Button
|
|
381
|
+
disabled={
|
|
382
|
+
isMutating ||
|
|
383
|
+
walletId === NONE ||
|
|
384
|
+
selectedCurrencies.size !== 1
|
|
385
|
+
}
|
|
386
|
+
onClick={() => linkMutation.mutate()}
|
|
387
|
+
size="sm"
|
|
388
|
+
>
|
|
389
|
+
<Link2 />
|
|
390
|
+
{t('assign_or_move')}
|
|
391
|
+
</Button>
|
|
392
|
+
<Button
|
|
393
|
+
disabled={isMutating}
|
|
394
|
+
onClick={() => applyDefaultsMutation.mutate()}
|
|
395
|
+
size="sm"
|
|
396
|
+
variant="outline"
|
|
397
|
+
>
|
|
398
|
+
<RefreshCw />
|
|
399
|
+
{t('apply_defaults')}
|
|
400
|
+
</Button>
|
|
401
|
+
</>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
405
|
+
</section>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
@@ -78,6 +78,7 @@ export function TransactionCard({
|
|
|
78
78
|
isDaily = false,
|
|
79
79
|
}: TransactionCardProps) {
|
|
80
80
|
const t = useTranslations('workspace-finance-transactions');
|
|
81
|
+
const sourceT = useTranslations('inventory-finance-reconciliation');
|
|
81
82
|
const commonT = useTranslations('common');
|
|
82
83
|
const financeHref = useFinanceHref();
|
|
83
84
|
const [isHovered, setIsHovered] = useState(false);
|
|
@@ -439,6 +440,20 @@ export function TransactionCard({
|
|
|
439
440
|
{t('confidential')}
|
|
440
441
|
</Badge>
|
|
441
442
|
)}
|
|
443
|
+
{transaction.source && (
|
|
444
|
+
<Link
|
|
445
|
+
href={transaction.source.inventoryHref}
|
|
446
|
+
onClick={(event) => event.stopPropagation()}
|
|
447
|
+
>
|
|
448
|
+
<Badge
|
|
449
|
+
variant="outline"
|
|
450
|
+
className="border-dynamic-blue/40 bg-dynamic-blue/10 text-dynamic-blue"
|
|
451
|
+
>
|
|
452
|
+
{sourceT(`provider_${transaction.source.provider}`)} ·{' '}
|
|
453
|
+
{sourceT(`kind_${transaction.source.kind}`)}
|
|
454
|
+
</Badge>
|
|
455
|
+
</Link>
|
|
456
|
+
)}
|
|
442
457
|
</div>
|
|
443
458
|
|
|
444
459
|
{/* Amount + Actions */}
|
|
@@ -122,4 +122,35 @@ describe('TransactionEditDialog', () => {
|
|
|
122
122
|
})
|
|
123
123
|
).toBeDisabled();
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
it('locks provider-controlled amount and occurrence controls', async () => {
|
|
127
|
+
renderDialog({
|
|
128
|
+
transaction: {
|
|
129
|
+
amount: 123,
|
|
130
|
+
category_id: 'category-1',
|
|
131
|
+
description: 'Polar sale',
|
|
132
|
+
id: 'transaction-1',
|
|
133
|
+
report_opt_in: true,
|
|
134
|
+
source: {
|
|
135
|
+
checkoutId: 'checkout-1',
|
|
136
|
+
entryId: 'entry-1',
|
|
137
|
+
inventoryHref: 'https://inventory.tuturuuu.com/ws-1/sales',
|
|
138
|
+
kind: 'sale',
|
|
139
|
+
provider: 'polar',
|
|
140
|
+
providerReferenceId: 'order-1',
|
|
141
|
+
reconciliationHref: '/ws-1/transactions?reconciliation=entry-1',
|
|
142
|
+
type: 'inventory',
|
|
143
|
+
},
|
|
144
|
+
taken_at: '2026-05-27T00:00:00.000Z',
|
|
145
|
+
wallet_id: 'wallet-1',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(await screen.findByText('provider_fields_locked')).toBeVisible();
|
|
150
|
+
expect(
|
|
151
|
+
screen.getByRole('switch', {
|
|
152
|
+
name: 'transaction-data-table.include_time',
|
|
153
|
+
})
|
|
154
|
+
).toBeDisabled();
|
|
155
|
+
});
|
|
125
156
|
});
|
|
@@ -168,6 +168,7 @@ export function TransactionEditDialog({
|
|
|
168
168
|
timezone,
|
|
169
169
|
}: TransactionEditDialogProps) {
|
|
170
170
|
const t = useTranslations();
|
|
171
|
+
const sourceT = useTranslations('inventory-finance-reconciliation');
|
|
171
172
|
const locale = useLocale();
|
|
172
173
|
const router = useRouter();
|
|
173
174
|
const queryClient = useQueryClient();
|
|
@@ -197,6 +198,7 @@ export function TransactionEditDialog({
|
|
|
197
198
|
// Disable all fields if user lacks permission to view/update confidential transaction
|
|
198
199
|
const isDisabled =
|
|
199
200
|
hasAnyConfidentialField && !canUpdateConfidentialTransactions;
|
|
201
|
+
const isProviderLinked = Boolean(transaction.source);
|
|
200
202
|
|
|
201
203
|
// Check if user can delete this specific transaction
|
|
202
204
|
const canDeleteThisTransaction =
|
|
@@ -345,15 +347,19 @@ export function TransactionEditDialog({
|
|
|
345
347
|
|
|
346
348
|
const payload = {
|
|
347
349
|
description,
|
|
348
|
-
amount: finalAmount,
|
|
349
350
|
origin_wallet_id: walletId,
|
|
350
351
|
category_id: categoryId,
|
|
351
|
-
taken_at: takenAt?.toISOString(),
|
|
352
352
|
report_opt_in: reportOptIn,
|
|
353
353
|
tag_ids: selectedTagIds,
|
|
354
354
|
is_amount_confidential: isAmountConfidential,
|
|
355
355
|
is_description_confidential: isDescriptionConfidential,
|
|
356
356
|
is_category_confidential: isCategoryConfidential,
|
|
357
|
+
...(isProviderLinked
|
|
358
|
+
? {}
|
|
359
|
+
: {
|
|
360
|
+
amount: finalAmount,
|
|
361
|
+
taken_at: takenAt?.toISOString(),
|
|
362
|
+
}),
|
|
357
363
|
};
|
|
358
364
|
|
|
359
365
|
if (transaction?.id) {
|
|
@@ -388,6 +394,7 @@ export function TransactionEditDialog({
|
|
|
388
394
|
canSave,
|
|
389
395
|
canUpdateTransactions,
|
|
390
396
|
isExpense,
|
|
397
|
+
isProviderLinked,
|
|
391
398
|
amount,
|
|
392
399
|
transaction,
|
|
393
400
|
wsId,
|
|
@@ -519,6 +526,12 @@ export function TransactionEditDialog({
|
|
|
519
526
|
{t('workspace-finance-transactions.confidential')}
|
|
520
527
|
</Badge>
|
|
521
528
|
)}
|
|
529
|
+
{transaction.source && (
|
|
530
|
+
<Badge variant="outline">
|
|
531
|
+
{sourceT(`provider_${transaction.source.provider}`)} ·{' '}
|
|
532
|
+
{sourceT(`kind_${transaction.source.kind}`)}
|
|
533
|
+
</Badge>
|
|
534
|
+
)}
|
|
522
535
|
</div>
|
|
523
536
|
<DialogDescription className="text-xs">
|
|
524
537
|
{canUpdateTransactions
|
|
@@ -634,7 +647,9 @@ export function TransactionEditDialog({
|
|
|
634
647
|
value={amount}
|
|
635
648
|
onChange={setAmount}
|
|
636
649
|
placeholder="0"
|
|
637
|
-
disabled={
|
|
650
|
+
disabled={
|
|
651
|
+
isDisabled || !canUpdateTransactions || isProviderLinked
|
|
652
|
+
}
|
|
638
653
|
locale={locale}
|
|
639
654
|
currencySuffix={selectedWalletCurrency}
|
|
640
655
|
className={cn(
|
|
@@ -667,6 +682,11 @@ export function TransactionEditDialog({
|
|
|
667
682
|
}).format(isExpense ? -amount : amount)}
|
|
668
683
|
</span>
|
|
669
684
|
</div>
|
|
685
|
+
{isProviderLinked && (
|
|
686
|
+
<p className="text-muted-foreground text-xs">
|
|
687
|
+
{sourceT('provider_fields_locked')}
|
|
688
|
+
</p>
|
|
689
|
+
)}
|
|
670
690
|
</div>
|
|
671
691
|
)}
|
|
672
692
|
|
|
@@ -760,7 +780,9 @@ export function TransactionEditDialog({
|
|
|
760
780
|
includeTimeLabel={t('transaction-data-table.include_time')}
|
|
761
781
|
allowClear={false}
|
|
762
782
|
showFooterControls={true}
|
|
763
|
-
disabled={
|
|
783
|
+
disabled={
|
|
784
|
+
isDisabled || !canUpdateTransactions || isProviderLinked
|
|
785
|
+
}
|
|
764
786
|
preferences={{
|
|
765
787
|
timezone: timezone || 'auto',
|
|
766
788
|
timeFormat: locale === 'vi' ? '24h' : '12h',
|
package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx
CHANGED
|
@@ -45,6 +45,7 @@ export function TransactionDetailsClientPage({
|
|
|
45
45
|
timezone,
|
|
46
46
|
}: Props) {
|
|
47
47
|
const t = useTranslations();
|
|
48
|
+
const sourceT = useTranslations('inventory-finance-reconciliation');
|
|
48
49
|
const financeHref = useFinanceHref();
|
|
49
50
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
|
50
51
|
const { isConfidential: areNumbersHidden } =
|
|
@@ -89,6 +90,12 @@ export function TransactionDetailsClientPage({
|
|
|
89
90
|
{transaction.category && (
|
|
90
91
|
<Badge variant="outline">{transaction.category}</Badge>
|
|
91
92
|
)}
|
|
93
|
+
{transaction.source && (
|
|
94
|
+
<Badge variant="outline">
|
|
95
|
+
{sourceT(`provider_${transaction.source.provider}`)} ·{' '}
|
|
96
|
+
{sourceT(`kind_${transaction.source.kind}`)}
|
|
97
|
+
</Badge>
|
|
98
|
+
)}
|
|
92
99
|
</div>
|
|
93
100
|
<p className="text-muted-foreground text-sm">
|
|
94
101
|
{transaction.description || t('ws-transactions.no_description')}
|
|
@@ -152,6 +159,21 @@ export function TransactionDetailsClientPage({
|
|
|
152
159
|
</div>
|
|
153
160
|
|
|
154
161
|
<div className="space-y-3 p-4">
|
|
162
|
+
{transaction.source && (
|
|
163
|
+
<>
|
|
164
|
+
<DetailItem
|
|
165
|
+
icon={<Tag className="h-5 w-5 text-primary" />}
|
|
166
|
+
label={sourceT('provider_source')}
|
|
167
|
+
value={
|
|
168
|
+
<span className="font-mono text-xs">
|
|
169
|
+
{transaction.source.providerReferenceId}
|
|
170
|
+
</span>
|
|
171
|
+
}
|
|
172
|
+
href={transaction.source.inventoryHref}
|
|
173
|
+
/>
|
|
174
|
+
<Separator />
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
155
177
|
<DetailItem
|
|
156
178
|
icon={<Wallet className="h-5 w-5 text-primary" />}
|
|
157
179
|
label={t('transaction-data-table.wallet')}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FinancePermissionWarningDialog,
|
|
5
5
|
} from '@tuturuuu/ui/finance/shared/finance-permission-warning-dialog';
|
|
6
6
|
import ExportDialogContent from '@tuturuuu/ui/finance/transactions/export-dialog-content';
|
|
7
|
+
import { InventoryReconciliationPanel } from '@tuturuuu/ui/finance/transactions/inventory-reconciliation-panel';
|
|
7
8
|
import { TransactionsCreateSummary } from '@tuturuuu/ui/finance/transactions/transactions-create-summary';
|
|
8
9
|
import { TransactionsInfinitePage } from '@tuturuuu/ui/finance/transactions/transactions-infinite-page';
|
|
9
10
|
import { Separator } from '@tuturuuu/ui/separator';
|
|
@@ -55,6 +56,7 @@ export default async function TransactionsPage({
|
|
|
55
56
|
const workspaceCurrency = resolveSupportedCurrency(resolvedCurrency);
|
|
56
57
|
|
|
57
58
|
const canViewTransactions = containsPermission('view_transactions');
|
|
59
|
+
const canManageFinance = containsPermission('manage_finance');
|
|
58
60
|
const canExportFinanceData = containsPermission('export_finance_data');
|
|
59
61
|
const canCreateTransactions = containsPermission('create_transactions');
|
|
60
62
|
const canUpdateTransactions = containsPermission('update_transactions');
|
|
@@ -99,6 +101,12 @@ export default async function TransactionsPage({
|
|
|
99
101
|
|
|
100
102
|
return (
|
|
101
103
|
<>
|
|
104
|
+
{canManageFinance && (
|
|
105
|
+
<InventoryReconciliationPanel
|
|
106
|
+
defaultCurrency={workspaceCurrency}
|
|
107
|
+
wsId={wsId}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
102
110
|
<TransactionsCreateSummary
|
|
103
111
|
pluralTitle={t('ws-transactions.plural')}
|
|
104
112
|
singularTitle={t('ws-transactions.singular')}
|