@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.
@@ -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";