@apptimate/ui 6.9.0 → 7.1.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/package.json +2 -1
- package/src/base-components/ImageDropzone.tsx +80 -80
- package/src/common-components/DashboardLayout.tsx +5 -3
- package/src/common-components/NotificationsModal.tsx +173 -0
- package/src/common-components/NotificationsPopover.tsx +224 -0
- package/src/common-components/attendance-shifts/BulkAttendanceModal.tsx +155 -30
- package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +73 -11
- package/src/common-components/attendance-shifts/ShiftCalendar.tsx +41 -13
- package/src/finance-components/DirectExpenseModal.tsx +248 -0
- package/src/index.tsx +1 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Button, Input, ModalFooter, ChartOfAccountPicker, Modal } from "../index";
|
|
5
|
+
import { PaymentSection, PaymentEntry } from "../index";
|
|
6
|
+
import toast from "react-hot-toast";
|
|
7
|
+
import { Upload, X } from "lucide-react";
|
|
8
|
+
import { recordDirectExpenseShared, getPaymentModesShared, getBankAccountsLookupShared, getChartOfAccountsLookupShared } from "@apptimate/core-lib";
|
|
9
|
+
|
|
10
|
+
interface DirectExpenseModalProps {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
onSuccess: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function DirectExpenseModal({ isOpen, onClose, onSuccess }: DirectExpenseModalProps) {
|
|
17
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
18
|
+
|
|
19
|
+
const [transactionDate, setTransactionDate] = useState(new Date().toISOString().split("T")[0]);
|
|
20
|
+
const [expenseAccountId, setExpenseAccountId] = useState<number | string>("");
|
|
21
|
+
const [totalAmount, setTotalAmount] = useState<string>("");
|
|
22
|
+
const [reference, setReference] = useState("");
|
|
23
|
+
const [description, setDescription] = useState("");
|
|
24
|
+
const [payments, setPayments] = useState<PaymentEntry[]>([]);
|
|
25
|
+
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
26
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (!isOpen) {
|
|
30
|
+
setTransactionDate(new Date().toISOString().split("T")[0]);
|
|
31
|
+
setExpenseAccountId("");
|
|
32
|
+
setTotalAmount("");
|
|
33
|
+
setReference("");
|
|
34
|
+
setDescription("");
|
|
35
|
+
setPayments([]);
|
|
36
|
+
setSelectedFiles([]);
|
|
37
|
+
}
|
|
38
|
+
}, [isOpen]);
|
|
39
|
+
|
|
40
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
|
|
43
|
+
if (!expenseAccountId) {
|
|
44
|
+
toast.error("Please select an Expense Account");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const amountNum = parseFloat(totalAmount);
|
|
49
|
+
if (isNaN(amountNum) || amountNum <= 0) {
|
|
50
|
+
toast.error("Please enter a valid total amount");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (payments.length === 0) {
|
|
55
|
+
toast.error("Please add at least one payment method");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const totalToPay = payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
|
60
|
+
if (Math.abs(totalToPay - amountNum) > 0.01) {
|
|
61
|
+
toast.error(`Payment split (${totalToPay.toFixed(2)}) must exactly match the total amount (${amountNum.toFixed(2)})`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setIsSubmitting(true);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const formattedPayments = payments.map(p => {
|
|
69
|
+
const payload: any = {
|
|
70
|
+
payment_method: p.mode_code,
|
|
71
|
+
payment_amount: p.amount,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (p.metadata) {
|
|
75
|
+
if (p.metadata.bank_account_id) payload.bank_account_id = p.metadata.bank_account_id;
|
|
76
|
+
}
|
|
77
|
+
return payload;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const res = await recordDirectExpenseShared({
|
|
81
|
+
expense_account_id: expenseAccountId,
|
|
82
|
+
transaction_date: transactionDate,
|
|
83
|
+
description: description || null,
|
|
84
|
+
reference: reference || null,
|
|
85
|
+
payments: formattedPayments,
|
|
86
|
+
attachments: selectedFiles,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (res.is_success) {
|
|
90
|
+
toast.success("Direct expense recorded successfully");
|
|
91
|
+
onSuccess();
|
|
92
|
+
handleClose();
|
|
93
|
+
} else {
|
|
94
|
+
toast.error(res.message || "Failed to record expense");
|
|
95
|
+
}
|
|
96
|
+
} catch (e: any) {
|
|
97
|
+
toast.error(e.message || "An error occurred");
|
|
98
|
+
} finally {
|
|
99
|
+
setIsSubmitting(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleClose = () => {
|
|
104
|
+
setTransactionDate(new Date().toISOString().split("T")[0]);
|
|
105
|
+
setExpenseAccountId("");
|
|
106
|
+
setTotalAmount("");
|
|
107
|
+
setReference("");
|
|
108
|
+
setDescription("");
|
|
109
|
+
setPayments([]);
|
|
110
|
+
setSelectedFiles([]);
|
|
111
|
+
onClose();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (!isOpen) return null;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Modal
|
|
118
|
+
isOpen={isOpen}
|
|
119
|
+
onClose={handleClose}
|
|
120
|
+
title="Record Direct Expense"
|
|
121
|
+
size="md"
|
|
122
|
+
>
|
|
123
|
+
<form onSubmit={handleSubmit} className="space-y-5">
|
|
124
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
125
|
+
<div className="flex flex-col gap-1.5">
|
|
126
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Date *</label>
|
|
127
|
+
<Input
|
|
128
|
+
type="date"
|
|
129
|
+
value={transactionDate}
|
|
130
|
+
onChange={(e) => setTransactionDate(e.target.value)}
|
|
131
|
+
required
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="flex flex-col gap-1.5">
|
|
135
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Expense Account *</label>
|
|
136
|
+
<ChartOfAccountPicker
|
|
137
|
+
value={expenseAccountId}
|
|
138
|
+
onChange={(val) => setExpenseAccountId(val)}
|
|
139
|
+
fetchAccounts={getChartOfAccountsLookupShared}
|
|
140
|
+
filterType="expense"
|
|
141
|
+
label=""
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div className="flex flex-col gap-1.5">
|
|
147
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Total Amount *</label>
|
|
148
|
+
<Input
|
|
149
|
+
type="number"
|
|
150
|
+
step="0.01"
|
|
151
|
+
min="0"
|
|
152
|
+
value={totalAmount}
|
|
153
|
+
onChange={(e) => setTotalAmount(e.target.value)}
|
|
154
|
+
placeholder="0.00"
|
|
155
|
+
required
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{parseFloat(totalAmount) > 0 && (
|
|
160
|
+
<div className="border border-border-subtle rounded-xl p-4 bg-gray-50/50">
|
|
161
|
+
<PaymentSection
|
|
162
|
+
totalAmount={parseFloat(totalAmount) || 0}
|
|
163
|
+
payments={payments}
|
|
164
|
+
onPaymentsChange={setPayments}
|
|
165
|
+
fetchPaymentModes={async () => {
|
|
166
|
+
const res = await getPaymentModesShared();
|
|
167
|
+
if (res.is_success && Array.isArray(res.result)) {
|
|
168
|
+
res.result = res.result.filter((m: any) => {
|
|
169
|
+
const code = m.code?.toLowerCase();
|
|
170
|
+
return code !== "credit" && code !== "credit-metal" && code !== "credit-gold" && code !== "cheque";
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return res;
|
|
174
|
+
}}
|
|
175
|
+
fetchBankAccounts={getBankAccountsLookupShared}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
181
|
+
<div className="flex flex-col gap-1.5">
|
|
182
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Reference No</label>
|
|
183
|
+
<Input
|
|
184
|
+
value={reference}
|
|
185
|
+
onChange={(e) => setReference(e.target.value)}
|
|
186
|
+
placeholder="e.g. Receipt #1234"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="flex flex-col gap-1.5">
|
|
190
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Description</label>
|
|
191
|
+
<Input
|
|
192
|
+
value={description}
|
|
193
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
194
|
+
placeholder="What was this for?"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Attachments */}
|
|
200
|
+
<div className="flex flex-col gap-1.5">
|
|
201
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Attachments</label>
|
|
202
|
+
<div
|
|
203
|
+
className="border-2 border-dashed border-gray-200 rounded-xl p-4 flex flex-col items-center justify-center bg-gray-50/50 hover:bg-gray-100 transition-colors cursor-pointer group"
|
|
204
|
+
onClick={() => fileInputRef.current?.click()}
|
|
205
|
+
>
|
|
206
|
+
<input
|
|
207
|
+
type="file"
|
|
208
|
+
multiple
|
|
209
|
+
className="hidden"
|
|
210
|
+
ref={fileInputRef}
|
|
211
|
+
onChange={(e) => {
|
|
212
|
+
if (e.target.files) {
|
|
213
|
+
setSelectedFiles(prev => [...prev, ...Array.from(e.target.files!)]);
|
|
214
|
+
}
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
<div className="w-8 h-8 bg-white rounded-full shadow-sm flex items-center justify-center text-gray-500 mb-2 group-hover:scale-110 transition-transform">
|
|
218
|
+
<Upload size={14} />
|
|
219
|
+
</div>
|
|
220
|
+
<p className="text-xs font-bold text-gray-700">Upload receipts or documents</p>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{selectedFiles.length > 0 && (
|
|
224
|
+
<div className="flex flex-wrap gap-2 mt-2">
|
|
225
|
+
{selectedFiles.map((file, index) => (
|
|
226
|
+
<div key={`new-${index}`} className="relative bg-gray-100 rounded-md px-2 py-1 flex items-center gap-2 border border-gray-200 text-xs">
|
|
227
|
+
<span className="truncate max-w-[120px]" title={file.name}>{file.name}</span>
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
onClick={() => setSelectedFiles(prev => prev.filter((_, i) => i !== index))}
|
|
231
|
+
className="text-red-500 hover:text-red-700"
|
|
232
|
+
>
|
|
233
|
+
<X size={12} />
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<ModalFooter>
|
|
242
|
+
<Button type="button" variant="flat" color="default" onClick={handleClose}>Cancel</Button>
|
|
243
|
+
<Button type="submit" color="primary" isLoading={isSubmitting}>Record Expense</Button>
|
|
244
|
+
</ModalFooter>
|
|
245
|
+
</form>
|
|
246
|
+
</Modal>
|
|
247
|
+
);
|
|
248
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -79,3 +79,4 @@ export * from './common-components/attendance-shifts/AttendanceTimesheets';
|
|
|
79
79
|
export * from "./base-components/ChartOfAccountPicker";
|
|
80
80
|
export * from './common-components/print/TemplateRenderer';
|
|
81
81
|
export { default as TransactionPrintSelector } from './common-components/print/TransactionPrintSelector';
|
|
82
|
+
export { default as DirectExpenseModal } from "./finance-components/DirectExpenseModal";
|