@apptimate/ui 7.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -14,11 +14,12 @@ interface BulkAttendanceModalProps {
14
14
  onClose: () => void;
15
15
  onSuccess: () => void;
16
16
  fetchProjects?: () => Promise<any>;
17
+ fetchAccounts?: (type?: string) => Promise<any>;
17
18
  initialProjectId?: string;
18
19
  isFixedProject?: boolean;
19
20
  }
20
21
 
21
- export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects, initialProjectId, isFixedProject }: BulkAttendanceModalProps) {
22
+ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects, fetchAccounts, initialProjectId, isFixedProject }: BulkAttendanceModalProps) {
22
23
  const [projects, setProjects] = useState<any[]>([]);
23
24
  const [projectId, setProjectId] = useState<string>(initialProjectId || "");
24
25
  const [attendanceDate, setAttendanceDate] = useState<string>(new Date().toISOString().split("T")[0]);
@@ -26,6 +27,8 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
26
27
  const [globalEmployees, setGlobalEmployees] = useState<any[]>([]);
27
28
  const [isLoadingEmployees, setIsLoadingEmployees] = useState(false);
28
29
  const [isSubmitting, setIsSubmitting] = useState(false);
30
+ const [accounts, setAccounts] = useState<any[]>([]);
31
+ const [paymentAccountId, setPaymentAccountId] = useState<string>("");
29
32
 
30
33
  // Time entries state: Record<employee_id, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>
31
34
  const [timeEntries, setTimeEntries] = useState<Record<string, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>>({});
@@ -34,8 +37,16 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
34
37
  if (isOpen) {
35
38
  loadProjects();
36
39
  loadGlobalEmployees();
40
+ if (fetchAccounts) {
41
+ fetchAccounts("asset").then((res) => {
42
+ if (res.is_success && res.result) {
43
+ setAccounts(res.result);
44
+ }
45
+ });
46
+ }
37
47
  setAttendanceDate(new Date().toISOString().split("T")[0]);
38
48
  setProjectId(initialProjectId || "");
49
+ setPaymentAccountId("");
39
50
  setTimeEntries({});
40
51
 
41
52
  // If initialProjectId is already set, the second useEffect won't trigger because projectId hasn't changed.
@@ -178,7 +189,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
178
189
  employees.forEach(emp => {
179
190
  const entry = timeEntries[emp.id];
180
191
 
181
- if (!entry || !entry.isPresent) return;
192
+ if (!entry) return;
182
193
 
183
194
  if (String(emp.id).startsWith('wf-')) {
184
195
  // It's a workforce gang worker
@@ -186,29 +197,45 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
186
197
  const workforceId = parts[1];
187
198
  const memberIdentifier = parts[2] || null;
188
199
 
189
- if (entry.in || entry.out) {
200
+ if (entry.isPresent) {
201
+ if (entry.in || entry.out) {
202
+ workforcePunches.push({
203
+ workforce_id: Number(workforceId),
204
+ project_id: Number(projectId),
205
+ date: attendanceDate,
206
+ member_identifier: memberIdentifier,
207
+ punch_in_time: entry.in || null,
208
+ punch_out_time: entry.out || null,
209
+ break_hours: entry.break || 0,
210
+ salary_advance: entry.salaryAdvance || 0,
211
+ });
212
+ } else if (entry.salaryAdvance > 0) {
213
+ // If they only have a salary advance but no punch
214
+ workforcePunches.push({
215
+ workforce_id: Number(workforceId),
216
+ project_id: Number(projectId),
217
+ date: attendanceDate,
218
+ member_identifier: memberIdentifier,
219
+ salary_advance: entry.salaryAdvance,
220
+ });
221
+ }
222
+ } else {
223
+ // ABSENT
190
224
  workforcePunches.push({
191
225
  workforce_id: Number(workforceId),
192
226
  project_id: Number(projectId),
193
227
  date: attendanceDate,
194
228
  member_identifier: memberIdentifier,
195
- punch_in_time: entry.in || null,
196
- punch_out_time: entry.out || null,
197
- break_hours: entry.break || 0,
229
+ punch_in_time: null,
230
+ punch_out_time: null,
231
+ break_hours: 0,
198
232
  salary_advance: entry.salaryAdvance || 0,
199
233
  });
200
- } else if (entry.salaryAdvance > 0) {
201
- // If they only have a salary advance but no punch
202
- workforcePunches.push({
203
- workforce_id: Number(workforceId),
204
- project_id: Number(projectId),
205
- date: attendanceDate,
206
- member_identifier: memberIdentifier,
207
- salary_advance: entry.salaryAdvance,
208
- });
209
234
  }
210
235
  } else {
211
236
  // It's a standard HR employee
237
+ if (!entry.isPresent) return; // Skip absent for HR for now
238
+
212
239
  if (entry.in) {
213
240
  hrPunches.push({
214
241
  employee_id: emp.id,
@@ -251,7 +278,10 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
251
278
 
252
279
  // Log HR punches
253
280
  if (hrPunches.length > 0) {
254
- const hrRes = await bulkPunchAttendance({ punches: hrPunches });
281
+ const hrRes = await bulkPunchAttendance({
282
+ punches: hrPunches,
283
+ payment_account_id: paymentAccountId ? Number(paymentAccountId) : undefined
284
+ });
255
285
  if (!hrRes.is_success) {
256
286
  toast.error(hrRes.message || "Failed to log HR attendance");
257
287
  hasError = true;
@@ -262,7 +292,14 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
262
292
  if (workforcePunches.length > 0) {
263
293
  // use sendRequest from core-lib which is imported at the top of this file
264
294
  const { sendRequest } = require("@apptimate/core-lib");
265
- const wfRes = await sendRequest({ url: "/api/construction/workforce-attendance/bulk-punch", method: "POST", data: { punches: workforcePunches } });
295
+ const wfRes = await sendRequest({
296
+ url: "/api/construction/workforce-attendance/bulk-punch",
297
+ method: "POST",
298
+ data: {
299
+ punches: workforcePunches,
300
+ payment_account_id: paymentAccountId ? Number(paymentAccountId) : undefined
301
+ }
302
+ });
266
303
  if (!wfRes.responseData?.is_success) {
267
304
  toast.error(wfRes.responseData?.message || "Failed to log Workforce attendance");
268
305
  hasError = true;
@@ -286,7 +323,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
286
323
  return (
287
324
  <Modal isOpen={isOpen} onClose={onClose} title="Bulk Attendance Log" size="2xl">
288
325
  <div className="space-y-4 py-2">
289
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
326
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
290
327
  <div>
291
328
  <label className="block text-sm font-medium text-gray-700 mb-1">Date <span className="text-danger-500">*</span></label>
292
329
  <Input
@@ -298,20 +335,28 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
298
335
  <div>
299
336
  <label className="block text-sm font-medium text-gray-700 mb-1">Project <span className="text-danger-500">*</span></label>
300
337
  <Select
338
+ options={projects.map((p) => ({ value: String(p.id), label: p.name }))}
301
339
  value={projectId}
302
340
  onChange={(e) => setProjectId(e.target.value)}
303
341
  disabled={isFixedProject}
304
- options={[
305
- { value: "", label: "Select Project..." },
306
- ...projects.map((p) => ({
307
- value: String(p.id),
308
- label: p.name
309
- }))
310
- ]}
311
342
  />
312
343
  </div>
344
+ {fetchAccounts && (
345
+ <div>
346
+ <label className="block text-sm font-medium text-gray-700 mb-1">Payment Account <span className="text-gray-400 font-normal text-xs">(Salary Advances)</span></label>
347
+ <Select
348
+ options={[
349
+ { value: "", label: "Default mapping" },
350
+ ...accounts.map((a) => ({ value: String(a.id), label: `${a.code} - ${a.name}` }))
351
+ ]}
352
+ value={paymentAccountId}
353
+ onChange={(e) => setPaymentAccountId(e.target.value)}
354
+ />
355
+ </div>
356
+ )}
313
357
  </div>
314
358
 
359
+
315
360
  {projectId && attendanceDate && (
316
361
  <div className="mt-6 border border-gray-100 rounded-xl overflow-hidden bg-white shadow-sm">
317
362
  <Table>
@@ -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";